/** * 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({ 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', }); 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 }, TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 }, TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 }, }); 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, }, 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 || []; game.worldState.railnet = (seeds && seeds.railnet) || ws.railnet || { nodes: [], edges: [] }; // 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 || []); _setState(game, STATE.READY, 'content_loaded'); return game; } /** 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); // Auswertung: Zeit-Limit (Fail) und Min-Ziel (Success bei Limit-Erreich) const limit = game._config && game._config.timeLimitHours; if (typeof limit === 'number' && getSimHoursElapsed(game) >= limit) { const cfg = game._config; const startBudget = cfg.startBudget || 5000; const profit = game.balance - startBudget; const minTarget = cfg.minTargetEarnings || 0; _setState(game, profit >= minTarget ? STATE.LEVEL_SUCCESS : STATE.LEVEL_FAILED, profit >= minTarget ? 'time_limit_with_target_reached' : 'time_limit_reached' ); } else if ((game.activeContracts || []).length === 0 && (game.completedContracts || []).length > 0) { // Alle Aufträge abgeschlossen → Erfolgsprüfung sofort const cfg = game._config || {}; const startBudget = cfg.startBudget || 5000; const profit = game.balance - startBudget; const minTarget = cfg.minTargetEarnings || 0; if (profit >= minTarget) { _setState(game, STATE.LEVEL_SUCCESS, 'all_contracts_done_target_reached'); } } 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) { geometry = dij.path.map(nodeId => { const n = rail.nodes.find(x => x.id === nodeId); const loc = locs.find(l => l.id === n.locationId); return [loc.lat, loc.lon]; }); distanceKm = dij.distanceKm; provider = 'rail_dijkstra'; // Segments pro Bahnkante 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); 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), }); } } } } // 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 + '_' + Date.now(), 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; // Phase 6c: Minigame-Modifier wirkt auf nächste LOADING (dann reset) const mgFactor = game._loadingTimeModifier || 1; v.phaseRemainingMinutes = loadMin * mgFactor; if (mgFactor !== 1) game._loadingTimeModifier = 1; 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) { (game.vehicles || []).forEach(v => { // Pro Vehicle: in einer Schleife alle Phasen abarbeiten, falls die // Phase mitten im Tick endet (sonst Drift bei großen Ticks). let remaining = simMinutes; let safety = 8; // max 8 Phasen pro Tick (4 Phasen × Sicherheits-Reserve) while (remaining > 0 && v.tripPhase && safety-- > 0) { const consumed = _stepVehiclePhase(game, v, remaining); if (consumed <= 0) break; // Phase noch aktiv, kein Übertrag remaining -= consumed; } }); } /** * 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; if (stop.type === 'PICKUP') { const mgFactor = game._loadingTimeModifier || 1; handlingMinutes *= mgFactor; if (mgFactor !== 1) game._loadingTimeModifier = 1; } v.phaseRemainingMinutes = handlingMinutes; 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; if (nextStop.type === 'PICKUP') { const mgFactor = game._loadingTimeModifier || 1; m *= mgFactor; if (mgFactor !== 1) game._loadingTimeModifier = 1; } v.phaseRemainingMinutes = m; 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, }); // Phase-2-Vorgriff (für „L1 spielbar"): bei { cost += rentalPerDay * simDays; if (v.state === VEHICLE_STATE.IDLE) { cost += 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; cost += portStandPerH * (c.quantityContainers || 1) * simHours; }); } if (cost > 0) { game.balance -= cost; if (!game.analytics) game.analytics = {}; game.analytics.runningCostsTotal = (game.analytics.runningCostsTotal || 0) + cost; } } /** * 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 || {}; const probMul = (typeof cfg.eventProbabilityMultiplier === 'number') ? cfg.eventProbabilityMultiplier : 1; 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; const mgFactor = (firstStop.type === 'PICKUP' ? (game._loadingTimeModifier || 1) : 1); v.phaseRemainingMinutes = firstStop.handlingMinutes * mgFactor; if (firstStop.type === 'PICKUP' && mgFactor !== 1) game._loadingTimeModifier = 1; 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, }); _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, }); } function _maybeGenerateFollowupContract(game) { const cfg = game._config || {}; const max = cfg.maxActiveContracts || 1; if ((game.activeContracts || []).length >= max) return; const generated = (game.completedContracts || []).length + (game.activeContracts || []).length; const code = 'C-' + String(generated + 1).padStart(3, '0'); let follow; if (max === 1) { // L1: pendele Wien ↔ Salzburg (vermeidet Leerfahrten — didaktisch // illustriert Pendelprinzip, hält naive im Akzeptanzkorridor) const lastTarget = ((game.completedContracts || []).slice(-1)[0] || {}).targetLocationId; const nextOrigin = lastTarget || 'wien'; const nextTarget = nextOrigin === 'wien' ? 'salzburg' : 'wien'; follow = _makeContract(game, code, nextOrigin, nextTarget, 1, false); } else { // L2/L3: variiert aus CONTRACT_TEMPLATES const tpl = _nextContractTemplate(game); follow = _makeContract(game, code, tpl.origin, tpl.target, tpl.containers, tpl.express); follow.cargoTypeId = tpl.cargoTypeId; } game.activeContracts.push(follow); } /** * 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', 'hamburg'], 3: ['wien', 'muenchen', 'hamburg', 'rotterdam', 'paris'], }; // Vehicle-Typ-Sequenz pro Level const seqPerLevel = { 1: ['TRUCK_SMALL'], 2: ['TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'], 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]; out.push({ id: 'veh_' + (i + 1), mode, displayName: _vehicleLabel(mode) + ' #' + (i + 1), color: VEHICLE_COLORS[i % VEHICLE_COLORS.length], state: VEHICLE_STATE.IDLE, currentLocationId: home.id, lat: home.lat, lon: home.lon, capacityUnits: t.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_LARGE') return 'Großer LKW'; if (mode === 'TRAIN') return 'Güterzug'; return mode; } /** * 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([ { origin: 'wien', target: 'salzburg', containers: [1, 1], express: false, cargoTypeIds: ['food', 'machinery'] }, { origin: 'wien', target: 'muenchen', containers: [1, 2], express: false, cargoTypeIds: ['machinery', 'auto_parts'] }, { origin: 'muenchen', target: 'hamburg', containers: [2, 3], express: false, cargoTypeIds: ['machinery', 'chemicals'] }, // Großauftrag { origin: 'berlin', target: 'hamburg', containers: [1, 1], express: false, cargoTypeIds: ['electronics', 'clothing'] }, { origin: 'hamburg', target: 'rotterdam', containers: [2, 3], express: false, cargoTypeIds: ['raw_materials', 'chemicals'] }, // Großauftrag { origin: 'rotterdam', target: 'paris', containers: [1, 1], express: true, cargoTypeIds: ['electronics', 'clothing'] }, { origin: 'paris', target: 'muenchen', containers: [1, 2], express: false, cargoTypeIds: ['food', 'clothing'] }, { origin: 'wien', target: 'berlin', containers: [1, 1], express: false, cargoTypeIds: ['auto_parts', 'machinery'] }, { origin: 'muenchen', target: 'mailand', containers: [1, 1], express: true, cargoTypeIds: ['auto_parts', 'electronics'] }, { origin: 'berlin', target: 'warschau', containers: [1, 2], express: false, cargoTypeIds: ['chemicals', 'machinery'] }, { origin: 'salzburg', target: 'muenchen', containers: [1, 1], express: false, cargoTypeIds: ['food', 'paper_wood'] }, { origin: 'kopenhagen', target: 'hamburg', containers: [1, 2], express: false, cargoTypeIds: ['paper_wood', 'food'] }, ]); /** * 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 { 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, 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, code, originLocationId: originId, targetLocationId: targetId, cargoTypeId: 'general', quantityContainers: containers, state: CONTRACT_STATE.OPEN, 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. * `loadingTimeFactor` (aus result.metadata) modifiziert phaseRemainingMinutes * der nächsten LOADING-Phase. */ 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, 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, // Phase 4b — Intermodal makeIntermodalContract: _makeIntermodalContract, // Phase 8 — Multi-Contract-Tour loadContract, unloadContract, cancelLoading, startTour, // Stubs (Phase 5/6) useHint, applyMinigameResult, // Helper (Phase 0 testbar) travelCost, latePenalty, calculateBonus, interpolateAlongPolyline, railShortestPath, }; })();