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

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

3120 lines
138 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <maxActiveContracts neuen Auftrag generieren
_maybeGenerateFollowupContract(game);
}
/**
* Laufende Kosten: Standkosten pro IDLE-Vehicle pro Sim-Stunde,
* Miete pro Vehicle pro Sim-Tag, Container-Standkosten an Häfen
* (Phase 4) pro wartendem Container am Hafen pro Sim-Stunde.
* @private
*/
function _applyRunningCosts(game, simMinutes) {
const cfg = game._config || {};
const idleCostPerH = cfg.idleCostPerHour || 0;
const rentalPerDay = cfg.rentalCostPerDay || 0;
const portStandPerH = cfg.containerStandCostPerHour || 0;
if (!idleCostPerH && !rentalPerDay && !portStandPerH) return;
const simHours = simMinutes / 60;
const simDays = simMinutes / (60 * 24);
let costRental = 0;
let costIdle = 0;
let costPort = 0;
(game.vehicles || []).forEach(v => {
// 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,
};
})();