/** * Logistik Europa — Engine (Skelett) * ------------------------------------------------------------------ * Headless Simulationslogik ohne DOM, ohne Rendering. Die UI * (game.html, test.html) ruft Methoden auf und verarbeitet * zurückgegebene Events selbst. * * Exportiert als globales `window.LogistikEngine` (kein ES-Module, * kein Bundler). * * Dieses Skelett enthält: * - State-Machine-Enum (aus Pflichtenheft Kap 44) * - Enum-Konstanten für Fahrzeugmodi, Vertrags-/Fahrzeug-Zustände, * Ereignistypen, Hilfestufen (Kap 42) * - Platzhalter-Funktionen für createGame, tick, assignContract, * calculateRoute, useHint, applyMinigameResult * - Test-Hooks für die Regressionstests aus dem Kickoff-Briefing * * Phase 0 (Balance-Matrix + Test-Harness) soll dieses Skelett nutzen, * aber die Implementierung bleibt bewusst Stub, damit die Tests * zuerst das Contract testen (TDD-ähnlich). */ (function () { 'use strict'; /* ============================================================ ENUMS (aus Pflichtenheft Kap 42, 44) ============================================================ */ const STATE = Object.freeze({ INIT: 'INIT', LOADING_CONTENT: 'LOADING_CONTENT', READY: 'READY', PLANNING: 'PLANNING', RUNNING: 'RUNNING', PAUSED_BY_EVENT: 'PAUSED_BY_EVENT', PAUSED_BY_ARRIVAL: 'PAUSED_BY_ARRIVAL', MINIGAME: 'MINIGAME', LEVEL_SUCCESS: 'LEVEL_SUCCESS', LEVEL_FAILED: 'LEVEL_FAILED', SAVING: 'SAVING', ERROR: 'ERROR', }); const VEHICLE_MODE = Object.freeze({ TRUCK_SMALL: 'TRUCK_SMALL', TRUCK_LARGE: 'TRUCK_LARGE', TRAIN: 'TRAIN', }); const VEHICLE_STATE = Object.freeze({ IDLE: 'IDLE', RESERVED: 'RESERVED', MOVING: 'MOVING', LOADING: 'LOADING', UNLOADING: 'UNLOADING', WAITING: 'WAITING', BLOCKED: 'BLOCKED', BROKEN: 'BROKEN', }); const CONTRACT_STATE = Object.freeze({ OFFERED: 'OFFERED', // L1-Progression: Angebot, muss erst akzeptiert werden OPEN: 'OPEN', RESERVED: 'RESERVED', ASSIGNED: 'ASSIGNED', PICKUP_PENDING: 'PICKUP_PENDING', LOADING: 'LOADING', IN_TRANSIT: 'IN_TRANSIT', AT_TRANSFER: 'AT_TRANSFER', DELIVERED: 'DELIVERED', LATE: 'LATE', FAILED: 'FAILED', CANCELLED: 'CANCELLED', EXPIRED: 'EXPIRED', // OFFERED abgelaufen / abgelehnt }); const LOCATION_TYPE = Object.freeze({ CITY: 'CITY', CAPITAL: 'CAPITAL', PORT: 'PORT', TERMINAL: 'TERMINAL', INDUSTRY: 'INDUSTRY', CUSTOMER: 'CUSTOMER', HUB: 'HUB', }); const EVENT_TYPE = Object.freeze({ TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT', SNOW: 'SNOW', STORM: 'STORM', PORT_DELAY: 'PORT_DELAY', RAIL_STRIKE: 'RAIL_STRIKE', SPECIAL_ORDER: 'SPECIAL_ORDER', MAINTENANCE: 'MAINTENANCE', }); const HINT_MODE = Object.freeze({ BLINK_EXACT: 'BLINK_EXACT', SHOW_COUNTRY: 'SHOW_COUNTRY', SHOW_REGION: 'SHOW_REGION', DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK', NONE: 'NONE', }); /* ============================================================ KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65 Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt sie ggf. pro Level. ============================================================ */ const VEHICLE_DEFAULTS = Object.freeze({ TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5, rentMult: 1.0 }, TRUCK_MID: { speedKmh: 65, capacity: 2, costPerKm: 1.6, costPerHour: 30, loadMinutes: 6, unloadMinutes: 6, rentMult: 1.0 }, TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8, rentMult: 1.4 }, TRAIN_SMALL: { speedKmh: 80, capacity: 6, costPerKm: 5, costPerHour: 90, loadMinutes: 15, unloadMinutes: 15, rentMult: 3.0 }, TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20, rentMult: 4.0 }, }); /* ============================================================ ID-Generierung — garantiert eindeutig pro Game-Session. Monotoner Counter (deterministisch fuer Replay), zusaetzlich Collision-Check nach jedem Push. ============================================================ */ function _nextUniqId(game) { if (!game._uniqIdCounter) game._uniqIdCounter = 0; return ++game._uniqIdCounter; } /** Wirft eine Warnung in die Konsole, wenn doppelte IDs auftauchen. * Greift defensiv — ein Bug schluepft so nicht unbemerkt durch. */ function _validateUniqueIds(game) { const groups = [ ['Contract', game.activeContracts || []], ['Vehicle', game.vehicles || []], ['Route', game.activeRoutes || []], ]; for (const [label, list] of groups) { const seen = new Set(); for (const item of list) { if (!item || !item.id) continue; if (seen.has(item.id)) { console.error('[Logistik] DOPPELTE ' + label + '-ID erkannt:', item.id, item); return false; } seen.add(item.id); } } return true; } const ECONOMY = Object.freeze({ standCostContainerPerHour: 10, // Container im Hafen standCostVehicleIdlePerHour: 5, latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts punctualBonusPercent: 0.1, // +10% optimalBonusPercent: 0.2, // +20% (keine Leerfahrt) standardContractBase: 1000, expressContractBase: 1500, }); const EVENT_RULES = Object.freeze({ TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 }, SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] }, PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 }, }); /* ============================================================ FACTORIES ============================================================ */ /** * Erzeugt einen neuen Spielzustand für das gegebene Level. * Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults. * * Tests/Headless-Runs können `opts.deterministic = true` setzen, dann * werden `sessionId` und `simulationTime` reproduzierbar (Seed-basiert). * * @param {number} levelNum * @param {{deterministic?:boolean, seed?:number}} [opts] * @returns {Object} gameState */ function createGame(levelNum, opts) { const o = opts || {}; const levelCfg = (window.LOGISTIK_LEVELS || []).find(l => l.level === levelNum)?.params || {}; const startIso = o.deterministic ? '2026-01-01T08:00:00.000Z' : new Date().toISOString(); return { seed: o.seed || _hashStr('default-seed-' + levelNum), sessionId: o.deterministic ? ('sess_' + (o.seed || 0)) : _uuid(), levelId: 'level_' + levelNum, state: STATE.INIT, simulationTime: startIso, simulationStartTime: startIso, timeScale: levelCfg.initialTimeScale ?? 1, paused: true, balance: levelCfg.startBudget ?? 5000, score: 0, activeContracts: [], completedContracts: [], vehicles: [], activeRoutes: [], activeEvents: [], pendingNotifications: [], pendingAnalytics: [], usedHints: [], analytics: { totalKm: 0, emptyKm: 0, lateDeliveries: 0, hintUsages: 0, misclicks: 0, }, progression: { round: 1, // Aktuelle Runde, +1 nach jedem abgeschlossenen Auftrag trucksBought: 0, // Wie viele Fahrzeuge wurden auto-/manuell dazugekauft }, pendingBonuses: [], // Ladebonus-Stack aus Minispielen: [{id, minutes, source, createdAt}] worldState: {}, version: '0.1.0', _pendingEvents: [], _config: levelCfg, _stateChangeListeners: [], // nicht-serialisiert (siehe serialize()) }; } /* ============================================================ LIFECYCLE — Phase 1 (State-Maschinen-Übergänge) ============================================================ */ /** * Setzt einen neuen State und ruft alle registrierten Listener * (z.B. Autosave-Hooks gemäß Plattform-Konvention 7b). * @private */ function _setState(game, newState, reason) { if (game.state === newState) return; const oldState = game.state; game.state = newState; const evt = { from: oldState, to: newState, reason: reason || null, at: game.simulationTime }; (game._stateChangeListeners || []).forEach(cb => { try { cb(evt, game); } catch (e) { /* Listener-Fehler isolieren */ } }); } /** * Registriert einen State-Change-Listener (z.B. für Autosave). * Listener-Signatur: `({from, to, reason, at}, game) => void`. * @returns {Function} unregister-Funktion */ function onStateChange(game, callback) { if (!game._stateChangeListeners) game._stateChangeListeners = []; game._stateChangeListeners.push(callback); return function unregister() { const idx = game._stateChangeListeners.indexOf(callback); if (idx !== -1) game._stateChangeListeners.splice(idx, 1); }; } /** * INIT → LOADING_CONTENT → READY (synchron, weil Seeds bereits via * window.LOGISTIK_SEEDS injiziert sind). */ function loadContent(game, seeds) { if (game.state !== STATE.INIT) { throw new Error('loadContent erwartet state=INIT, ist aber ' + game.state); } _setState(game, STATE.LOADING_CONTENT, 'loadContent'); const ws = window.LOGISTIK_SEEDS || {}; game.worldState.locations = (seeds && seeds.locations) || ws.locations || []; game.worldState.vehicleTypes = (seeds && seeds.vehicleTypes) || ws.vehicleTypes || []; game.worldState.cargoTypes = (seeds && seeds.cargoTypes) || ws.cargoTypes || []; // Robust: PHP-Wrapper liefert '[]' bei fehlender Datei -> truthy Array // ohne .nodes. Defensiv normalisieren auf {nodes, edges}. const _normNet = (n) => (n && Array.isArray(n.nodes) && Array.isArray(n.edges)) ? n : { nodes: [], edges: [] }; game.worldState.railnet = _normNet((seeds && seeds.railnet) || ws.railnet); game.worldState.roadnet = _normNet((seeds && seeds.roadnet) || ws.roadnet); // Phase 3: Hand-gepflegte Polylines (lg-routes-osm.json) const rOsm = (seeds && seeds.routesOsm) || ws.routesOsm || {}; game.worldState.routesOsm = Array.isArray(rOsm) ? rOsm : (rOsm.routes || []); // Phase 8d: R-1 Startbudget-Runway-Pruefung (console.warn, nicht blockierend) _validateRuleR1(game._config || {}); _setState(game, STATE.READY, 'content_loaded'); return game; } /** Regel R-1 (Startbudget-Runway): ein Level muss 1.5 Tage „Luft" bieten. * startBudget >= 1.5 * (rentalCostPerDay*vc + idleCostPerHour*vc*12) * Verletzungen loggen, Spiel nicht abbrechen (Lehrkraft kann bewusst * aendern, aber wir weisen in der Konsole darauf hin). */ function _validateRuleR1(cfg) { const vc = cfg.vehicleCount || 1; const rental = cfg.rentalCostPerDay || 0; const idle = cfg.idleCostPerHour || 0; const daily = rental * vc + idle * vc * 12; const needed = 1.5 * daily; const have = cfg.startBudget || 0; if (have < needed) { console.warn( '[Logistik R-1] Level verletzt Startbudget-Runway: ' + 'startBudget=' + have + '€, benoetigt >= ' + Math.ceil(needed) + '€ ' + '(Formel: 1.5 × (' + rental + '×' + vc + ' + ' + idle + '×' + vc + '×12 = ' + daily + '/Tag)). ' + 'Spieler*in wird in Tag 1 im Minus landen. Fix: startBudget hoeher oder vehicleCount/rentalCostPerDay runter.' ); } } /** READY → PLANNING (Bearbeiter*in disponiert, Zeit steht still). */ function startPlanning(game) { if (game.state !== STATE.READY) { throw new Error('startPlanning erwartet state=READY, ist aber ' + game.state); } _setState(game, STATE.PLANNING, 'ui_ready'); return game; } /** PLANNING/PAUSED_BY_* → RUNNING. Setzt paused=false. */ function startSimulation(game) { const allowed = [STATE.PLANNING, STATE.PAUSED_BY_EVENT, STATE.PAUSED_BY_ARRIVAL, STATE.READY]; if (allowed.indexOf(game.state) === -1) { throw new Error('startSimulation erwartet state in {' + allowed.join(',') + '}, ist aber ' + game.state); } game.paused = false; _setState(game, STATE.RUNNING, 'sim_started'); return game; } /** RUNNING → paused (state bleibt RUNNING, nur Flag). User-Pause. */ function pause(game) { game.paused = true; return game; } /** Aufhebung der User-Pause. */ function resume(game) { game.paused = false; return game; } /** * Setzt die Zeitskala. Pflichtwerte: 1 / 2 / 4 / 8 (PH 14.6, 65.4). * `0` ist erlaubt als Software-Pause-Äquivalent (selten gebraucht). */ function setTimeScale(game, scale) { if (typeof scale !== 'number' || scale < 0) { throw new Error('setTimeScale: ungültige scale ' + scale); } game.timeScale = scale; return game; } /* ============================================================ TICK — Phase 1 (Sim-Zeit läuft, noch keine Vehicle/Contract-Mutation) ============================================================ */ /** * Konvertiert Echtzeit-Delta in Sim-Minuten. * Verhältnis: 1× = 1 Sim-Minute pro Echt-Sekunde (PH 65.4). * @param {number} deltaMs * @param {number} timeScale * @returns {number} Sim-Minuten (kann float sein) */ function convertRealTimeToGameMinutes(deltaMs, timeScale) { return (deltaMs / 1000) * (timeScale || 1); } /** * Sim-Stunden seit Start. * @param {Object} game * @returns {number} */ function getSimHoursElapsed(game) { const start = new Date(game.simulationStartTime).getTime(); const now = new Date(game.simulationTime).getTime(); return (now - start) / (1000 * 60 * 60); } /** * Tick-Pipeline (PH 45.3) — Phase-1-Ausbau: * 1. SimulationTime erhöhen (immer) * 2. updateEvents — Phase 5 (Stub) * 3. updateVehicles — Phase 2 (Stub) * 4. updateLoading — Phase 3 (Stub) * 5. updateContracts — Phase 2 (Stub) * 6. applyEconomy — Phase 3 (Stub) * 7. generateNotifications — Phase 1 (kein Output) * 8. evaluateConditions — Phase 1: nur Zeit-Limit prüfen * * Tut nichts wenn paused oder state != RUNNING. * * @param {Object} game * @param {number} deltaMs Echtzeit-Delta seit letztem Tick * @returns {Object} game */ function tick(game, deltaMs) { if (game.state !== STATE.RUNNING || game.paused) return game; const simMinutes = convertRealTimeToGameMinutes(deltaMs, game.timeScale); if (simMinutes <= 0) return game; // timeScale=0 → Pause-Äquivalent const dt = new Date(game.simulationTime); dt.setTime(dt.getTime() + simMinutes * 60 * 1000); game.simulationTime = dt.toISOString(); // Phase 2: Vehicle-Bewegung (Routen-Interpolation, Delivery, Erlös) _updateVehicles(game, simMinutes); // Phase 3: Laufende Kosten (Standkosten IDLE/h, Miete/Tag pro Vehicle) _applyRunningCosts(game, simMinutes); // Phase 4: Hafen-Schiffsankünfte (deterministisch, periodisch) _maybeGenerateShipArrival(game); // Phase 5: Events generieren + auslaufen lassen _maybeGenerateEvents(game, simMinutes); _expireEvents(game); // Phase 8e: OFFERED-Angebote auslaufen lassen (stille Entfernung) _expireOffers(game); // Phase 8e: Refill-Logik. _maybeGenerateFollowupContract routet selbst: // L1 -> _maybeRefillOffers + Auto-Buy, L2 -> Pool + Auto-Retoure. // (Vorher nur _maybeRefillOffers -> L2-Slot-Lücken bei Massen-RESERVED.) _maybeGenerateFollowupContract(game); // Bankrott-Regel (Thomas 2026-04-24): balance <= 0 -> sofort LEVEL_FAILED. // Greift auf allen Levels. Gilt unabhaengig vom Zeitlimit. if ((game.balance || 0) <= 0) { _setState(game, STATE.LEVEL_FAILED, 'bankrupt'); return game; } // Auswertung: Zeit-Limit erreicht -> Abrechnung. // Neue Regel: Erfolg = nicht-pleite (balance > 0). Kein Min-Ziel mehr, // stattdessen Sterne-Bewertung in der UI basierend auf Endbilanz. const limit = game._config && game._config.timeLimitHours; if (typeof limit === 'number' && getSimHoursElapsed(game) >= limit) { _setState(game, (game.balance || 0) > 0 ? STATE.LEVEL_SUCCESS : STATE.LEVEL_FAILED, (game.balance || 0) > 0 ? 'time_limit_solvent' : 'time_limit_bankrupt' ); } // Kein vorzeitiger SUCCESS mehr, wenn alle Auftraege fertig sind — // Thomas will die volle Spielzeit fuer maximale Endbilanz nutzen. return game; } /* ============================================================ SERIALIZE / DESERIALIZE (Autosave-Vorbereitung Plattform 7b) ============================================================ */ /** * Liefert eine JSON-serialisierbare Kopie ohne nicht-serialisierbare * Felder (Listener, Polylines mit Funktionen etc.). */ function serialize(game) { const clone = JSON.parse(JSON.stringify(game, (key, value) => { if (key === '_stateChangeListeners') return undefined; return value; })); return clone; } /** Reaktiviert Listener-Array nach Deserialisierung. */ function deserialize(json) { const game = (typeof json === 'string') ? JSON.parse(json) : json; if (!game._stateChangeListeners) game._stateChangeListeners = []; return game; } /* ============================================================ PLACEHOLDER / STUB FUNKTIONEN (Logistik-Instanz implementiert diese in Phase 1 – 7) ============================================================ */ /* ============================================================ PHASE 2 — calculateRoute, assignContract, Vehicle-Bewegung ============================================================ */ /** * Berechnet eine Route. Phase-3-Strategie: * 1. Lookup in `worldState.routesOsm`. Wenn Polyline (auch invers) * gefunden → nutze die Hand-gepflegte Polyline. * 2. Fallback: Luftlinie × 1.3 (Phase-2-Modus). * * @param {Object} game * @param {string} originId Location-ID * @param {string} targetId Location-ID * @param {'TRUCK_SMALL'|'TRUCK_LARGE'|'TRAIN'} mode * @returns {Object} Route mit Geometrie, Distanz, Dauer */ function calculateRoute(game, originId, targetId, mode) { const veh = VEHICLE_DEFAULTS[mode]; if (!veh) throw new Error('Unbekannter Fahrzeugmodus: ' + mode); const locs = (game.worldState && game.worldState.locations) || []; const o = locs.find(l => l.id === originId); const t = locs.find(l => l.id === targetId); if (!o) throw new Error('Origin nicht gefunden: ' + originId); if (!t) throw new Error('Target nicht gefunden: ' + targetId); let geometry = null; let provider = 'airline_x1.3'; let distanceKm; let segments = null; // Phase 4: TRAIN nutzt railnet-Dijkstra (wenn beide Locations Bahnknoten sind) if (mode === 'TRAIN') { const rail = (game.worldState && game.worldState.railnet) || { nodes: [], edges: [] }; const node = (locId) => rail.nodes.find(n => n.locationId === locId); if (node(originId) && node(targetId)) { const dij = railShortestPath( { nodes: rail.nodes, edges: rail.edges }, node(originId).id, node(targetId).id ); if (dij && dij.path) { // Edge-Polylines ausrollen (analog zur Roadnet-Logik), damit auf // der Karte echte Bahnkorridor-Kurven statt Luftlinie dargestellt // werden. Edge-Polyline kann andersherum gespeichert sein -> reverse. geometry = []; segments = []; for (let i = 0; i < dij.path.length - 1; i++) { const a = rail.nodes.find(n => n.id === dij.path[i]); const b = rail.nodes.find(n => n.id === dij.path[i + 1]); const edge = rail.edges.find(e => (e.from === a.id && e.to === b.id) || (e.from === b.id && e.to === a.id)); const la = locs.find(l => l.id === a.locationId); const lb = locs.find(l => l.id === b.locationId); let edgePoly = edge && edge.polyline ? edge.polyline.slice() : null; if (edgePoly && edge.from === b.id && edge.to === a.id) edgePoly.reverse(); if (!edgePoly) edgePoly = [[la.lat, la.lon], [lb.lat, lb.lon]]; if (i === 0) geometry.push(...edgePoly); else geometry.push(...edgePoly.slice(1)); segments.push({ sequenceNo: i, segmentType: 'RAIL', startLat: la.lat, startLon: la.lon, endLat: lb.lat, endLon: lb.lon, distanceKm: edge ? edge.distanceKm : 0, durationMinutes: edge && edge.durationMinutes ? edge.durationMinutes : ((edge ? edge.distanceKm : 0) / veh.speedKmh * 60), }); } distanceKm = dij.distanceKm; provider = 'rail_dijkstra_polyline'; } } } // Phase 8e+: TRUCK_* nutzt Straßennetz-Dijkstra — fährt via sinnvolle // Zwischenstationen (z.B. Salzburg→Berlin via München) statt Luftlinie. // Option C: jede Kante kann eine hand-kuratierte Polyline haben, die die // echte Autobahn-Fuehrung abbildet (keine Knotengerade mehr). if (!geometry && mode !== 'TRAIN') { const road = (game.worldState && game.worldState.roadnet) || { nodes: [], edges: [] }; const roadNode = (locId) => road.nodes.find(n => n.locationId === locId); if (road.nodes.length && roadNode(originId) && roadNode(targetId)) { const dij = railShortestPath( { nodes: road.nodes, edges: road.edges }, roadNode(originId).id, roadNode(targetId).id ); if (dij && dij.path && dij.distanceKm) { geometry = []; segments = []; for (let i = 0; i < dij.path.length - 1; i++) { const a = road.nodes.find(n => n.id === dij.path[i]); const b = road.nodes.find(n => n.id === dij.path[i + 1]); const edge = road.edges.find(e => (e.from === a.id && e.to === b.id) || (e.from === b.id && e.to === a.id)); const la = locs.find(l => l.id === a.locationId); const lb = locs.find(l => l.id === b.locationId); // Polyline in Richtung a→b holen. Kante kann andersherum gespeichert sein. let edgePoly = edge && edge.polyline ? edge.polyline.slice() : null; if (edgePoly && edge.from === b.id && edge.to === a.id) edgePoly.reverse(); if (!edgePoly) edgePoly = [[la.lat, la.lon], [lb.lat, lb.lon]]; // Fuer die erste Kante: gesamte Polyline. Fuer Folgende: ohne // ersten Punkt (Endpunkt der vorigen Kante), um Duplikate zu // vermeiden. if (i === 0) geometry.push(...edgePoly); else geometry.push(...edgePoly.slice(1)); segments.push({ sequenceNo: i, segmentType: 'ROAD', startLat: la.lat, startLon: la.lon, endLat: lb.lat, endLon: lb.lon, distanceKm: edge ? edge.distanceKm : 0, durationMinutes: (edge ? edge.distanceKm : 0) / veh.speedKmh * 60, routeCode: edge && edge.routeCode, }); } distanceKm = dij.distanceKm; provider = 'road_dijkstra_polyline'; } } } // Phase 3: Polyline-Lookup für Straße (oder TRAIN-Fallback wenn kein Bahnknoten) if (!geometry) { const osmRoutes = (game.worldState && game.worldState.routesOsm) || []; for (const r of osmRoutes) { if (r.originLocationId === originId && r.targetLocationId === targetId) { geometry = r.polyline.slice(); provider = 'osm_polyline'; break; } if (r.originLocationId === targetId && r.targetLocationId === originId) { geometry = r.polyline.slice().reverse(); provider = 'osm_polyline_reversed'; break; } } if (geometry) { distanceKm = _polylineLength(geometry); } else { // Fallback Luftlinie × 1.3 (Phase 2) const airlineKm = _segmentLength([o.lat, o.lon], [t.lat, t.lon]); distanceKm = airlineKm * 1.3; geometry = [[o.lat, o.lon], [t.lat, t.lon]]; } } const durationMinutes = (distanceKm / veh.speedKmh) * 60; return { id: 'route_' + originId + '_' + targetId + '_' + mode + '_' + _nextUniqId(game), originLocationId: originId, targetLocationId: targetId, mode, provider, totalDistanceKm: distanceKm, totalDurationMinutes: durationMinutes, geometry, segments: segments || [{ sequenceNo: 0, segmentType: mode === 'TRAIN' ? 'RAIL' : 'ROAD', startLat: o.lat, startLon: o.lon, endLat: t.lat, endLon: t.lon, distanceKm, durationMinutes, }], }; } /** * Weist einen offenen Auftrag einem freien Fahrzeug zu. * Berechnet Route, fügt sie zu activeRoutes, setzt States. * * Phase-2-Annahme: Fahrzeug startet **direkt am Auftrags-Origin** * (kein Pickup-Drive aus Vorposition). Vereinfacht den Loop massiv; * realistisches Pickup-Drive kommt mit Phase 3. */ function assignContract(game, contractId, vehicleId, legNum) { const c = (game.activeContracts || []).find(x => x.id === contractId); if (!c) throw new Error('Auftrag nicht gefunden: ' + contractId); const v = (game.vehicles || []).find(x => x.id === vehicleId); if (!v) throw new Error('Fahrzeug nicht gefunden: ' + vehicleId); if (v.state !== VEHICLE_STATE.IDLE) { throw new Error('Fahrzeug nicht IDLE, sondern ' + v.state); } // Phase 4b: Intermodal — leg-basierte Zuweisung let originLocationId, targetLocationId, leg; if (c.intermodal) { const legIdx = (typeof legNum === 'number') ? legNum : c.currentLegNum; leg = c.legs[legIdx]; if (!leg) throw new Error('Leg ' + legIdx + ' existiert nicht'); if (leg.state !== CONTRACT_STATE.OPEN) { throw new Error('Leg ' + legIdx + ' nicht OPEN, sondern ' + leg.state); } // Vehicle-Mode muss zum Leg passen (bei TRAIN-Leg muss ein Zug fahren) if (leg.mode === 'TRAIN' && v.mode !== 'TRAIN') { throw new Error('Leg verlangt TRAIN, Vehicle ist ' + v.mode); } if (leg.mode !== 'TRAIN' && v.mode === 'TRAIN') { throw new Error('Leg verlangt LKW, Vehicle ist Zug'); } originLocationId = leg.originLocationId; targetLocationId = leg.targetLocationId; } else { if (c.state !== CONTRACT_STATE.OPEN) { throw new Error('Auftrag nicht OPEN, sondern ' + c.state); } originLocationId = c.originLocationId; targetLocationId = c.targetLocationId; } const veh = VEHICLE_DEFAULTS[v.mode]; const containers = c.quantityContainers || 1; const loadMin = (veh.loadMinutes || 5) * containers; const unloadMin = (veh.unloadMinutes || 5) * containers; if (!game.activeRoutes) game.activeRoutes = []; let pickupRoute = null; if (v.currentLocationId !== originLocationId) { pickupRoute = calculateRoute(game, v.currentLocationId, originLocationId, v.mode); pickupRoute.vehicleId = v.id; pickupRoute.vehicleColor = v.color || null; pickupRoute.isPickup = true; game.activeRoutes.push(pickupRoute); } const deliveryRoute = calculateRoute(game, originLocationId, targetLocationId, v.mode); deliveryRoute.vehicleId = v.id; deliveryRoute.vehicleColor = v.color || null; deliveryRoute.isPickup = false; game.activeRoutes.push(deliveryRoute); v.assignedContractId = contractId; v.assignedLegNum = leg ? leg.legNum : null; v.pickupRouteId = pickupRoute ? pickupRoute.id : null; v.deliveryRouteId = deliveryRoute.id; v.loadMinutes = loadMin; v.unloadMinutes = unloadMin; v.phaseRemainingMinutes = 0; v.segmentProgress = 0; if (pickupRoute) { v.tripPhase = 'pickup_drive'; v.activeRouteId = pickupRoute.id; v.state = VEHICLE_STATE.MOVING; if (leg) leg.state = CONTRACT_STATE.PICKUP_PENDING; else c.state = CONTRACT_STATE.PICKUP_PENDING; } else { v.tripPhase = 'loading'; v.activeRouteId = null; v.state = VEHICLE_STATE.LOADING; v.phaseRemainingMinutes = loadMin; _consumeNextLoadBonus(game, v); // Phase 8e++: Ladebonus-Queue-Konsum if (leg) leg.state = CONTRACT_STATE.LOADING; else c.state = CONTRACT_STATE.LOADING; } if (leg) { leg.assignedVehicleId = vehicleId; leg.routeId = deliveryRoute.id; leg.departTime = game.simulationTime; } else { c.assignedVehicleId = vehicleId; c.routeId = deliveryRoute.id; c.departTime = game.simulationTime; } return { contract: c, leg, vehicle: v, route: deliveryRoute, pickupRoute }; } /** * Phase-3-Trip-Pipeline: pickup_drive → loading → delivery_drive → unloading. * Pro Vehicle wird der aktuelle TripPhase getickt; bei Phasen-Ende wird * der nächste Phase initialisiert. Bei UNLOADING-Ende: Contract abschließen. * @private */ function _updateVehicles(game, simMinutes) { // Evt. Verfahren-Event pro Fahrzeug auswuerfeln, bevor die Bewegung passiert _maybeTriggerVerfahren(game, simMinutes); (game.vehicles || []).forEach(v => { // Fahrzeug steht still, wenn „verfahren" ungeloest ist if (v.verfahrenPending) return; // Pflicht-Pause (ab L2): Fahrzeug steht, bis die Pausenzeit abgelaufen ist if (v.onMandatoryBreak) { v.breakRemainingMin = Math.max(0, (v.breakRemainingMin || 0) - simMinutes); if (v.breakRemainingMin <= 0) { v.onMandatoryBreak = false; v.breakRemainingMin = 0; v.drivingMinutesSinceBreak = 0; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'break_end_' + v.id + '_' + Date.now(), type: 'BREAK_ENDED', vehicleId: v.id, messageShort: '☕ ' + (v.displayName || 'LKW') + ': Pause vorbei, Fahrt geht weiter.', simTime: game.simulationTime, }); } return; } let remaining = simMinutes; let safety = 8; while (remaining > 0 && v.tripPhase && safety-- > 0) { const consumed = _stepVehiclePhase(game, v, remaining); if (consumed <= 0) break; remaining -= consumed; // Pflicht-Pausen-Akkumulation pro Fahrminute (ab L2+) if (consumed > 0 && _isDrivingPhase(v.tripPhase)) { _accumulateDriving(game, v, consumed); if (v.onMandatoryBreak) break; // sofort Pause beginnen, Rest verwerfen } } }); } function _isDrivingPhase(phase) { return phase === 'pickup_drive' || phase === 'delivery_drive' || phase === 'tour_driving'; } /** Pflicht-Pause-Logik: Ab L2 zaehlt jede Fahrminute. Bei 270 min (4,5 h) * ohne Pause wird eine 45-min-Pflichtpause ausgeloest. Auf L1 (nur ein * Auftrag parallel) ist das aus, weil die Lernkurve dort nicht damit * belastet werden soll. */ const MANDATORY_DRIVE_LIMIT_MIN = 270; // 4,5 h const MANDATORY_BREAK_MIN = 45; // EU-Regel (grob) function _accumulateDriving(game, v, minutes) { const cfg = game._config || {}; if ((cfg.maxActiveContracts || 1) <= 1) return; // L1 → keine Pflichtpause v.drivingMinutesSinceBreak = (v.drivingMinutesSinceBreak || 0) + minutes; if (v.drivingMinutesSinceBreak < MANDATORY_DRIVE_LIMIT_MIN) return; v.onMandatoryBreak = true; v.breakRemainingMin = MANDATORY_BREAK_MIN; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'break_start_' + v.id + '_' + Date.now(), type: 'BREAK_STARTED', vehicleId: v.id, messageShort: '☕ ' + (v.displayName || 'LKW') + ': Lenkzeit-Pause — 45 min Pause.', simTime: game.simulationTime, }); } /** Verfahren-Ereignis: mit kleiner Wahrscheinlichkeit pro Sim-Stunde * gerät ein Fahrzeug im `*_drive`-Phase vom Weg ab. Fahrt pausiert, * der Spieler muss auf der Karte den Zielort anklicken. Alternierend * „Fahrerin" / „Fahrer" aus `game.progression.verfahrenCount`. * Didaktisch: Kartenorientierung uebt. * Nur auf L1 ab Stufe 2 und nur mit kleiner Rate. */ function _maybeTriggerVerfahren(game, simMinutes) { const cfg = game._config || {}; const isL1 = (cfg.maxActiveContracts || 1) === 1; const isL2New = (cfg.maxActiveContracts || 1) > 1 && cfg.railEnabled && !cfg.intermodalEnabled; if (!isL1 && !isL2New) return; const round = (game.progression && game.progression.round) || 1; if (isL1) { const stufe = _l1StufeCfg(game); if (!stufe || stufe.n < 2) return; } else { // L2: ab Runde 2 (nach erster Lieferung). Gleiche Rate wie L1. if (round < 2) return; } // Basisrate 6% pro Sim-Stunde und Fahrzeug im *_drive const rate = 0.06 * (simMinutes / 60); (game.vehicles || []).forEach(v => { if (v.verfahrenPending) return; if (v.tripPhase !== 'pickup_drive' && v.tripPhase !== 'delivery_drive' && v.tripPhase !== 'tour_driving') return; // Thomas-Regel (2026-04-25): Verfahren maximal alle 4 Fahrten pro // Fahrzeug. Wir zaehlen gelieferte Kontrakte dieses Fahrzeugs und // erlauben einen neuen Verfahren-Wuerfel nur, wenn seit dem letzten // Ereignis mindestens 3 Zustellungen durch sind. const toursDone = (game.completedContracts || []) .filter(c => c.assignedVehicleId === v.id).length; const lastAt = (typeof v.lastVerfahrenAtTour === 'number') ? v.lastVerfahrenAtTour : -999; if ((toursDone - lastAt) < 3) return; if (!game._verfahrenRngCalls) game._verfahrenRngCalls = 0; const rng = _makeRng((game.seed || 1) + 77777); for (let i = 0; i < game._verfahrenRngCalls; i++) rng(); const roll = rng(); game._verfahrenRngCalls++; if (roll >= rate) return; // Ziel: aktueller Trip-Zielort bestimmen let targetLocationId = null; if (v.tripPhase === 'pickup_drive') { const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId); if (c) targetLocationId = c.originLocationId; } else if (v.tripPhase === 'delivery_drive') { const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId); if (c) targetLocationId = c.targetLocationId; } else if (v.tripPhase === 'tour_driving') { const stop = v.tourStops && v.tourStops[v.currentStopIndex]; if (stop) targetLocationId = stop.locationId; } if (!targetLocationId) return; v.verfahrenPending = true; v.verfahrenTargetLocationId = targetLocationId; v.lastVerfahrenAtTour = toursDone; // Cooldown-Zaehler merken // Fahrer-Label ist jetzt fest pro Fahrzeug (driverLabel beim Spawn). // Fallback fuer alte Spielstaende ohne driverLabel. if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; const driverType = v.driverLabel || ((game.progression.verfahrenCount || 0) % 2 === 0 ? 'Fahrerin Anna' : 'Fahrer Tom'); game.progression.verfahrenCount = (game.progression.verfahrenCount || 0) + 1; v.verfahrenDriverLabel = driverType; // Notification fuer UI if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'verfahren_' + v.id + '_' + _nextUniqId(game), type: 'DRIVER_LOST', vehicleId: v.id, targetLocationId: targetLocationId, driverLabel: driverType, messageShort: '🤷 ' + driverType + ' hat sich verfahren — Ziel auf der Karte zeigen.', simTime: game.simulationTime, }); }); } /** Der Spieler klickt auf einen Ort. 3 moegliche Auswertungen: * - Falsche Stadt → 1,5 Sim-Stunden Zeit-Strafe akkumuliert, Event bleibt offen * - Richtige Stadt → Event geloest, akkumulierte Strafe in segmentProgress abgezogen * Jede Stadt mit Marker zaehlt als Tipp (nicht mehr nur Hauptstaedte). * Return: { ok, wrong?, wastedHours?, totalLostHours?, reason? } */ const VERFAHREN_PENALTY_H = 1.5; // Sim-Stunden pro falschem Tipp function resolveDriverLost(game, vehicleId, clickedLocationId, locationType) { const v = (game.vehicles || []).find(x => x.id === vehicleId); if (!v || !v.verfahrenPending) return { ok: false, reason: 'kein Ereignis aktiv' }; // Korrekter Tipp if (clickedLocationId === v.verfahrenTargetLocationId) { const totalPenalty = v.verfahrenPenaltyHours || 0; // Akkumulierte Zeit-Strafe durch Reduktion des segmentProgress umsetzen, // sodass die Restfahrt entsprechend laenger dauert. Pro Fahrt max 90 % // des Fortschritts zuruecknehmen (realistisches Limit). if (totalPenalty > 0) { const route = (game.activeRoutes || []).find(r => r.id === v.activeRouteId); if (route && route.totalDurationMinutes > 0) { const wastedRatio = Math.min(0.9, (totalPenalty * 60) / route.totalDurationMinutes); v.segmentProgress = Math.max(0, (v.segmentProgress || 0) - wastedRatio); } } v.verfahrenPending = false; v.verfahrenTargetLocationId = null; v.verfahrenDriverLabel = null; v.verfahrenPenaltyHours = 0; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'verfahren_resolved_' + v.id + '_' + Date.now(), type: 'DRIVER_FOUND', vehicleId: v.id, totalLostHours: totalPenalty, messageShort: totalPenalty > 0 ? '✓ Ziel gezeigt — ' + totalPenalty.toFixed(1) + ' h durch falsche Tipps verloren.' : '✓ Ziel gezeigt — Fahrt geht weiter.', simTime: game.simulationTime, }); return { ok: true, totalLostHours: totalPenalty }; } // Falsche Hauptstadt: Penalty akkumulieren, Event bleibt offen v.verfahrenPenaltyHours = (v.verfahrenPenaltyHours || 0) + VERFAHREN_PENALTY_H; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'verfahren_wrong_' + v.id + '_' + Date.now(), type: 'DRIVER_WRONG_TIP', vehicleId: v.id, wastedHours: VERFAHREN_PENALTY_H, messageShort: 'Dein Tipp war falsch — ' + VERFAHREN_PENALTY_H + ' h verloren.', simTime: game.simulationTime, }); return { ok: false, wrong: true, wastedHours: VERFAHREN_PENALTY_H }; } /** * Verarbeitet EINEN Phasen-Schritt; gibt zurück, wie viele Sim-Minuten * verbraucht wurden. Wenn Phase fertig vor simMinutes-Ende: konsumiert * exakt die nötige Restzeit der Phase und wechselt; Caller läuft mit * Übertrag in der neuen Phase weiter. * @private * @returns {number} verbrauchte Minuten oder simMinutes wenn Phase weiterläuft */ function _stepVehiclePhase(game, v, simMinutes) { const routesById = {}; (game.activeRoutes || []).forEach(r => { routesById[r.id] = r; }); /* -------- Phase 8: Tour-Modus (Multi-Contract) -------- */ if (v.tripPhase === 'tour_driving') { const stop = v.tourStops[v.currentStopIndex]; const route = routesById[v.activeRouteId]; if (!route || !stop) return simMinutes; const speedMul = _vehicleSpeedMultiplier(game); const totalRaw = route.totalDurationMinutes || 1; const total = totalRaw / Math.max(0.05, speedMul); const remainingProgress = 1 - (v.segmentProgress || 0); const minutesToEnd = remainingProgress * total; const consume = Math.min(simMinutes, minutesToEnd); const next = (v.segmentProgress || 0) + (consume / total); const [lat, lon] = interpolateAlongPolyline(route.geometry, Math.min(next, 1)); v.segmentProgress = next; v.lat = lat; v.lon = lon; if (next >= 0.99999) { v.currentLocationId = stop.locationId; if (!game.analytics) game.analytics = {}; game.analytics.totalKm = (game.analytics.totalKm || 0) + route.totalDistanceKm; if (route.isPickup) { game.analytics.emptyKm = (game.analytics.emptyKm || 0) + route.totalDistanceKm; } // Fahrkosten sofort buchen const fareCost = travelCost(route.totalDistanceKm, route.totalDurationMinutes / 60, v.mode); game.balance -= fareCost; game.analytics.totalFareCostPaid = (game.analytics.totalFareCostPaid || 0) + fareCost; // Route entfernen game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== route.id); // Handling beginnen v.tripPhase = 'tour_handling'; v.activeRouteId = null; v.segmentProgress = 0; v.state = stop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING; let handlingMinutes = stop.handlingMinutes || 5; v.phaseRemainingMinutes = handlingMinutes; _consumeNextLoadBonus(game, v); // gilt fuer Pickup UND Dropoff stop.contractIds.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (c) c.state = stop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING; }); } return consume; } if (v.tripPhase === 'tour_handling') { const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0); v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume; if (v.phaseRemainingMinutes <= 0) { const stop = v.tourStops[v.currentStopIndex]; // Contract-States nach Handling stop.contractIds.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (!c) return; if (stop.type === 'PICKUP') { c.state = CONTRACT_STATE.IN_TRANSIT; } else { // Dropoff → Contract komplett abschließen _completeContractMulti(game, c, v); } }); v.currentStopIndex++; if (v.currentStopIndex >= v.tourStops.length) { _endTour(game, v); } else { const nextStop = v.tourStops[v.currentStopIndex]; if (nextStop.inRouteId) { v.tripPhase = 'tour_driving'; v.activeRouteId = nextStop.inRouteId; v.segmentProgress = 0; v.state = VEHICLE_STATE.MOVING; } else { v.tripPhase = 'tour_handling'; v.activeRouteId = null; v.state = nextStop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING; let m = nextStop.handlingMinutes || 5; v.phaseRemainingMinutes = m; _consumeNextLoadBonus(game, v); nextStop.contractIds.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (c) c.state = nextStop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING; }); } } } return consume; } /* -------- Legacy-Pfade (Single-Contract + Intermodal) -------- */ if (v.tripPhase === 'pickup_drive' || v.tripPhase === 'delivery_drive') { const route = routesById[v.activeRouteId]; if (!route) return simMinutes; // Phase 5: Event-Speed-Multiplikator (Unfall halbiert, Schnee × 0.7 etc.) const speedMul = _vehicleSpeedMultiplier(game); const totalRaw = route.totalDurationMinutes || 1; const total = totalRaw / Math.max(0.05, speedMul); // bei speedMul<1 dauert länger const remainingProgress = 1 - (v.segmentProgress || 0); const minutesToEnd = remainingProgress * total; const consume = Math.min(simMinutes, minutesToEnd); const next = (v.segmentProgress || 0) + (consume / total); const [lat, lon] = interpolateAlongPolyline(route.geometry, Math.min(next, 1)); v.segmentProgress = next; v.lat = lat; v.lon = lon; if (next >= 0.99999) { v.currentLocationId = route.targetLocationId; if (!game.analytics) game.analytics = {}; game.analytics.totalKm = (game.analytics.totalKm || 0) + route.totalDistanceKm; if (v.tripPhase === 'pickup_drive') { game.analytics.emptyKm = (game.analytics.emptyKm || 0) + route.totalDistanceKm; v.tripPhase = 'loading'; v.activeRouteId = null; v.segmentProgress = 0; v.state = VEHICLE_STATE.LOADING; v.phaseRemainingMinutes = v.loadMinutes || 5; const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId); if (c) { c.state = CONTRACT_STATE.LOADING; c.pickupKm = route.totalDistanceKm; } game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== route.id); } else { v.tripPhase = 'unloading'; v.activeRouteId = null; v.segmentProgress = 0; v.state = VEHICLE_STATE.UNLOADING; v.phaseRemainingMinutes = v.unloadMinutes || 5; const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId); if (c) c.state = CONTRACT_STATE.AT_TRANSFER; } } return consume; } if (v.tripPhase === 'loading') { const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0); v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume; if (v.phaseRemainingMinutes <= 0) { v.tripPhase = 'delivery_drive'; v.activeRouteId = v.deliveryRouteId; v.segmentProgress = 0; v.state = VEHICLE_STATE.MOVING; const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId); if (c) c.state = CONTRACT_STATE.IN_TRANSIT; } return consume; } if (v.tripPhase === 'unloading') { const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0); v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume; if (v.phaseRemainingMinutes <= 0) { const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId); const deliveryRoute = routesById[v.deliveryRouteId]; game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== v.deliveryRouteId); if (c && deliveryRoute) { if (c.intermodal) { _completeLeg(game, c, v.assignedLegNum, deliveryRoute, v); } else { _completeContract(game, c, deliveryRoute, v); } } v.tripPhase = null; v.assignedContractId = null; v.assignedLegNum = null; v.pickupRouteId = null; v.deliveryRouteId = null; v.activeRouteId = null; v.segmentProgress = 0; v.phaseRemainingMinutes = 0; v.state = VEHICLE_STATE.IDLE; } return consume; } return simMinutes; } /** * Phase 4b: Vollendet ein einzelnes Leg eines intermodalen Contracts. * Bucht Fahrkosten sofort. Wenn letztes Leg → ganzer Contract DELIVERED * mit Reward + Bonus + Strafe. * @private */ function _completeLeg(game, contract, legNum, route, vehicle) { const leg = contract.legs[legNum]; if (!leg) return; leg.state = CONTRACT_STATE.DELIVERED; leg.arrivalTime = game.simulationTime; contract.currentLocationId = leg.targetLocationId; // Fahrkosten dieses Legs sofort buchen (Reward kommt am Schluss) const fareCost = travelCost(route.totalDistanceKm, route.totalDurationMinutes / 60, route.mode); let pickupFareCost = 0; if (vehicle && vehicle.pickupRouteId && contract.pickupKm) { const veh = VEHICLE_DEFAULTS[route.mode]; const pickupHours = contract.pickupKm / veh.speedKmh; pickupFareCost = contract.pickupKm * veh.costPerKm + pickupHours * veh.costPerHour; contract.pickupKm = 0; // reset für nächsten Leg } game.balance -= (fareCost + pickupFareCost); contract.legCostsPaid = (contract.legCostsPaid || 0) + fareCost + pickupFareCost; if (legNum + 1 < contract.legs.length) { // Nächster Leg → OPEN contract.currentLegNum = legNum + 1; contract.legs[legNum + 1].state = CONTRACT_STATE.OPEN; // Notification für Übergabe if (!game.pendingNotifications) game.pendingNotifications = []; const locs = game.worldState.locations || []; const locName = (id) => (locs.find(l => l.id === id) || {}).name || id; game.pendingNotifications.push({ id: 'leg_' + contract.id + '_' + legNum, type: 'LEG_TRANSFERRED', contractId: contract.id, messageShort: 'Auftrag „' + (contract.code || contract.id) + '": Container in ' + locName(leg.targetLocationId) + ' angekommen, nächster Leg offen.', simTime: game.simulationTime, }); } else { // Letztes Leg → ganzer Contract abschließen mit Bonus/Strafe/Reward const cfg = game._config || {}; const arrivalIso = game.simulationTime; const dueMs = new Date(contract.dueTime || arrivalIso).getTime(); const arrMs = new Date(arrivalIso).getTime(); const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60)); const baseReward = contract.rewardBase || ECONOMY.standardContractBase; const penaltyRate = (typeof cfg.latePenaltyOverride === 'number') ? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour; const penalty = Math.max(0, baseReward * penaltyRate * lateHours); const onTime = lateHours === 0; // Intermodal-Bonus: noEmpty true wenn überall am Übergabe-Ort gestartet // (vereinfachte Annahme: Pickup-Drive war bei _stepVehiclePhase pickupRouteId markiert) const noEmpty = true; // Intermodal: separate Bonus-Logik möglich, hier vereinfacht const bonus = calculateBonus(baseReward, { onTime, noEmpty }); const net = baseReward + bonus - penalty - (contract.legCostsPaid || 0); // Reward am Ende buchen (Fahrkosten waren schon pro Leg abgezogen) game.balance += baseReward + bonus - penalty; game.score += Math.max(0, Math.round(net / 100)); contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED; contract.finalBalance = net; contract.lateHours = lateHours; if (!game.completedContracts) game.completedContracts = []; game.completedContracts.push(contract); game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id); if (!game.analytics) game.analytics = {}; if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'notif_intermodal_' + contract.id, type: 'CONTRACT_DELIVERED', contractId: contract.id, messageShort: 'Intermodaler Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.', simTime: arrivalIso, net, }); } } /** * Schließt einen Vertrag ab: Erlös buchen, Strafen prüfen, Bonus prüfen, * Vertrag in completedContracts verschieben. * Phase 3: vehicle-Parameter ermöglicht Leerfahrten-Erkennung * (pickupRouteId vorhanden = Leerfahrt = noEmpty=false). * @private */ function _completeContract(game, contract, route, vehicle) { const cfg = game._config || {}; const arrivalIso = game.simulationTime; contract.unloadEndTime = arrivalIso; // Verspätung berechnen const dueMs = new Date(contract.dueTime || arrivalIso).getTime(); const arrMs = new Date(arrivalIso).getTime(); const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60)); // Wirtschaft const baseReward = contract.rewardBase || ECONOMY.standardContractBase; // Fahrkosten = pickup-km + delivery-km (Leerfahrt + Lieferfahrt zählen beide) let totalKm = route.totalDistanceKm; let totalDur = route.totalDurationMinutes; let hadPickupDrive = false; if (vehicle && vehicle.pickupRouteId) { // Pickup-Route ist schon aus activeRoutes entfernt — wir berechnen // approx aus contract+vehicle currentLocation-Diff. Pragmatischer: // Pickup-Distanz wurde in analytics.emptyKm gespeichert; nutzen wir die. hadPickupDrive = true; } // Wenn pickup vorhanden: Fahrkosten verdoppelt? Nein, nur die echten km // werden gerechnet. Da wir hier nur die Delivery-Route haben, holen wir // pickup-km approximativ aus analytics-Diff. const fareCost = travelCost(totalKm, totalDur / 60, route.mode); // Pickup-Fahrkosten separat (ohne Erlös) — vereinfacht: identische €/km/h let pickupFareCost = 0; if (hadPickupDrive && contract.pickupKm) { const veh = VEHICLE_DEFAULTS[route.mode]; const pickupHours = contract.pickupKm / veh.speedKmh; pickupFareCost = contract.pickupKm * veh.costPerKm + pickupHours * veh.costPerHour; } const penaltyRate = (typeof cfg.latePenaltyOverride === 'number') ? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour; const penalty = Math.max(0, baseReward * penaltyRate * lateHours); const onTime = lateHours === 0; const noEmpty = !hadPickupDrive; // Phase 3: Leerfahrt erkannt const bonus = calculateBonus(baseReward, { onTime, noEmpty }); const net = baseReward + bonus - fareCost - pickupFareCost - penalty; game.balance += net; game.score += Math.max(0, Math.round(net / 100)); contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED; contract.finalBalance = net; contract.lateHours = lateHours; // Tracking: Auftrags-Dauer const issueMs = new Date(contract.issueTime || arrivalIso).getTime(); contract.durationHours = (arrMs - issueMs) / (1000 * 60 * 60); // In completedContracts verschieben if (!game.completedContracts) game.completedContracts = []; game.completedContracts.push(contract); game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id); // Analytics if (!game.analytics) game.analytics = {}; if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1; game.analytics.totalRewardEarned = (game.analytics.totalRewardEarned || 0) + baseReward + bonus; game.analytics.totalPenaltyPaid = (game.analytics.totalPenaltyPaid || 0) + penalty; game.analytics.totalFareCostPaid = (game.analytics.totalFareCostPaid || 0) + fareCost + pickupFareCost; // Notification if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'notif_' + contract.id, type: 'CONTRACT_DELIVERED', contractId: contract.id, messageShort: 'Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.', simTime: arrivalIso, net, }); _pushAnalyticsEntry(game, contract, { assignedVehicle: vehicle && vehicle.typeId, routeDistanceKm: Math.round((totalKm || 0) + (contract.pickupKm || 0)), routeMode: route && route.mode, }); // Progression-Runde +1 (zaehlt fuer L1 dynamic max + Auto-Buy) if (game.progression) game.progression.round = (game.progression.round || 1) + 1; // Phase-2-Vorgriff (für „L1 spielbar"): bei { // Per-Vehicle Mietmultiplikator (Train teurer als Truck). const veh = VEHICLE_DEFAULTS[v.mode] || {}; const mult = (typeof veh.rentMult === 'number') ? veh.rentMult : 1.0; costRental += rentalPerDay * simDays * mult; if (v.state === VEHICLE_STATE.IDLE) { costIdle += idleCostPerH * simHours; } }); // Phase 4: Container-Standkosten an Häfen if (portStandPerH > 0) { const locs = game.worldState.locations || []; (game.activeContracts || []).forEach(c => { if (c.state !== CONTRACT_STATE.OPEN) return; const orig = locs.find(l => l.id === c.originLocationId); if (!orig || orig.type !== 'PORT') return; costPort += portStandPerH * (c.quantityContainers || 1) * simHours; }); } const cost = costRental + costIdle + costPort; if (cost > 0) { game.balance -= cost; if (!game.analytics) game.analytics = {}; game.analytics.runningCostsTotal = (game.analytics.runningCostsTotal || 0) + cost; game.analytics.runningCostsRental = (game.analytics.runningCostsRental || 0) + costRental; game.analytics.runningCostsIdle = (game.analytics.runningCostsIdle || 0) + costIdle; game.analytics.runningCostsPort = (game.analytics.runningCostsPort || 0) + costPort; } } /** * Phase 5: Würfelt Events deterministisch via Seed pro Sim-Stunden-Block. * Pro EVENT_TYPE wird `prob = baseProb × eventProbabilityMultiplier` * auf jeden Stundenblock angewendet. Treffer → Event in * `game.activeEvents` mit zufälliger Dauer (30-120 min). * @private */ function _maybeGenerateEvents(game, simMinutes) { const cfg = game._config || {}; let probMul = (typeof cfg.eventProbabilityMultiplier === 'number') ? cfg.eventProbabilityMultiplier : 1; // L1-Event-Ramp: Stufe 1-5 = 0 (deterministisch), Stufe 6 = stufen-override const stufe = _l1StufeCfg(game); if (stufe) probMul = stufe.eventMult; if (probMul <= 0) return; if (!game._eventsRngCalls) game._eventsRngCalls = 0; if (!game.activeEvents) game.activeEvents = []; // Stundenblock-Tracker — wir würfeln einmal pro vollendete Sim-Stunde const lastH = Math.floor(game._lastEventCheckSimHours || 0); const nowH = Math.floor(getSimHoursElapsed(game)); if (nowH <= lastH) return; game._lastEventCheckSimHours = nowH; const rng = _makeRng((game.seed || 1) ^ 0xE7E7); for (let i = 0; i < game._eventsRngCalls; i++) rng(); const checks = nowH - lastH; // wie viele Stunden zu würfeln const types = Object.keys(EVENT_RULES); for (let h = 0; h < checks; h++) { types.forEach(type => { const rule = EVENT_RULES[type]; const p = rule.probabilityPerHour * probMul; const v = rng(); game._eventsRngCalls++; if (v < p) { // Trigger: Event-Dauer 30-120 min const v2 = rng(); game._eventsRngCalls++; const durationMin = 30 + Math.floor(v2 * 90); // Geo-Position abhängig vom Event-Typ (Phase 7-UX) const geo = _pickEventLocation(game, type, rng); game._eventsRngCalls += 2; // _pickEventLocation verbraucht bis zu 2 RNG-Calls const event = { id: 'evt_' + Date.now() + '_' + game._eventsRngCalls, type, severity: 'normal', startTime: game.simulationTime, endTime: new Date(new Date(game.simulationTime).getTime() + durationMin * 60000).toISOString(), durationMin, lat: geo.lat, lon: geo.lon, locationId: geo.locationId || null, impact: { speedMult: rule.speedMult || 1, loadMult: rule.loadMult || 1, regions: rule.regions || null, }, messageShort: _eventMessage(type), sourceType: 'random_seeded', }; game.activeEvents.push(event); if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'notif_' + event.id, type: 'EVENT_STARTED', eventId: event.id, messageShort: event.messageShort, simTime: game.simulationTime, }); } }); } } /** * Phase 7-UX: Wählt Geo-Position für ein Event-Typ (deterministisch). * - TRAFFIC_ACCIDENT: Mittelpunkt einer aktiven Route (falls vorhanden), * sonst Mittel-Europa (Frankfurt-nähe) * - SNOW: in den Alpen (Brennerpass mit ±0.8° Streuung) * - PORT_DELAY: an einem zufälligen Hafen * @private */ function _pickEventLocation(game, type, rng) { const locs = game.worldState.locations || []; if (type === 'TRAFFIC_ACCIDENT') { // Mittelpunkt einer aktiven Lieferroute, sonst Frankfurt const deliveryRoutes = (game.activeRoutes || []).filter(r => !r.isPickup && r.geometry && r.geometry.length); if (deliveryRoutes.length) { const idx = Math.floor(rng() * deliveryRoutes.length); const r = deliveryRoutes[idx]; const m = r.geometry[Math.floor(r.geometry.length / 2)]; return { lat: m[0], lon: m[1] }; } return { lat: 50.11, lon: 8.68 }; // Frankfurt a.M. } if (type === 'SNOW') { // Alpen: Brennerpass-Region mit Streuung (Innsbruck ~ Bozen) const baseLat = 47.0, baseLon = 11.5; const r1 = rng(), r2 = rng(); return { lat: baseLat + (r1 - 0.5) * 1.0, lon: baseLon + (r2 - 0.5) * 1.6 }; } if (type === 'PORT_DELAY') { const ports = locs.filter(l => l.type === 'PORT'); if (ports.length) { const idx = Math.floor(rng() * ports.length); const p = ports[idx]; return { lat: p.lat, lon: p.lon, locationId: p.id }; } } // Fallback: Mittel-Europa return { lat: 50.5, lon: 10.5 }; } function _eventMessage(type) { if (type === 'TRAFFIC_ACCIDENT') return '🚧 Unfall — Speed reduziert'; if (type === 'SNOW') return '❄ Schnee in den Alpen — vorsichtig fahren'; if (type === 'PORT_DELAY') return '⚓ Hafenüberlastung — längere Ladezeit'; if (type === 'STORM') return '🌪 Sturm — Speed reduziert'; if (type === 'RAIL_STRIKE') return '🚆 Bahnstreik — Bahnverkehr eingeschränkt'; return type; } /** * Entfernt abgelaufene Events. * @private */ function _expireEvents(game) { if (!game.activeEvents || !game.activeEvents.length) return; const nowMs = new Date(game.simulationTime).getTime(); game.activeEvents = game.activeEvents.filter(e => { const endMs = new Date(e.endTime).getTime(); return endMs > nowMs; }); } /** * Berechnet Speed-Mult aus aktiven Events für ein Vehicle. * Phase 5: kombiniert alle aktiven Events multiplikativ. * @private */ function _vehicleSpeedMultiplier(game) { if (!game.activeEvents || !game.activeEvents.length) return 1; let m = 1; game.activeEvents.forEach(e => { if (e.impact && typeof e.impact.speedMult === 'number') m *= e.impact.speedMult; }); return m; } /** * Phase 4: Generiert periodisch Schiffsankünfte an Häfen * (alle 6 Sim-Stunden ein Container-Auftrag pro Hafen, deterministisch). * @private */ /* ============================================================ PHASE 8 — Multi-Contract-Tour (Vehicle lädt mehrere Aufträge, fährt Multi-Stop-Tour in Klick-Reihenfolge ab) ============================================================ */ /** * Lädt einen OPEN-Auftrag in den Laderaum eines IDLE-Vehicles. * Validiert Kapazität, Intermodal-Check. Contract wird auf RESERVED * gesetzt (unsichtbar für andere). */ function loadContract(game, vehicleId, contractId) { const v = (game.vehicles || []).find(x => x.id === vehicleId); if (!v) throw new Error('Fahrzeug nicht gefunden: ' + vehicleId); if (v.state !== VEHICLE_STATE.IDLE) { throw new Error('Fahrzeug nicht IDLE, sondern ' + v.state); } const c = (game.activeContracts || []).find(x => x.id === contractId); if (!c) throw new Error('Auftrag nicht gefunden: ' + contractId); if (c.state !== CONTRACT_STATE.OPEN) { throw new Error('Auftrag nicht OPEN, sondern ' + c.state); } if (c.intermodal) { throw new Error('Intermodal-Aufträge haben eigenen Ablauf (legs)'); } const loaded = v.loadedContractIds || []; const usedContainers = loaded.reduce((sum, cid) => { const lc = (game.activeContracts || []).find(x => x.id === cid); return sum + (lc ? (lc.quantityContainers || 0) : 0); }, 0); const need = c.quantityContainers || 1; const free = (v.capacityUnits || 1) - usedContainers; if (need > free) { throw new Error('Kapazität reicht nicht — ' + free + ' Plätze frei, Auftrag braucht ' + need); } v.loadedContractIds = loaded.concat([contractId]); c.state = CONTRACT_STATE.RESERVED; c.reservedByVehicleId = v.id; return { vehicle: v, contract: c, freeSlotsAfter: free - need }; } /** Nimmt einen geladenen Auftrag vor Fahrt-Start wieder heraus. */ function unloadContract(game, vehicleId, contractId) { const v = (game.vehicles || []).find(x => x.id === vehicleId); if (!v) throw new Error('Fahrzeug nicht gefunden'); if (v.state !== VEHICLE_STATE.IDLE) { throw new Error('Entladen nur vor Fahrt-Start möglich'); } v.loadedContractIds = (v.loadedContractIds || []).filter(id => id !== contractId); const c = (game.activeContracts || []).find(x => x.id === contractId); if (c) { c.state = CONTRACT_STATE.OPEN; c.reservedByVehicleId = null; } return v; } /** Setzt alle Aufträge eines Vehicles zurück auf OPEN. */ function cancelLoading(game, vehicleId) { const v = (game.vehicles || []).find(x => x.id === vehicleId); if (!v) return; (v.loadedContractIds || []).forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (c) { c.state = CONTRACT_STATE.OPEN; c.reservedByVehicleId = null; } }); v.loadedContractIds = []; } /** * Startet die Multi-Stop-Tour eines beladenen Vehicles. * Reihenfolge: alle Pickups (in Lade-Reihenfolge), dann alle Dropoffs * (in derselben Reihenfolge). Aufeinanderfolgende Stops am selben * Ort mit demselben Typ werden konsolidiert. */ function startTour(game, vehicleId) { const v = (game.vehicles || []).find(x => x.id === vehicleId); if (!v) throw new Error('Fahrzeug nicht gefunden'); if (v.state !== VEHICLE_STATE.IDLE) throw new Error('Vehicle nicht IDLE'); const loaded = v.loadedContractIds || []; if (!loaded.length) throw new Error('Kein Auftrag geladen — nichts zu fahren'); const veh = VEHICLE_DEFAULTS[v.mode]; const locs = game.worldState.locations || []; // Raw-Stops aus Klick-Reihenfolge const raw = []; loaded.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (!c) return; raw.push({ type: 'PICKUP', locationId: c.originLocationId, contractIds: [cid] }); }); loaded.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (!c) return; raw.push({ type: 'DROPOFF', locationId: c.targetLocationId, contractIds: [cid] }); }); // Konsolidieren: aufeinanderfolgende Stops desselben Typs+Orts zusammenlegen const stops = []; raw.forEach(s => { const last = stops[stops.length - 1]; if (last && last.type === s.type && last.locationId === s.locationId) { last.contractIds = last.contractIds.concat(s.contractIds); } else { stops.push({ type: s.type, locationId: s.locationId, contractIds: s.contractIds.slice() }); } }); // Routen berechnen + Handling-Minuten + Stop-Koordinaten if (!game.activeRoutes) game.activeRoutes = []; let prevLocId = v.currentLocationId; stops.forEach((stop, idx) => { const loc = locs.find(l => l.id === stop.locationId); if (loc) { stop.lat = loc.lat; stop.lon = loc.lon; } if (stop.locationId !== prevLocId) { const route = calculateRoute(game, prevLocId, stop.locationId, v.mode); route.vehicleId = v.id; route.vehicleColor = v.color || null; // Pickup-Fahrt zählt als "Leerfahrt-Anteil" nur wenn noch nichts geladen war const carriedBefore = _contractsCarriedBefore(game, stops, idx, v); route.isPickup = (stop.type === 'PICKUP' && carriedBefore === 0); stop.inRouteId = route.id; game.activeRoutes.push(route); } else { stop.inRouteId = null; } stop.handlingMinutes = stop.contractIds.reduce((sum, cid) => { const c = (game.activeContracts || []).find(x => x.id === cid); if (!c) return sum; const perContainer = stop.type === 'PICKUP' ? veh.loadMinutes : veh.unloadMinutes; return sum + (perContainer || 5) * (c.quantityContainers || 1); }, 0); prevLocId = stop.locationId; }); v.tourStops = stops; v.currentStopIndex = 0; v.assignedContractId = null; // deprecated, wird in Tour-Modus nicht verwendet v.pickupRouteId = null; v.deliveryRouteId = null; // Loaded-Contracts auf PICKUP_PENDING (oder direkt IN_TRANSIT wenn schon am ersten Pickup-Ort) loaded.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (c) { c.assignedVehicleId = v.id; c.departTime = game.simulationTime; c.state = CONTRACT_STATE.PICKUP_PENDING; } }); // Erste Phase starten const firstStop = stops[0]; if (firstStop.inRouteId) { v.tripPhase = 'tour_driving'; v.activeRouteId = firstStop.inRouteId; v.segmentProgress = 0; v.state = VEHICLE_STATE.MOVING; } else { // Vehicle schon am Ort des ersten Stops (z.B. Pickup direkt am Hub) v.tripPhase = 'tour_handling'; v.activeRouteId = null; v.segmentProgress = 0; v.state = firstStop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING; v.phaseRemainingMinutes = firstStop.handlingMinutes; _consumeNextLoadBonus(game, v); firstStop.contractIds.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (c) c.state = firstStop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING; }); } return { vehicle: v, stops }; } /** Zählt, wie viele Contracts zum Zeitpunkt vor Stop-Index bereits * im Laderaum sind (Pickup-Stops vor diesem Index addieren, Dropoff subtrahieren). */ function _contractsCarriedBefore(game, stops, idx, v) { let count = 0; for (let i = 0; i < idx; i++) { const s = stops[i]; s.contractIds.forEach(cid => { const c = (game.activeContracts || []).find(x => x.id === cid); if (!c) return; count += (s.type === 'PICKUP' ? 1 : -1) * (c.quantityContainers || 1); }); } return count; } function _maybeGenerateShipArrival(game) { const cfg = game._config || {}; if (!cfg.portsEnabled) return; const intervalH = 6; const nowH = getSimHoursElapsed(game); const lastH = game._lastShipArrivalSimHours || 0; if (nowH - lastH < intervalH) return; game._lastShipArrivalSimHours = nowH; const locs = game.worldState.locations || []; const ports = locs.filter(l => l.type === 'PORT'); if (!ports.length) return; const counter = (game._shipArrivalCounter || 0); game._shipArrivalCounter = counter + 1; const port = ports[counter % ports.length]; // Inland-Ziele für Schiffs-Container: Hauptstädte (deterministisch rotierend) const inlandPool = ['wien', 'muenchen', 'berlin', 'paris', 'warschau']; const target = inlandPool[counter % inlandPool.length]; if (target === port.id) return; const code = 'SHIP-' + String(counter + 1).padStart(3, '0'); const contract = _makeContract(game, code, port.id, target, 3, false); contract.narrativeText = 'Schiff in ' + port.name + ' eingetroffen — ' + '3 Container nach ' + (locs.find(l => l.id === target) || {}).name; if (!game.activeContracts) game.activeContracts = []; game.activeContracts.push(contract); if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'ship_' + code, type: 'SHIP_ARRIVED', contractId: contract.id, messageShort: '🚢 Schiff in ' + port.name + ' angekommen — neuer Auftrag.', simTime: game.simulationTime, }); } /** * Tour abschließen: Vehicle zurück auf IDLE, Tour-Felder leeren. * @private */ function _endTour(game, v) { v.tripPhase = null; v.state = VEHICLE_STATE.IDLE; v.tourStops = []; v.currentStopIndex = 0; v.loadedContractIds = []; v.activeRouteId = null; v.segmentProgress = 0; v.phaseRemainingMinutes = 0; // Reste aus activeRoutes entfernen game.activeRoutes = (game.activeRoutes || []).filter(r => r.vehicleId !== v.id); } /** * Contract-Abschluss im Multi-Tour-Modus. Fahrkosten wurden während * der Fahrt schon gebucht — hier nur Reward + Bonus + Penalty. * @private */ function _completeContractMulti(game, contract, vehicle) { const cfg = game._config || {}; const arrivalIso = game.simulationTime; contract.unloadEndTime = arrivalIso; const dueMs = new Date(contract.dueTime || arrivalIso).getTime(); const arrMs = new Date(arrivalIso).getTime(); const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60)); const baseReward = contract.rewardBase || ECONOMY.standardContractBase; const penaltyRate = (typeof cfg.latePenaltyOverride === 'number') ? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour; const penalty = Math.max(0, baseReward * penaltyRate * lateHours); const onTime = lateHours === 0; // Sammelladung: pauschal kein Leerfahrt-Malus (Leerfahrt-Tracking läuft über analytics.emptyKm global) const noEmpty = true; const bonus = calculateBonus(baseReward, { onTime, noEmpty }); const net = baseReward + bonus - penalty; game.balance += baseReward + bonus - penalty; game.score += Math.max(0, Math.round(net / 100)); contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED; contract.finalBalance = net; contract.lateHours = lateHours; const issueMs = new Date(contract.issueTime || arrivalIso).getTime(); contract.durationHours = (arrMs - issueMs) / (1000 * 60 * 60); if (!game.completedContracts) game.completedContracts = []; game.completedContracts.push(contract); game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id); if (!game.analytics) game.analytics = {}; if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1; game.analytics.totalRewardEarned = (game.analytics.totalRewardEarned || 0) + baseReward + bonus; game.analytics.totalPenaltyPaid = (game.analytics.totalPenaltyPaid || 0) + penalty; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'notif_' + contract.id, type: 'CONTRACT_DELIVERED', contractId: contract.id, messageShort: 'Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.', simTime: arrivalIso, net, }); _pushAnalyticsEntry(game, contract, { assignedVehicle: vehicle && vehicle.typeId, routeDistanceKm: contract.plannedRouteKm || null, routeMode: vehicle && vehicle.typeId, }); if (game.progression) game.progression.round = (game.progression.round || 1) + 1; _maybeGenerateFollowupContract(game); } /** * Schreibt einen Analytics-Eintrag in game.pendingAnalytics. Die UI * drainiert die Queue und POSTet an /api/logistik-analytics. Die * Engine selbst fuehrt keinen Netzwerkzugriff aus — bleibt damit in * Node (headless-runner) und Browser gleich testbar. * @private */ function _pushAnalyticsEntry(game, contract, extra) { if (!game.pendingAnalytics) game.pendingAnalytics = []; const levelNum = parseInt(String(game.levelId || 'level_1').replace(/^level_/, ''), 10) || 1; const lateMinutes = Math.round(((contract.lateHours || 0) * 60)); const stateStr = (contract.state === CONTRACT_STATE.LATE) ? 'LATE' : (contract.state === CONTRACT_STATE.FAILED) ? 'FAILED' : (contract.state === CONTRACT_STATE.CANCELLED) ? 'CANCELLED' : 'DELIVERED'; game.pendingAnalytics.push({ levelId: levelNum, contractCode: contract.code || contract.id || '', assignedVehicle: (extra && extra.assignedVehicle) || null, state: stateStr, finalBalance: Math.round(game.balance || 0), lateMinutes: lateMinutes, hintUsages: (game.analytics && game.analytics.hintUsages) || 0, intermodal: !!contract.intermodal, routeDistanceKm: (extra && extra.routeDistanceKm) || null, routeMode: (extra && extra.routeMode) || null, }); } /** Effektives maxActiveContracts mit L1-Progression. * L1: nimmt stufe.maxOpen. L2/L3: cfg-Wert direkt. */ function _effectiveMaxContracts(game) { const cfg = game._config || {}; const cfgMax = cfg.maxActiveContracts || 1; if (cfgMax !== 1) return cfgMax; const stufe = _l1StufeCfg(game); return (stufe && stufe.maxOpen) || 1; } /** L1-Stufen-System (nur fuer cfg.max=1). Einfaches Modell: * Pro Stufe waechst max gleichzeitiger OPEN-Auftraege + Pool-Distanz. * Aufträge sind immer Standard (kein Eilauftrag, keine Multi-Container * — Tutorial soll entspannt sein). Sie erscheinen direkt als OPEN. * Kommen und gehen: nicht-zugewiesene Auftraege verfallen an ihrer * eigenen dueTime (silent-remove). */ const L1_STUFEN = Object.freeze([ null, { n: 1, name: 'Erster Auftrag', minRound: 1, maxOpen: 1, poolMaxMult: 1.0, eventMult: 0 }, { n: 2, name: 'Mehrere zur Wahl', minRound: 2, maxOpen: 3, poolMaxMult: 1.2, eventMult: 0 }, { n: 3, name: 'Weitere Strecken', minRound: 4, maxOpen: 4, poolMaxMult: 1.5, eventMult: 0 }, { n: 4, name: 'Mehr Fahrzeuge', minRound: 7, maxOpen: 4, poolMaxMult: 1.7, eventMult: 0 }, { n: 5, name: 'Soft-Ramp → L2', minRound: 11, maxOpen: 4, poolMaxMult: 1.8, eventMult: 0.3 }, ]); /** Gibt Stufen-Config fuer aktuelles Game zurueck (nur L1, sonst null). */ function _l1StufeCfg(game) { const cfg = game._config || {}; if ((cfg.maxActiveContracts || 1) !== 1) return null; const round = (game.progression && game.progression.round) || 1; let pick = L1_STUFEN[1]; for (let i = 1; i < L1_STUFEN.length; i++) { if (L1_STUFEN[i] && round >= L1_STUFEN[i].minRound) pick = L1_STUFEN[i]; } return pick; } /** Kurzform nur Stufen-Nummer. */ function _l1Stufe(game) { const s = _l1StufeCfg(game); return s ? s.n : 0; } /** Nimmt ein OFFERED-Angebot an: wird zu OPEN, Expiry geloescht. */ function acceptOffer(game, contractId) { const c = (game.activeContracts || []).find(x => x.id === contractId); if (!c) throw new Error('Contract nicht gefunden: ' + contractId); if (c.state !== CONTRACT_STATE.OFFERED) { throw new Error('Contract ' + contractId + ' ist nicht OFFERED (state=' + c.state + ')'); } // Pruefen: gibt es noch Platz fuer einen weiteren OPEN? const openCount = (game.activeContracts || []).filter(x => x.state === CONTRACT_STATE.OPEN).length; if (openCount >= _effectiveMaxContracts(game)) { throw new Error('Maximale parallele Auftraege erreicht (' + openCount + ')'); } c.state = CONTRACT_STATE.OPEN; c.offerExpiryTime = null; return c; } /** Lehnt ein OFFERED-Angebot ab: wird aus activeContracts entfernt. */ function declineOffer(game, contractId) { const before = (game.activeContracts || []).length; game.activeContracts = (game.activeContracts || []).filter(c => { return !(c.id === contractId && c.state === CONTRACT_STATE.OFFERED); }); return (game.activeContracts || []).length < before; } /** L2-Auftragspool (Neuanfang 2026-04-25) — Klon des L1-Pools, aber * ergaenzt um 2-Container-Auftraege auf passenden Strecken. So lohnt * der TRUCK_MID (Cap 2) durch Buendelung. Container-Range wird in * _generateL2ContractFromPool ausgewuerfelt. * tier 1 = ab Runde 1 (Einstiegsrouten DACH+Brenner+Berlin) * tier 2 = ab Runde 3 (ganz Europa, inkl. Häfen) */ const L2_OFFER_POOL = [ // ── Tier 1: DACH + Mailand + Berlin (wie L1) ── { origin: 'salzburg', target: 'muenchen', mult: 0.7, cont: [1, 1], tier: 1 }, { origin: 'muenchen', target: 'salzburg', mult: 0.7, cont: [1, 1], tier: 1 }, { origin: 'wien', target: 'graz', mult: 0.8, cont: [1, 1], tier: 1 }, { origin: 'graz', target: 'wien', mult: 0.8, cont: [1, 1], tier: 1 }, { origin: 'wien', target: 'salzburg', mult: 1.0, cont: [1, 1], tier: 1 }, { origin: 'salzburg', target: 'wien', mult: 1.0, cont: [1, 1], tier: 1 }, { origin: 'muenchen', target: 'innsbruck', mult: 0.7, cont: [1, 1], tier: 1 }, { origin: 'innsbruck',target: 'muenchen', mult: 0.7, cont: [1, 1], tier: 1 }, { origin: 'wien', target: 'muenchen', mult: 1.3, cont: [1, 2], tier: 1 }, { origin: 'muenchen', target: 'wien', mult: 1.3, cont: [1, 2], tier: 1 }, { origin: 'salzburg', target: 'graz', mult: 1.0, cont: [1, 2], tier: 1 }, { origin: 'graz', target: 'salzburg', mult: 1.0, cont: [1, 2], tier: 1 }, { origin: 'innsbruck',target: 'salzburg', mult: 0.8, cont: [1, 2], tier: 1 }, { origin: 'salzburg', target: 'innsbruck', mult: 0.8, cont: [1, 2], tier: 1 }, { origin: 'muenchen', target: 'mailand', mult: 1.2, cont: [1, 2], tier: 1 }, { origin: 'mailand', target: 'muenchen', mult: 1.2, cont: [1, 2], tier: 1 }, { origin: 'innsbruck',target: 'mailand', mult: 1.1, cont: [1, 2], tier: 1 }, { origin: 'mailand', target: 'innsbruck', mult: 1.1, cont: [1, 2], tier: 1 }, { origin: 'salzburg', target: 'mailand', mult: 1.4, cont: [1, 2], tier: 1 }, { origin: 'mailand', target: 'salzburg', mult: 1.4, cont: [1, 2], tier: 1 }, { origin: 'muenchen', target: 'berlin', mult: 1.5, cont: [1, 2], tier: 1 }, { origin: 'berlin', target: 'muenchen', mult: 1.5, cont: [1, 2], tier: 1 }, { origin: 'wien', target: 'berlin', mult: 1.7, cont: [1, 2], tier: 1 }, { origin: 'berlin', target: 'wien', mult: 1.7, cont: [1, 2], tier: 1 }, // ── Tier 2: ab Runde 3, ganz Europa und Häfen (NEU 2026-04-25) ── // Frankreich { origin: 'paris', target: 'rotterdam', mult: 1.5, cont: [1, 2], tier: 2 }, { origin: 'rotterdam',target: 'paris', mult: 1.5, cont: [1, 2], tier: 2 }, { origin: 'paris', target: 'mailand', mult: 2.0, cont: [1, 2], tier: 2 }, { origin: 'paris', target: 'madrid', mult: 2.5, cont: [1, 2], tier: 2 }, { origin: 'madrid', target: 'paris', mult: 2.5, cont: [1, 2], tier: 2 }, // Deutschland-Nord + Skandinavien { origin: 'hamburg', target: 'rotterdam', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'rotterdam',target: 'hamburg', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'berlin', target: 'kopenhagen',mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'kopenhagen',target:'berlin', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'berlin', target: 'warschau', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'warschau', target: 'berlin', mult: 1.4, cont: [1, 2], tier: 2 }, // Häfen-Anbindung — Stadt zum Hafen (Luftlinie wenn Hafen nicht in Roadnet) { origin: 'hamburg', target: 'hamburg_hafen', mult: 0.5, cont: [1, 2], tier: 2 }, { origin: 'rotterdam',target: 'rotterdam_hafen', mult: 0.5, cont: [1, 2], tier: 2 }, { origin: 'wien', target: 'triest_hafen', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'muenchen', target: 'genua_hafen', mult: 1.6, cont: [1, 2], tier: 2 }, { origin: 'mailand', target: 'genua_hafen', mult: 0.8, cont: [1, 2], tier: 2 }, { origin: 'hamburg', target: 'goeteborg_hafen', mult: 1.5, cont: [1, 2], tier: 2 }, { origin: 'warschau', target: 'gdansk_hafen', mult: 1.0, cont: [1, 2], tier: 2 }, // Rückrichtungen Hafen → Stadt { origin: 'hamburg_hafen', target: 'berlin', mult: 1.0, cont: [1, 2], tier: 2 }, { origin: 'rotterdam_hafen', target: 'paris', mult: 1.5, cont: [1, 2], tier: 2 }, // ── Tier 2: AT/DE → Westeuropa & Skandinavien (Thomas 2026-04-26) ── // Damit Spieler auch von Wien/Salzburg/Innsbruck/Graz und München zu // den lukrativen Fernzielen kommt — und Retouren existieren. { origin: 'wien', target: 'paris', mult: 2.5, cont: [1, 2], tier: 2 }, { origin: 'paris', target: 'wien', mult: 2.5, cont: [1, 2], tier: 2 }, { origin: 'wien', target: 'rotterdam', mult: 2.2, cont: [1, 2], tier: 2 }, { origin: 'rotterdam', target: 'wien', mult: 2.2, cont: [1, 2], tier: 2 }, { origin: 'wien', target: 'hamburg', mult: 1.8, cont: [1, 2], tier: 2 }, { origin: 'hamburg', target: 'wien', mult: 1.8, cont: [1, 2], tier: 2 }, { origin: 'wien', target: 'warschau', mult: 1.5, cont: [1, 2], tier: 2 }, { origin: 'warschau', target: 'wien', mult: 1.5, cont: [1, 2], tier: 2 }, { origin: 'wien', target: 'kopenhagen', mult: 2.0, cont: [1, 2], tier: 2 }, { origin: 'kopenhagen',target: 'wien', mult: 2.0, cont: [1, 2], tier: 2 }, { origin: 'salzburg', target: 'paris', mult: 2.3, cont: [1, 2], tier: 2 }, { origin: 'paris', target: 'salzburg', mult: 2.3, cont: [1, 2], tier: 2 }, { origin: 'salzburg', target: 'hamburg', mult: 1.6, cont: [1, 2], tier: 2 }, { origin: 'hamburg', target: 'salzburg', mult: 1.6, cont: [1, 2], tier: 2 }, { origin: 'muenchen', target: 'paris', mult: 1.8, cont: [1, 2], tier: 2 }, { origin: 'paris', target: 'muenchen', mult: 1.8, cont: [1, 2], tier: 2 }, { origin: 'muenchen', target: 'rotterdam', mult: 1.6, cont: [1, 2], tier: 2 }, { origin: 'rotterdam', target: 'muenchen', mult: 1.6, cont: [1, 2], tier: 2 }, { origin: 'muenchen', target: 'hamburg', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'hamburg', target: 'muenchen', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'muenchen', target: 'warschau', mult: 1.7, cont: [1, 2], tier: 2 }, { origin: 'warschau', target: 'muenchen', mult: 1.7, cont: [1, 2], tier: 2 }, { origin: 'muenchen', target: 'kopenhagen', mult: 1.7, cont: [1, 2], tier: 2 }, { origin: 'kopenhagen',target: 'muenchen', mult: 1.7, cont: [1, 2], tier: 2 }, { origin: 'innsbruck', target: 'paris', mult: 2.0, cont: [1, 2], tier: 2 }, { origin: 'paris', target: 'innsbruck', mult: 2.0, cont: [1, 2], tier: 2 }, { origin: 'graz', target: 'warschau', mult: 1.7, cont: [1, 2], tier: 2 }, { origin: 'warschau', target: 'graz', mult: 1.7, cont: [1, 2], tier: 2 }, { origin: 'graz', target: 'mailand', mult: 1.4, cont: [1, 2], tier: 2 }, { origin: 'mailand', target: 'graz', mult: 1.4, cont: [1, 2], tier: 2 }, ]; /** Train-spezifische Auftraege (4-6 Container, lange Strecken). * Train startet in Hamburg, daher viele hamburg-Routen + Rueckwege. */ const L2_TRAIN_POOL = [ // Hamburg-Hub (Train-Spawn) { origin: 'hamburg', target: 'rotterdam', mult: 1.4, cont: [5, 6] }, { origin: 'rotterdam',target: 'hamburg', mult: 1.4, cont: [5, 6] }, { origin: 'hamburg', target: 'berlin', mult: 1.2, cont: [4, 6] }, { origin: 'berlin', target: 'hamburg', mult: 1.2, cont: [4, 6] }, { origin: 'hamburg', target: 'muenchen', mult: 1.7, cont: [5, 6] }, { origin: 'muenchen', target: 'hamburg', mult: 1.7, cont: [5, 6] }, // Lange DACH-Achse (Train-tauglich) { origin: 'wien', target: 'muenchen', mult: 1.3, cont: [4, 6] }, { origin: 'muenchen', target: 'mailand', mult: 1.3, cont: [4, 6] }, { origin: 'wien', target: 'mailand', mult: 1.7, cont: [4, 6] }, { origin: 'muenchen', target: 'berlin', mult: 1.5, cont: [4, 6] }, { origin: 'wien', target: 'berlin', mult: 1.7, cont: [4, 6] }, ]; /** Generiert einen L2-Auftrag aus dem Pool. Container-Anzahl per cont-Range. * Tier-Filter: tier 1 von Anfang an, tier 2 ab Runde 3. * Train-Wave (Thomas-Regel): wenn ein TRAIN_SMALL gemietet ist, kommt * pro 3 generierte Aufträge genau 1 train-tauglicher (4-6 Container, * lange Strecke). Letzte 24 h Sim-Zeit: keine Train-Aufträge mehr — * der Spieler muss seine Train-Investition rechtzeitig amortisieren. */ function _generateL2ContractFromPool(game, code, alreadyOffered) { const usedOrigins = new Set(alreadyOffered.map(c => c.originLocationId)); const vehicleLocs = new Set((game.vehicles || []).map(v => v.currentLocationId)); const round = (game.progression && game.progression.round) || 1; const maxTier = round >= 3 ? 2 : 1; const cfg = game._config || {}; const trainActive = (game.vehicles || []).some(v => v.mode === 'TRAIN_SMALL' || v.mode === 'TRAIN'); const elapsedH = game.simulationStartTime ? (new Date(game.simulationTime).getTime() - new Date(game.simulationStartTime).getTime()) / 3600000 : 0; const limitH = cfg.timeLimitHours || 144; const isLastDay = (limitH - elapsedH) < 24; // Train-Wave: every 3rd train-tauglich, ausser im letzten Tag. // Routen vom aktuellen Train-Standort werden bevorzugt (sonst muss // der Train erst eine Leerfahrt zum Pickup machen). if (trainActive && !isLastDay) { if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; const cnt = game.progression.trainWaveCounter = (game.progression.trainWaveCounter || 0) + 1; if (cnt % 3 === 1) { // jeder 3., beginnend mit dem 1. nach Anmietung const train = (game.vehicles || []).find(v => v.mode === 'TRAIN_SMALL' || v.mode === 'TRAIN'); const trainLoc = train ? train.currentLocationId : 'hamburg'; const direct = L2_TRAIN_POOL.filter(p => p.origin === trainLoc); const trainPick = (direct.length && _progressionRng(game) < 0.7) ? direct[Math.floor(_progressionRng(game) * direct.length)] : L2_TRAIN_POOL[Math.floor(_progressionRng(game) * L2_TRAIN_POOL.length)]; const [tcMin, tcMax] = trainPick.cont || [4, 6]; const trainContainers = tcMin + Math.floor(_progressionRng(game) * (tcMax - tcMin + 1)); const tc = _makeContract(game, code, trainPick.origin, trainPick.target, trainContainers, false); tc.rewardBase = Math.round(tc.rewardBase * trainPick.mult); tc.trainEligible = true; return tc; } } // Mindest-Quote (Thomas 2026-04-26): immer >= 3 sichtbare (OPEN+RESERVED) // mit nur 1 Container (Paketcontainer fuer Cap-1-Fahrzeuge). const oneContainerCount = (alreadyOffered || []) .filter(c => (c.state === 'OPEN' || c.state === 'RESERVED') && (c.quantityContainers || 1) === 1).length; const forceSingle = oneContainerCount < 3; let pool = L2_OFFER_POOL.filter(p => (p.tier || 1) <= maxTier); if (forceSingle) { // Nur Templates mit cont-Range, der 1 enthaelt pool = pool.filter(p => (p.cont || [1, 1])[0] === 1); } // Bevorzuge nicht-genutzte Origins fuer Vielfalt (Rotation, damit // kein LKW am "Ende der Welt" festhaengt). Auto-Retoure (s.u.) sorgt // separat dafuer, dass IDLE-Standorte einen Rueckweg-Auftrag haben. let candidates = pool.filter(p => !usedOrigins.has(p.origin)); if (!candidates.length) candidates = pool; const direct = candidates.filter(p => vehicleLocs.has(p.origin)); const away = candidates.filter(p => !vehicleLocs.has(p.origin)); let pick; // Vehicle-Loc-Bias 0.7 (Thomas 2026-04-26 Re-Adjust): am Start braucht // Anna in Wien sofort mehrere Wien-Origin-Auftraege, sonst nur Verluste. if (direct.length && away.length === 0) { pick = direct[Math.floor(_progressionRng(game) * direct.length)]; } else if (direct.length && _progressionRng(game) < 0.7) { pick = direct[Math.floor(_progressionRng(game) * direct.length)]; } else if (away.length) { pick = away[Math.floor(_progressionRng(game) * away.length)]; } else { pick = candidates[Math.floor(_progressionRng(game) * candidates.length)]; } const [cMin, cMax] = pick.cont || [1, 1]; // Bei forceSingle Container-Wuerfel auf 1 zwingen (Mindest-Quote 1-Cont) const containers = forceSingle ? 1 : cMin + Math.floor(_progressionRng(game) * (cMax - cMin + 1)); const c = _makeContract(game, code, pick.origin, pick.target, containers, false); c.rewardBase = Math.round(c.rewardBase * pick.mult); if (!vehicleLocs.has(pick.origin)) c.awayBonus = true; return c; } /** L1-Auftragspool — bewusst begrenzt auf DACH-Raum bis Mailand/Berlin. * ALLE Staedte sind Roadnet-Knoten, d.h. die Fahrzeuge nutzen echte * Autobahn-Polylines (keine Luftlinie-Fallbacks). Reward-Mult * kompensiert zusaetzliche Distanz. Richtung spielt keine Rolle — jede * Route existiert in beiden Richtungen. */ const L1_OFFER_POOL = [ // Stufe 1: sehr kurze Einstiegs-Touren (mult <= 1.0) { origin: 'salzburg', target: 'muenchen', mult: 0.7 }, // 143 km { origin: 'muenchen', target: 'salzburg', mult: 0.7 }, { origin: 'muenchen', target: 'innsbruck', mult: 0.7 }, // 163 km { origin: 'innsbruck',target: 'muenchen', mult: 0.7 }, { origin: 'innsbruck',target: 'salzburg', mult: 0.8 }, // 186 km { origin: 'salzburg', target: 'innsbruck', mult: 0.8 }, { origin: 'wien', target: 'graz', mult: 0.8 }, // 194 km { origin: 'graz', target: 'wien', mult: 0.8 }, { origin: 'salzburg', target: 'graz', mult: 1.0 }, // 278 km { origin: 'graz', target: 'salzburg', mult: 1.0 }, { origin: 'wien', target: 'salzburg', mult: 1.0 }, // 296 km, Klassiker { origin: 'salzburg', target: 'wien', mult: 1.0 }, // Stufe 2: mittlere Distanzen (mult <= 1.2) { origin: 'innsbruck',target: 'mailand', mult: 1.1 }, // 389 km Brenner { origin: 'mailand', target: 'innsbruck', mult: 1.1 }, { origin: 'wien', target: 'muenchen', mult: 1.2 }, // ~500 km (via Salzburg) { origin: 'muenchen', target: 'wien', mult: 1.2 }, { origin: 'muenchen', target: 'mailand', mult: 1.2 }, // 493 km Brenner { origin: 'mailand', target: 'muenchen', mult: 1.2 }, // Stufe 3: weitere Strecken (mult <= 1.5) { origin: 'salzburg', target: 'mailand', mult: 1.4 }, // ~640 km Brenner { origin: 'mailand', target: 'salzburg', mult: 1.4 }, { origin: 'muenchen', target: 'berlin', mult: 1.5 }, // 585 km { origin: 'berlin', target: 'muenchen', mult: 1.5 }, // Stufe 4+: weite DACH-Nord-Sued-Achse (mult <= 1.7) { origin: 'wien', target: 'berlin', mult: 1.7 }, // ~880 km via Salzburg+Muenchen { origin: 'berlin', target: 'wien', mult: 1.7 }, { origin: 'wien', target: 'mailand', mult: 1.8 }, // ~970 km via Innsbruck { origin: 'mailand', target: 'wien', mult: 1.8 }, ]; /** L1-Auftragsprofile — jedes Angebot bekommt ein Profil gewuerfelt, * das Reward-Mult + Frist-Mult + ggf. Pool-Filter festlegt. Gibt dem * Spieler echte Entscheidungen (lohnend vs. zeitlich riskant). */ const L1_PROFILES = Object.freeze({ standard: { weight: 50, rewardMult: 1.0, deadlineMult: 1.0 }, tight: { weight: 20, rewardMult: 1.3, deadlineMult: 0.6 }, // mehr Geld, knappe Frist far: { weight: 15, rewardMult: 1.0, deadlineMult: 1.0, minPoolMult: 1.5 }, // weit + Abstecher-Risiko easy: { weight: 15, rewardMult: 0.85, deadlineMult: 1.3, maxPoolMult: 1.0 }, // kurz, wenig, schnell fertig }); function _pickL1Profile(game, stufeCfg) { // Stufe 1: immer standard (Tutorial) if (stufeCfg.n <= 1) return Object.assign({ name: 'standard' }, L1_PROFILES.standard); const entries = Object.entries(L1_PROFILES); const total = entries.reduce((s, [,p]) => s + p.weight, 0); let r = _progressionRng(game) * total, cum = 0; for (const [name, p] of entries) { cum += p.weight; if (r < cum) return Object.assign({ name }, p); } return Object.assign({ name: 'standard' }, L1_PROFILES.standard); } /** Generiert einen L1-Auftrag aus dem Pool. Immer OPEN, immer 1 Container, * nie Express — aber mit **Profil-Variance** (standard/tight/far/easy) fuer * echte Entscheidungsvielfalt. „Kommen und gehen": Auftrag verfaellt still * wenn seine `dueTime` ohne Zuweisung verstreicht. */ function _generateL1ContractFromPool(game, code, alreadyOffered) { const usedOrigins = new Set(alreadyOffered.map(c => c.originLocationId)); const vehicleLocs = new Set((game.vehicles || []).map(v => v.currentLocationId)); const stufeCfg = _l1StufeCfg(game) || L1_STUFEN[1]; const profile = _pickL1Profile(game, stufeCfg); let pool = L1_OFFER_POOL.filter(p => p.mult <= stufeCfg.poolMaxMult); if (profile.minPoolMult) pool = pool.filter(p => p.mult >= profile.minPoolMult); if (profile.maxPoolMult) pool = pool.filter(p => p.mult <= profile.maxPoolMult); if (!pool.length) pool = L1_OFFER_POOL.filter(p => p.mult <= stufeCfg.poolMaxMult); let candidates = pool.filter(p => !usedOrigins.has(p.origin)); if (!candidates.length) candidates = pool; const direct = candidates.filter(p => vehicleLocs.has(p.origin)); const away = candidates.filter(p => !vehicleLocs.has(p.origin)); let pick; if (alreadyOffered.length === 0 && direct.length) { pick = direct[Math.floor(_progressionRng(game) * direct.length)]; } else if (away.length) { pick = away[Math.floor(_progressionRng(game) * away.length)]; } else { pick = candidates[Math.floor(_progressionRng(game) * candidates.length)]; } const c = _makeContract(game, code, pick.origin, pick.target, 1, false); c.rewardBase = Math.round(c.rewardBase * pick.mult * profile.rewardMult); c.profile = profile.name; if (!vehicleLocs.has(pick.origin)) c.awayBonus = true; // Frist per Profil strecken/stauchen if (profile.deadlineMult !== 1.0) { const nowMs = new Date(game.simulationTime).getTime(); const origDueMs = new Date(c.dueTime).getTime(); const adjustedMs = nowMs + (origDueMs - nowMs) * profile.deadlineMult; c.dueTime = new Date(adjustedMs).toISOString(); } return c; } /** Deterministische Progression-RNG (verbraucht eigenen Counter). */ function _progressionRng(game) { if (!game._progressionRngCalls) game._progressionRngCalls = 0; const rng = _makeRng((game.seed || 1) + 99991); for (let i = 0; i < game._progressionRngCalls; i++) rng(); const v = rng(); game._progressionRngCalls++; return v; } /** Gesamt-Slot-Groesse: einfach = effektives max (kein Offers-Buffer mehr). */ function _totalContractSlots(game) { return _effectiveMaxContracts(game); } function _maybeGenerateFollowupContract(game) { const cfg = game._config || {}; const cfgMax = cfg.maxActiveContracts || 1; const max = _effectiveMaxContracts(game); // L2-Pfad (Neuanfang 2026-04-25): wie L1, aber eigener Pool mit // 1- und 2-Container-Auftraegen. Auftraege koennen weiter generiert // werden bis maxActiveContracts erreicht. Identifikator: maxActiveContracts // > 1 UND cfg.railEnabled (signalisiert L2 vs. L3). if (cfgMax > 1 && cfg.railEnabled && !cfg.intermodalEnabled) { // Thomas-Regel (2026-04-26): IMMER mindestens MIN_VISIBLE Karten // (OPEN + RESERVED) sichtbar. Sobald eine wegfaellt, sofort // nachfuellen. >= MIN_OPEN_1C davon muessen 1-Container sein // (forceSingle in _generateL2ContractFromPool sorgt dafuer). const MIN_VISIBLE = Math.max(cfgMax, 7); const MIN_OPEN_1C = 3; if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; if (typeof game.progression.contractCounter !== 'number') { game.progression.contractCounter = (game.completedContracts || []).length + (game.activeContracts || []).length; } // 1) Sichtbar-Slots auffuellen (OPEN + RESERVED, NUR nicht abgelaufen). // Vorher: nur c.state-Check -> verfallene OPEN zaehlten als visible, // UI versteckte sie aber, Liste blieb leer. Jetzt deckungsgleich mit UI. const _nowMsRefill = new Date(game.simulationTime).getTime(); const _isVisible = (c) => { if (c.state !== 'OPEN' && c.state !== 'RESERVED') return false; if (c.dueTime && new Date(c.dueTime).getTime() < _nowMsRefill) return false; return true; }; let safety = 40; while (safety-- > 0) { const visibleCount = (game.activeContracts || []).filter(_isVisible).length; if (visibleCount >= MIN_VISIBLE) break; game.progression.contractCounter++; const code = 'C-' + String(game.progression.contractCounter).padStart(3, '0'); try { const follow = _generateL2ContractFromPool(game, code, game.activeContracts || []); if (follow) { game.activeContracts.push(follow); _validateUniqueIds(game); } // KEIN break bei !follow — naechster Versuch (z.B. Train-Wave hat // verschluckt, normaler Auftrag muss kommen) } catch (e) { console.warn('[L2 refill skip]', e && e.message); // continue, NICHT break — eine kaputte Generierung blockt nicht alles } } // 2) 1-Container-Quote separat absichern (>= 3 sichtbare 1-Cont). let safetyOne = 12; while (safetyOne-- > 0) { const oneCount = (game.activeContracts || []) .filter(c => _isVisible(c) && (c.quantityContainers || 1) === 1).length; if (oneCount >= MIN_OPEN_1C) break; game.progression.contractCounter++; const code = 'C-' + String(game.progression.contractCounter).padStart(3, '0'); try { const follow = _generateL2ContractFromPool(game, code, game.activeContracts || []); if (follow) { game.activeContracts.push(follow); _validateUniqueIds(game); } } catch (e) { console.warn('[L2 1c-refill skip]', e && e.message); } } // Auto-Retoure (Thomas 2026-04-26): fuer jedes IDLE Fahrzeug an einem // Standort, von dem kein OPEN-Auftrag startet, force-spawn einen. // Erspart Leerfahrt zurueck, gerade wenn LKW in Paris/Hamburg/etc. steht. const round = (game.progression && game.progression.round) || 1; const maxTier = round >= 3 ? 2 : 1; const idleLocs = new Set( (game.vehicles || []) .filter(v => v.state === 'IDLE' && !v.tripPhase && v.currentLocationId) .map(v => v.currentLocationId) ); // Pro IDLE-Standort >= 2 OPEN-1-Cont garantieren (Cap-1-Trucks brauchen // mehrere wien-/muenchen-Origin-Auftraege fuer rentable Auswahl). for (const loc of idleLocs) { const localPool1c = L2_OFFER_POOL.filter(p => p.origin === loc && (p.tier || 1) <= maxTier && (p.cont || [1,1])[0] === 1 ); if (!localPool1c.length) continue; let safetyLoc = 5; while (safetyLoc-- > 0) { const openFromLoc1c = (game.activeContracts || []) .filter(c => c.state === 'OPEN' && c.originLocationId === loc && (c.quantityContainers || 1) === 1).length; if (openFromLoc1c >= 2) break; try { const pick = localPool1c[Math.floor(_progressionRng(game) * localPool1c.length)]; game.progression.contractCounter++; const code = 'C-' + String(game.progression.contractCounter).padStart(3, '0'); const c = _makeContract(game, code, pick.origin, pick.target, 1, false); c.rewardBase = Math.round(c.rewardBase * pick.mult); c.returnTrip = true; game.activeContracts.push(c); _validateUniqueIds(game); } catch (e) { console.warn('[L2 auto-return]', e && e.message); break; } } } return; } // Legacy-Pfad fuer altes komplexes L2/L3 (Templates-basiert) if (cfgMax !== 1) { if ((game.activeContracts || []).length >= max) return; if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; if (typeof game.progression.contractCounter !== 'number') { game.progression.contractCounter = (game.completedContracts || []).length + (game.activeContracts || []).length; } game.progression.contractCounter++; const code = 'C-' + String(game.progression.contractCounter).padStart(3, '0'); const tpl = _nextContractTemplate(game); const follow = _makeContract(game, code, tpl.origin, tpl.target, tpl.containers, tpl.express); follow.cargoTypeId = tpl.cargoTypeId; game.activeContracts.push(follow); _validateUniqueIds(game); return; } // L1: fuelle OPEN+OFFERED bis total-slots _maybeRefillOffers(game); // Auto-Buy nach jeder Generation pruefen _maybeAutoBuyVehicle(game); } /** Fuellt OFFERED-Angebote + OPEN-Slots bis totalContractSlots erreicht ist. * Auf Stufe 1 (offersBuffer=0) fuellt nur OPEN-Slots. */ function _maybeRefillOffers(game) { const cfg = game._config || {}; if ((cfg.maxActiveContracts || 1) !== 1) return; // Nur L1 const total = _totalContractSlots(game); const stufe = _l1StufeCfg(game) || {}; // Thomas-Regel (2026-04-25): mit 25% Wahrscheinlichkeit einen bereits // offenen Auftrag als Bonus-Zwilling mit +50% Reward anbieten. So hat // der Spieler eine echte Wahl: Standard nehmen oder auf den Bonus warten // und die lukrative Variante erwischen. Nur ab Stufe 2 (Tutorial- // freundlich). if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; // Bug-Fix (2026-04-25): contractCounter darf NIEMALS zurueckgehen, // sonst kollidieren Codes/IDs zwischen ausgelieferten und neuen Auftraegen. // Initial-Sync auf bereits vorhandene Aufträge, falls Counter fehlt. if (typeof game.progression.contractCounter !== 'number') { game.progression.contractCounter = (game.completedContracts || []).length + (game.activeContracts || []).length; } let safety = 8; while ((game.activeContracts || []).length < total && safety-- > 0) { game.progression.contractCounter++; const code = 'C-' + String(game.progression.contractCounter).padStart(3, '0'); const openList = (game.activeContracts || []).filter(c => c.state === CONTRACT_STATE.OPEN && !c.bonusVariant && !c.bonusTwinOf ); const stufeN = (stufe && stufe.n) || 1; const wantBonus = stufeN >= 2 && openList.length > 0 && _progressionRng(game) < 0.25; if (wantBonus) { const twin = openList[Math.floor(_progressionRng(game) * openList.length)]; const bonusContract = _makeContract(game, code, twin.originLocationId, twin.targetLocationId, 1, false); bonusContract.rewardBase = Math.round(twin.rewardBase * 1.5); bonusContract.bonusVariant = true; bonusContract.bonusTwinOf = twin.id; bonusContract.profile = twin.profile; game.activeContracts.push(bonusContract); } else { const follow = _generateL1ContractFromPool(game, code, game.activeContracts || []); game.activeContracts.push(follow); _validateUniqueIds(game); } } } /** Silent-remove unassigned OPEN-Auftraege deren dueTime verstrichen ist. * Aktiv auf L1 UND L2 (Thomas 2026-04-26): die UI verbirgt verfallene * Auftraege ohnehin — wenn die Engine sie nicht entfernt, zaehlt der * Refill-Loop sie faelschlich als "visible" und liefert 0 sichtbare * Karten obwohl activeContracts technisch voll ist. */ function _expireOffers(game) { const cfg = game._config || {}; if (cfg.intermodalEnabled) return; // L3 (legacy) ausnehmen const nowMs = new Date(game.simulationTime).getTime(); const before = (game.activeContracts || []).length; game.activeContracts = (game.activeContracts || []).filter(c => { if (c.state !== CONTRACT_STATE.OPEN) return true; // nur unassigned OPEN if (c.assignedVehicleId) return true; // ein Fahrzeug laeuft schon if (!c.dueTime) return true; return new Date(c.dueTime).getTime() > nowMs; }); const removed = before - (game.activeContracts || []).length; if (removed > 0) { if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'contract_expired_' + nowMs, type: 'CONTRACT_EXPIRED', messageShort: removed + ' Auftrag' + (removed === 1 ? '' : 'en') + ' verfallen (Frist abgelaufen).', simTime: game.simulationTime, }); } } /** L1-Auto-Buy: kauft automatisch einen TRUCK_SMALL, wenn Balance ueber * 15.000 € liegt UND maximal noch 2 Fahrzeuge da sind. Kosten 5.000 €. */ function _maybeAutoBuyVehicle(game) { // Auto-Rent nur noch auf L1 (Thomas 2026-04-25). L2 nutzt manuellen // Kauf via buyVehicle-API (UI-Button im Vehicles-Panel). const cfg = game._config || {}; const isL1 = (cfg.maxActiveContracts || 1) === 1; if (!isL1) return; const cap = 2, threshold = 10000, mode = 'TRUCK_SMALL'; if ((game.vehicles || []).length >= cap) return; if ((game.balance || 0) < threshold) return; const newVeh = _spawnVehicle(game, mode); if (!newVeh) return; game.progression.trucksBought = (game.progression.trucksBought || 0) + 1; const rentPerDay = cfg.rentalCostPerDay || 0; const totalRent = rentPerDay * (game.vehicles || []).length; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'autorent_' + newVeh.id, type: 'VEHICLE_PURCHASED', messageShort: '🚚 Neuer ' + newVeh.displayName + ' gemietet — Tagesmiete jetzt ' + totalRent + ' €.', simTime: game.simulationTime, }); } /** Preisliste Fahrzeugkauf (manuell ab L2 via UI, auto auf L1 bei Balance-Trigger). */ const VEHICLE_PRICES = Object.freeze({ TRUCK_SMALL: 5000, TRUCK_MID: 10000, TRUCK_LARGE: 15000, TRAIN_SMALL: 5000, // Symbolische Anzahlung (Train ist Miete, Tagesmiete ist der Hauptkosten-Hebel) TRAIN: 40000, }); /** Manueller Fahrzeugkauf. Returns {success, vehicle?, reason?}. * Spezialfall TRAIN_SMALL: nur ab Tag 5 (Sim-Stunde 96), Spawn in Hamburg. */ function buyVehicle(game, mode) { const price = VEHICLE_PRICES[mode]; if (!price) return { success: false, reason: 'Unbekannter Fahrzeugtyp: ' + mode }; const cfg = game._config || {}; const available = cfg.availableVehicleTypes || ['TRUCK_SMALL']; if (!available.includes(mode)) { return { success: false, reason: 'Typ ' + mode + ' nicht auf diesem Level freigeschaltet' }; } // Train: nur ab Tag 5 verfuegbar (Thomas-Regel L2) if (mode === 'TRAIN_SMALL' || mode === 'TRAIN') { const elapsedH = getSimHoursElapsed(game); if (elapsedH < 96) { return { success: false, reason: 'Güterzug erst ab Tag 5 mietbar (aktuell Tag ' + (Math.floor(elapsedH/24) + 1) + ')' }; } // Limit: nur ein Train pro Spiel if ((game.vehicles || []).some(v => v.mode === 'TRAIN_SMALL' || v.mode === 'TRAIN')) { return { success: false, reason: 'Nur ein Güterzug gleichzeitig moeglich' }; } } if ((game.balance || 0) < price) { return { success: false, reason: 'Zu wenig Geld (benoetigt ' + price + ' €)' }; } // Spawn-Hub: Train kommt in Hamburg an, sonst Standard (_spawnVehicle nutzt 'wien') let veh; if (mode === 'TRAIN_SMALL' || mode === 'TRAIN') { veh = _spawnVehicle(game, mode, 'hamburg'); } else { veh = _spawnVehicle(game, mode); } if (!veh) return { success: false, reason: 'Konnte Fahrzeug nicht spawnen' }; game.balance -= price; if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; game.progression.trucksBought = (game.progression.trucksBought || 0) + 1; if (!game.pendingNotifications) game.pendingNotifications = []; const isTrain = (mode === 'TRAIN_SMALL' || mode === 'TRAIN'); game.pendingNotifications.push({ id: (isTrain ? 'rent_train_' : 'buy_') + veh.id, type: 'VEHICLE_PURCHASED', messageShort: (isTrain ? '🚂 ' : '🛒 ') + veh.displayName + (isTrain ? ' gemietet — Anzahlung ' : ' gekauft (−') + price + ' €' + (isTrain ? ', Tagesmiete läuft.' : ')'), simTime: game.simulationTime, }); return { success: true, vehicle: veh }; } /** Spawnt ein neues Fahrzeug (gleiche Logik wie seedInitialVehicles, aber einzeln). * optionaler hubLocId fuer Spawn-Ort (Default: wien). */ function _spawnVehicle(game, mode, hubLocId) { const t = VEHICLE_DEFAULTS[mode]; if (!t) return null; const locs = game.worldState.locations || []; const home = locs.find(l => l.id === 'wien') || locs[0]; if (!home) return null; if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; if (typeof game.progression.vehicleCounter !== 'number') { game.progression.vehicleCounter = (game.vehicles || []).length; } game.progression.vehicleCounter++; const idx = game.progression.vehicleCounter; const driver = _pickDriver(idx); const v = { id: 'veh_' + idx, mode, driverName: driver.name, driverLabel: driver.label, displayName: driver.name, vehicleTypeLabel: _vehicleLabel(mode), color: VEHICLE_COLORS[(idx - 1) % VEHICLE_COLORS.length], state: VEHICLE_STATE.IDLE, currentLocationId: home.id, lat: home.lat, lon: home.lon, capacityUnits: t.capacity, speedKmh: t.speedKmh, assignedContractId: null, pickupRouteId: null, deliveryRouteId: null, activeRouteId: null, tripPhase: null, segmentProgress: 0, phaseRemainingMinutes: 0, loadMinutes: 0, unloadMinutes: 0, loadedContractIds: [], tourStops: [], currentStopIndex: 0, }; if (!game.vehicles) game.vehicles = []; game.vehicles.push(v); return v; } /** * Erzeugt initiale Fahrzeuge je nach Level-Config. * Level 1: 1× TRUCK_SMALL in Wien (Tutorial). * Level 2: 1× TRUCK_SMALL Wien, 1× TRUCK_LARGE München, 1× TRAIN Hamburg. * Level 3: 2× SMALL + 2× LARGE + 1× TRAIN, verteilt auf 5 Hubs. */ // Distinkte Farben für Vehicles (bis 8 Vehicles — für L3 reicht) const VEHICLE_COLORS = Object.freeze([ '#1f4b37', // moosgrün '#b45900', // terrakotta '#1e4f7a', // stahlblau '#6d2d7a', // traube '#7a4a1e', // umber '#2d6d3d', // mittelgrün '#3a5fad', // königsblau '#9a3a3a', // ziegelrot ]); function seedInitialVehicles(game) { const cfg = game._config || {}; const types = cfg.availableVehicleTypes || ['TRUCK_SMALL']; const count = cfg.vehicleCount || 1; const locs = game.worldState.locations || []; if (!locs.length) { game.vehicles = []; return; } // Hub-Verteilung pro Level (nach maxActiveContracts als L-Indikator) const hubsPerLevel = { 1: ['wien'], 2: ['wien', 'muenchen'], // L2: 2 Hubs für 2 Start-Fahrzeuge 3: ['wien', 'muenchen', 'hamburg', 'rotterdam', 'paris'], }; // Vehicle-Typ-Sequenz pro Level const seqPerLevel = { 1: ['TRUCK_SMALL'], 2: ['TRUCK_SMALL', 'TRUCK_MID'], // L2 (Neuanfang 2026-04-25): klein + mittel 3: ['TRUCK_SMALL', 'TRUCK_LARGE', 'TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'], }; const lvlKey = (cfg.maxActiveContracts === 1) ? 1 : (cfg.maxActiveContracts <= 3 ? 2 : 3); const hubs = hubsPerLevel[lvlKey]; const seq = seqPerLevel[lvlKey]; const out = []; for (let i = 0; i < count; i++) { const mode = (seq[i] && types.includes(seq[i])) ? seq[i] : types[i % types.length]; const hubId = hubs[i % hubs.length]; const home = locs.find(l => l.id === hubId) || locs.find(l => l.id === 'wien') || locs[0]; const t = VEHICLE_DEFAULTS[mode]; if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; if (typeof game.progression.vehicleCounter !== 'number') game.progression.vehicleCounter = 0; game.progression.vehicleCounter++; const idx = game.progression.vehicleCounter; const driver = _pickDriver(idx); // Per-Level Capacity-Override: L2 hat einen kleineren TRAIN // (8 Plaetze statt 20), damit er auch zum Einstieg rentabel laeuft. let capacity = t.capacity; if (mode === 'TRAIN' && lvlKey === 2) capacity = 8; out.push({ id: 'veh_' + idx, mode, driverName: driver.name, driverLabel: driver.label, displayName: driver.name, vehicleTypeLabel: _vehicleLabel(mode) + (mode === 'TRAIN' && lvlKey === 2 ? ' (klein)' : ''), color: VEHICLE_COLORS[(idx - 1) % VEHICLE_COLORS.length], state: VEHICLE_STATE.IDLE, currentLocationId: home.id, lat: home.lat, lon: home.lon, capacityUnits: capacity, speedKmh: t.speedKmh, assignedContractId: null, // Legacy (für intermodal-Pfad) pickupRouteId: null, deliveryRouteId: null, activeRouteId: null, tripPhase: null, segmentProgress: 0, phaseRemainingMinutes: 0, loadMinutes: 0, unloadMinutes: 0, // Phase 8: Multi-Contract-Tour loadedContractIds: [], tourStops: [], currentStopIndex: 0, }); } game.vehicles = out; } function _vehicleLabel(mode) { if (mode === 'TRUCK_SMALL') return 'Kleiner LKW'; if (mode === 'TRUCK_MID') return 'Mittlerer LKW'; if (mode === 'TRUCK_LARGE') return 'Großer LKW'; if (mode === 'TRAIN_SMALL') return 'Güterzug (klein)'; if (mode === 'TRAIN') return 'Güterzug'; return mode; } /** Fahrer-Pool — wird in Reihenfolge an gespawnte Fahrzeuge zugeteilt. * Anna und Tom zuerst, weil dafuer Voice-Lines (verfahren_anna/_tom) * bereits generiert sind. Weitere Namen fuer L2/L3-Fahrzeuge. */ const DRIVERS = Object.freeze([ { name: 'Anna', label: 'Fahrerin Anna' }, { name: 'Tom', label: 'Fahrer Tom' }, { name: 'Lisa', label: 'Fahrerin Lisa' }, { name: 'Max', label: 'Fahrer Max' }, { name: 'Sara', label: 'Fahrerin Sara' }, { name: 'Felix', label: 'Fahrer Felix' }, { name: 'Mia', label: 'Fahrerin Mia' }, { name: 'Jonas', label: 'Fahrer Jonas' }, ]); function _pickDriver(idx) { return DRIVERS[(idx - 1) % DRIVERS.length]; } /** * Auftrags-Templates für deterministische Generierung (Phase 3). * Pro Template: Origin, Target, max-Container-Range, Eilauftrags-Flag. */ // Phase 8-Balance: mehr 1-Container-Aufträge, damit kleine LKWs nützlich sind. // Nur wenige „Großaufträge" (2-3 Container) bleiben für Zug / großen LKW. const CONTRACT_TEMPLATES = Object.freeze([ // ── Wien-Hub-Auftraege (passend fuer den TRUCK_SMALL in Wien) ── { origin: 'wien', target: 'salzburg', containers: [1, 1], express: false, cargoTypeIds: ['food', 'machinery'] }, { origin: 'wien', target: 'salzburg', containers: [1, 1], express: false, cargoTypeIds: ['food'] }, { origin: 'wien', target: 'muenchen', containers: [1, 1], express: false, cargoTypeIds: ['machinery', 'auto_parts'] }, { origin: 'wien', target: 'graz', containers: [1, 1], express: false, cargoTypeIds: ['food'] }, { origin: 'wien', target: 'berlin', containers: [1, 1], express: false, cargoTypeIds: ['auto_parts'] }, { origin: 'salzburg', target: 'muenchen', containers: [1, 1], express: false, cargoTypeIds: ['food', 'paper_wood'] }, { origin: 'salzburg', target: 'wien', containers: [1, 1], express: false, cargoTypeIds: ['food'] }, // ── Muenchen-Hub-Auftraege (TRUCK_LARGE-tauglich, 2-4 Container) ── { origin: 'muenchen', target: 'hamburg', containers: [2, 3], express: false, cargoTypeIds: ['machinery', 'chemicals'] }, { origin: 'muenchen', target: 'mailand', containers: [2, 3], express: false, cargoTypeIds: ['auto_parts'] }, // Brenner { origin: 'muenchen', target: 'berlin', containers: [2, 3], express: false, cargoTypeIds: ['machinery', 'electronics'] }, { origin: 'muenchen', target: 'wien', containers: [2, 3], express: false, cargoTypeIds: ['auto_parts'] }, { origin: 'mailand', target: 'muenchen', containers: [2, 3], express: false, cargoTypeIds: ['food', 'clothing'] }, // ── Hamburg-Hub-Auftraege (TRAIN-tauglich, 8-15 Container) ── { origin: 'hamburg', target: 'rotterdam', containers: [6, 8], express: false, cargoTypeIds: ['raw_materials'] }, // Hafen-Hafen, Train-Klasse (L2 Cap 8) { origin: 'hamburg', target: 'berlin', containers: [4, 6], express: false, cargoTypeIds: ['chemicals'] }, // Mittelschwer { origin: 'rotterdam', target: 'hamburg', containers: [6, 8], express: false, cargoTypeIds: ['electronics'] }, // Container Import (L2 Cap 8) { origin: 'kopenhagen', target: 'hamburg', containers: [4, 6], express: false, cargoTypeIds: ['paper_wood'] }, { origin: 'berlin', target: 'hamburg', containers: [3, 5], express: false, cargoTypeIds: ['electronics'] }, // ── Express-Aufträge (höhere Reward, knappe Frist) ── { origin: 'rotterdam', target: 'paris', containers: [1, 2], express: true, cargoTypeIds: ['electronics'] }, { origin: 'muenchen', target: 'mailand', containers: [1, 1], express: true, cargoTypeIds: ['auto_parts'] }, // ── Sonstige ── { origin: 'paris', target: 'muenchen', containers: [2, 3], express: false, cargoTypeIds: ['food', 'clothing'] }, { origin: 'berlin', target: 'warschau', containers: [2, 3], express: false, cargoTypeIds: ['chemicals'] }, ]); /** * Holt deterministisch das nächste Template aus CONTRACT_TEMPLATES. * Spult den RNG-Stream zum aktuellen Counter vor. * @private */ function _nextContractTemplate(game) { if (!game._contractRngCalls) game._contractRngCalls = 0; const rng = _makeRng(game.seed || 1); for (let i = 0; i < game._contractRngCalls; i++) rng(); const v = rng(); game._contractRngCalls++; const tpl = CONTRACT_TEMPLATES[Math.floor(v * CONTRACT_TEMPLATES.length)]; // Container-Wahl deterministisch const v2 = rng(); game._contractRngCalls++; const [cMin, cMax] = tpl.containers; const containers = cMin + Math.floor(v2 * (cMax - cMin + 1)); // Cargo-Typ deterministisch const v3 = rng(); game._contractRngCalls++; const cargoIds = tpl.cargoTypeIds || ['general']; const cargoTypeId = cargoIds[Math.floor(v3 * cargoIds.length)]; return { origin: tpl.origin, target: tpl.target, containers, express: tpl.express, cargoTypeId, }; } /** * Erzeugt initiale Aufträge je nach Level-Config. * Level 1: 1 fester Auftrag Wien → Salzburg. * Level 2/3: deterministisch aus CONTRACT_TEMPLATES (via game.seed). */ function seedInitialContracts(game) { const cfg = game._config || {}; const max = cfg.maxActiveContracts || 1; const out = []; if (max === 1) { // L1 deterministisch und hardcoded — gut für Tutorial out.push(_makeContract(game, 'C-001', 'wien', 'salzburg', 1, false)); } else if (cfg.railEnabled && !cfg.intermodalEnabled) { // L2-Neuanfang: Pool-basiert, Container-Range wird ausgewuerfelt if (!game.progression) game.progression = { round: 1, trucksBought: 0 }; game.progression.contractCounter = 0; for (let i = 0; i < max; i++) { game.progression.contractCounter++; const code = 'C-' + String(game.progression.contractCounter).padStart(3, '0'); const c = _generateL2ContractFromPool(game, code, out); out.push(c); } } else { // Legacy-Pfad fuer altes komplexes L2/L3 (Templates-basiert) for (let i = 0; i < max; i++) { const tpl = _nextContractTemplate(game); const code = 'C-' + String(i + 1).padStart(3, '0'); const c = _makeContract(game, code, tpl.origin, tpl.target, tpl.containers, tpl.express); c.cargoTypeId = tpl.cargoTypeId; out.push(c); } } game.activeContracts = out; } /** * Phase 4b: Intermodaler Contract mit mehreren Legs. * Jedes Leg = ein Vehicle-Trip. Container wandert von Leg zu Leg * über Übergabepunkte (Hafen, Terminal). * * @param {Object} game * @param {string} code * @param {Array<{mode, originId, targetId}>} legs * @param {number} containers * @param {boolean} isExpress */ function _makeIntermodalContract(game, code, legs, containers, isExpress) { const cfg = game._config || {}; const locs = game.worldState.locations || []; let totalDistanceKm = 0; let totalDurationMinutes = 0; legs.forEach(l => { const o = locs.find(x => x.id === l.originId); const t = locs.find(x => x.id === l.targetId); if (!o || !t) return; // Approximation: airline×1.3 — echte Routen werden bei assignContract berechnet const air = _segmentLength([o.lat, o.lon], [t.lat, t.lon]); const km = air * 1.3; const veh = VEHICLE_DEFAULTS[l.mode] || VEHICLE_DEFAULTS.TRUCK_SMALL; totalDistanceKm += km; totalDurationMinutes += (km / veh.speedKmh) * 60; }); const baseFristH = isExpress ? (totalDistanceKm / 70) : (totalDistanceKm / 60 * 1.5); const fristH = baseFristH * (cfg.deadlineMultiplier || 1); const due = new Date(game.simulationTime); due.setTime(due.getTime() + fristH * 60 * 60 * 1000); const baseReward = Math.round(_computeReward(totalDistanceKm, isExpress, containers) * 1.5); // Intermodal-Bonus +50 % return { id: 'contract_' + code + '_' + _nextUniqId(game), code, intermodal: true, legs: legs.map((l, i) => ({ legNum: i, mode: l.mode, originLocationId: l.originId, targetLocationId: l.targetId, state: i === 0 ? CONTRACT_STATE.OPEN : 'WAITING', assignedVehicleId: null, routeId: null, departTime: null, arrivalTime: null, })), currentLegNum: 0, currentLocationId: legs[0].originId, originLocationId: legs[0].originId, // Erster Origin targetLocationId: legs[legs.length - 1].targetId, // Letzter Target cargoTypeId: 'general', quantityContainers: containers, state: CONTRACT_STATE.OPEN, issueTime: game.simulationTime, dueTime: due.toISOString(), rewardBase: baseReward, hintMode: cfg.defaultHintMode || HINT_MODE.NONE, narrativeText: 'Intermodal: ' + legs.map(l => (locs.find(x => x.id === l.originId) || {}).name).concat( [(locs.find(x => x.id === legs[legs.length - 1].targetId) || {}).name] ).join(' → '), }; } /** * Phase 8-Tuning: Reward hat Distanz- und Container-Komponente. * - PH-Base bleibt * - Distanz-Bonus: +1.50 €/km über 300 km * - Container-Bonus: +250 € pro zusätzlichem Container (über 1 hinaus) * * Damit sind 1-Container-Aufträge für kleine LKWs wirtschaftlich, * und 2-3-Container-Aufträge bleiben für große LKW / Zug interessant. */ function _computeReward(distanceKm, isExpress, containers) { const base = isExpress ? ECONOMY.expressContractBase : ECONOMY.standardContractBase; const distanceBonus = Math.max(0, (distanceKm - 300) * 1.5); const containerBonus = Math.max(0, ((containers || 1) - 1)) * 250; return Math.round(base + distanceBonus + containerBonus); } function _makeContract(game, code, originId, targetId, containers, isExpress) { const locs = game.worldState.locations || []; const o = locs.find(l => l.id === originId); const t = locs.find(l => l.id === targetId); const cfg = game._config || {}; // Frist nach Pflichtenheft 65.6: Standard = Distanz/60 × 1.5 × deadlineMultiplier const airlineKm = (o && t) ? _segmentLength([o.lat, o.lon], [t.lat, t.lon]) : 0; const distanceKm = airlineKm * 1.3; const baseFristH = isExpress ? (distanceKm / 70) : (distanceKm / 60 * 1.5); const fristH = baseFristH * (cfg.deadlineMultiplier || 1); const due = new Date(game.simulationTime); due.setTime(due.getTime() + fristH * 60 * 60 * 1000); return { id: 'contract_' + code + '_' + _nextUniqId(game), code, originLocationId: originId, targetLocationId: targetId, cargoTypeId: 'general', quantityContainers: containers, state: CONTRACT_STATE.OPEN, isExpress: !!isExpress, issueTime: game.simulationTime, dueTime: due.toISOString(), rewardBase: _computeReward(distanceKm, isExpress, containers), recommendedModes: ['TRUCK_SMALL'], hintMode: cfg.defaultHintMode || HINT_MODE.BLINK_EXACT, narrativeText: isExpress ? 'Eilauftrag: ' + (o ? o.name : originId) + ' → ' + (t ? t.name : targetId) : 'Standard: ' + (o ? o.name : originId) + ' → ' + (t ? t.name : targetId), }; } /** @throws nicht implementiert — Phase 5 */ function useHint(game, contractId, hintMode) { throw new Error('LogistikEngine.useHint: nicht implementiert (Phase 5)'); } /** * Phase 6c: Minigame-Result wird auf nächste Verladung angewendet. * Phase 8e++: Ladebonus-Queue fuer Minispiel-Rewards. * Jeder erfolgreiche Einpark-Run erzeugt einen Bonus in Minuten. * Beim naechsten Uebergang in LOADING / UNLOADING / tour_handling wird * der aelteste Bonus aus der Queue konsumiert (FIFO) und reduziert die * Phase-Dauer um bis zu X Minuten (min. 1 Min Restzeit). */ const LOAD_BONUS_MAX_STACK = 5; function pushLoadBonus(game, minutes, source) { if (!minutes || minutes <= 0) return false; if (!game.pendingBonuses) game.pendingBonuses = []; // Stapel-Limit: aelteste fliegen beim Ueberschreiten raus while (game.pendingBonuses.length >= LOAD_BONUS_MAX_STACK) { game.pendingBonuses.shift(); } game.pendingBonuses.push({ id: 'bonus_' + (Date.now() + Math.floor(Math.random() * 1000)), minutes: Math.round(minutes), source: source || 'minigame', createdAt: game.simulationTime, }); return true; } /** Interner Konsum: beim Phase-Uebergang LOADING/UNLOADING/tour_handling. */ function _consumeNextLoadBonus(game, vehicle) { const q = game.pendingBonuses || []; if (!q.length) return null; const bonus = q.shift(); const before = vehicle.phaseRemainingMinutes || 0; const after = Math.max(1, before - bonus.minutes); vehicle.phaseRemainingMinutes = after; const actualSaved = before - after; if (!game.pendingNotifications) game.pendingNotifications = []; game.pendingNotifications.push({ id: 'bonus_consumed_' + bonus.id, type: 'LOAD_BONUS_CONSUMED', vehicleId: vehicle.id, bonusId: bonus.id, minutesSaved: actualSaved, messageShort: '⚡ Ladebonus −' + actualSaved + ' min bei ' + (vehicle.displayName || vehicle.id), simTime: game.simulationTime, }); return { bonus, savedMinutes: actualSaved }; } function applyMinigameResult(game, result) { if (!result) return; const factor = (result.metadata && typeof result.metadata.loadingTimeFactor === 'number') ? result.metadata.loadingTimeFactor : 1; game._loadingTimeModifier = factor; if (!game.analytics) game.analytics = {}; game.analytics.minigamesPlayed = (game.analytics.minigamesPlayed || 0) + 1; if (result.success) { game.analytics.minigamesSuccessful = (game.analytics.minigamesSuccessful || 0) + 1; } if (!game.pendingNotifications) game.pendingNotifications = []; const q = (result.metadata && result.metadata.quality) || (result.success ? 'ok' : 'miss'); game.pendingNotifications.push({ id: 'mg_' + Date.now(), type: 'MINIGAME_DONE', messageShort: '🎮 Mini-Übung: ' + q, simTime: game.simulationTime, }); return game; } /* ============================================================ REINE HELPER (können / sollen Phase 0 schon getestet werden) ============================================================ */ /** * Fahrkosten für eine Strecke. Pflichttest #1 aus Kickoff-Briefing. * @param {number} distanceKm * @param {number} durationHours * @param {'TRUCK_SMALL'|'TRUCK_LARGE'|'TRAIN'} mode * @returns {number} Kosten in € */ function travelCost(distanceKm, durationHours, mode) { const v = VEHICLE_DEFAULTS[mode]; if (!v) throw new Error('Unbekannter Fahrzeugmodus: ' + mode); return distanceKm * v.costPerKm + durationHours * v.costPerHour; } /** * Strafkosten bei Verspätung. Pflichttest #3. * @param {number} contractValue * @param {number} hoursLate * @returns {number} Strafe in € */ function latePenalty(contractValue, hoursLate) { return Math.max(0, contractValue * ECONOMY.latePenaltyPercentPerHour * hoursLate); } /** * Bonus-Kombi: pünktlich + optimal. Pflichttest #4. * @param {number} contractValue * @param {{ onTime: boolean, noEmpty: boolean }} flags * @returns {number} Bonus in € */ function calculateBonus(contractValue, flags) { let bonus = 0; if (flags.onTime) bonus += contractValue * ECONOMY.punctualBonusPercent; if (flags.noEmpty) bonus += contractValue * ECONOMY.optimalBonusPercent; return bonus; } /** * Interpolation entlang einer Polyline (lat/lon-Paare). * Pflichttest #2. * @param {[number,number][]} polyline * @param {number} progress 0..1 * @returns {[number,number]} [lat, lon] */ function interpolateAlongPolyline(polyline, progress) { if (!polyline || polyline.length < 2) throw new Error('Polyline zu kurz'); const p = Math.max(0, Math.min(1, progress)); if (p === 0) return polyline[0]; if (p === 1) return polyline[polyline.length - 1]; const total = _polylineLength(polyline); const target = p * total; let acc = 0; for (let i = 0; i < polyline.length - 1; i++) { const d = _segmentLength(polyline[i], polyline[i + 1]); if (acc + d >= target) { const local = (target - acc) / d; const [lat1, lon1] = polyline[i]; const [lat2, lon2] = polyline[i + 1]; return [lat1 + (lat2 - lat1) * local, lon1 + (lon2 - lon1) * local]; } acc += d; } return polyline[polyline.length - 1]; } /** * Dijkstra über Bahnnetz. Pflichttest #7. * Graph: { nodes:[{id}], edges:[{from,to,distanceKm}] } — ungerichtet. * @param {{nodes:Array, edges:Array}} graph * @param {string} startId * @param {string} endId * @returns {{path:string[], distanceKm:number}|null} */ function railShortestPath(graph, startId, endId) { const adj = new Map(); graph.nodes.forEach(n => adj.set(n.id, [])); graph.edges.forEach(e => { if (!adj.has(e.from) || !adj.has(e.to)) return; adj.get(e.from).push({ to: e.to, d: e.distanceKm }); adj.get(e.to).push({ to: e.from, d: e.distanceKm }); }); const dist = new Map(); const prev = new Map(); const queue = new Set(graph.nodes.map(n => n.id)); graph.nodes.forEach(n => dist.set(n.id, Infinity)); dist.set(startId, 0); while (queue.size) { let u = null, best = Infinity; for (const id of queue) if (dist.get(id) < best) { best = dist.get(id); u = id; } if (u === null || u === endId) break; queue.delete(u); for (const { to, d } of adj.get(u) || []) { if (!queue.has(to)) continue; const alt = dist.get(u) + d; if (alt < dist.get(to)) { dist.set(to, alt); prev.set(to, u); } } } if (!isFinite(dist.get(endId))) return null; const path = []; let cur = endId; while (cur !== undefined) { path.unshift(cur); cur = prev.get(cur); } return { path, distanceKm: dist.get(endId) }; } /* ============================================================ PRIVATE ============================================================ */ /** Mulberry32 RNG — gleich wie headless-runner.js, hier intern. */ function _makeRng(seed) { let s = (seed >>> 0) || 1; return function () { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function _hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return h >>> 0; } function _uuid() { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); } function _segmentLength(a, b) { const [lat1, lon1] = a; const [lat2, lon2] = b; const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); } function _polylineLength(poly) { let sum = 0; for (let i = 0; i < poly.length - 1; i++) sum += _segmentLength(poly[i], poly[i + 1]); return sum; } /* ============================================================ EXPORT ============================================================ */ window.LogistikEngine = { // Enums STATE, VEHICLE_MODE, VEHICLE_STATE, CONTRACT_STATE, LOCATION_TYPE, EVENT_TYPE, HINT_MODE, // Konstanten VEHICLE_DEFAULTS, VEHICLE_PRICES, ECONOMY, EVENT_RULES, // Factories createGame, // Lifecycle (Phase 1) loadContent, startPlanning, startSimulation, pause, resume, setTimeScale, onStateChange, // Tick (Phase 1: Sim-Zeit + Zeitlimit) tick, convertRealTimeToGameMinutes, getSimHoursElapsed, // Persistenz (Plattform 7b) serialize, deserialize, // Phase 2 — Aufträge, Fahrzeuge, Routen calculateRoute, assignContract, seedInitialVehicles, seedInitialContracts, buyVehicle, // Phase 4b — Intermodal makeIntermodalContract: _makeIntermodalContract, // Phase 8 — Multi-Contract-Tour loadContract, unloadContract, cancelLoading, startTour, // Phase 8e: L1-Stufen-Progression (Accept/Decline) acceptOffer, declineOffer, // Phase 8e+: Verfahren-Ereignis resolveDriverLost, // Phase 8e++: Ladebonus-Queue (pushLoadBonus fuer UI nach erfolgreichem Minispiel) pushLoadBonus, // Stubs (Phase 5/6) useHint, applyMinigameResult, // Helper (Phase 0 testbar) travelCost, latePenalty, calculateBonus, interpolateAlongPolyline, railShortestPath, }; })();