Files
geograsim/App/sims/logistik/engine.js
T
Adminator fc8996f064 Atlas: DSGVO-Tile-Proxy + Logistik-Phase-bis-8 + Sounds-Anleitung
Platform:
- OSM-DE als dritter Tile-Proxy-Provider (deutsche Beschriftungen)
- logistik.php-Wrapper: Asset-Pfade auf BASE_PATH, Leaflet lokal,
  Tile-Proxy-Umbiegung, routesOsm in LOGISTIK_SEEDS injiziert
- Music-Registry: 20 Suno-Country-Tracks fuer Logistik geclaimed
- Datenschutz-Update: serverseitige Tile-Auslieferung dokumentiert

Logistik (Instanz-Lieferungen, parallel):
- Phase 0 bis 8 inkl. Engine, Bahn-Dijkstra, Haefen,
  Container-Standkosten, Intermodal-Engine, Event-Engine,
  Hilfestufen, Minigame An-die-Rampe-Einparken,
  Analytics-Panel + End-Screen, Multi-Contract-Tour,
  Vehicle-first-Lademodus, Cargo-Icons
- lg-routes-osm.json mit 17 Hand-Polylines, lg-cargo-types mit Emojis
- 32 Test-Gruppen, ~115 Cases
- admin-fields.json fuer Admin-UI-Integration

Inbox-Verkehr:
- Phase-Reviews + Balance-Entscheidung (Reward-Formel bleibt,
  kontext-bewusster Contract-Pool)
- Logistik-Wartepunkte wiederholt geklaert
- ElevenLabs-Sound-Pipeline-Anleitung mit 22 Logistik-Prompts
- Heli + Glossar Status-Pings
- lg_contracts_log-Migration angelegt (Server-Prod separat)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:15:00 +02:00

2019 lines
81 KiB
JavaScript
Raw 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({
OPEN: 'OPEN',
RESERVED: 'RESERVED',
ASSIGNED: 'ASSIGNED',
PICKUP_PENDING: 'PICKUP_PENDING',
LOADING: 'LOADING',
IN_TRANSIT: 'IN_TRANSIT',
AT_TRANSFER: 'AT_TRANSFER',
DELIVERED: 'DELIVERED',
LATE: 'LATE',
FAILED: 'FAILED',
CANCELLED: 'CANCELLED',
});
const LOCATION_TYPE = Object.freeze({
CITY: 'CITY',
CAPITAL: 'CAPITAL',
PORT: 'PORT',
TERMINAL: 'TERMINAL',
INDUSTRY: 'INDUSTRY',
CUSTOMER: 'CUSTOMER',
HUB: 'HUB',
});
const EVENT_TYPE = Object.freeze({
TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT',
SNOW: 'SNOW',
STORM: 'STORM',
PORT_DELAY: 'PORT_DELAY',
RAIL_STRIKE: 'RAIL_STRIKE',
SPECIAL_ORDER: 'SPECIAL_ORDER',
MAINTENANCE: 'MAINTENANCE',
});
const HINT_MODE = Object.freeze({
BLINK_EXACT: 'BLINK_EXACT',
SHOW_COUNTRY: 'SHOW_COUNTRY',
SHOW_REGION: 'SHOW_REGION',
DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK',
NONE: 'NONE',
});
/* ============================================================
KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65
Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt
sie ggf. pro Level.
============================================================ */
const VEHICLE_DEFAULTS = Object.freeze({
TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5 },
TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 },
TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 },
});
const ECONOMY = Object.freeze({
standCostContainerPerHour: 10, // Container im Hafen
standCostVehicleIdlePerHour: 5,
latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts
punctualBonusPercent: 0.1, // +10%
optimalBonusPercent: 0.2, // +20% (keine Leerfahrt)
standardContractBase: 1000,
expressContractBase: 1500,
});
const EVENT_RULES = Object.freeze({
TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 },
SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] },
PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 },
});
/* ============================================================
FACTORIES
============================================================ */
/**
* Erzeugt einen neuen Spielzustand für das gegebene Level.
* Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults.
*
* Tests/Headless-Runs können `opts.deterministic = true` setzen, dann
* werden `sessionId` und `simulationTime` reproduzierbar (Seed-basiert).
*
* @param {number} levelNum
* @param {{deterministic?:boolean, seed?:number}} [opts]
* @returns {Object} gameState
*/
function createGame(levelNum, opts) {
const o = opts || {};
const levelCfg = (window.LOGISTIK_LEVELS || []).find(l => l.level === levelNum)?.params || {};
const startIso = o.deterministic
? '2026-01-01T08:00:00.000Z'
: new Date().toISOString();
return {
seed: o.seed || _hashStr('default-seed-' + levelNum),
sessionId: o.deterministic ? ('sess_' + (o.seed || 0)) : _uuid(),
levelId: 'level_' + levelNum,
state: STATE.INIT,
simulationTime: startIso,
simulationStartTime: startIso,
timeScale: levelCfg.initialTimeScale ?? 1,
paused: true,
balance: levelCfg.startBudget ?? 5000,
score: 0,
activeContracts: [],
completedContracts: [],
vehicles: [],
activeRoutes: [],
activeEvents: [],
pendingNotifications: [],
pendingAnalytics: [],
usedHints: [],
analytics: {
totalKm: 0,
emptyKm: 0,
lateDeliveries: 0,
hintUsages: 0,
misclicks: 0,
},
worldState: {},
version: '0.1.0',
_pendingEvents: [],
_config: levelCfg,
_stateChangeListeners: [], // nicht-serialisiert (siehe serialize())
};
}
/* ============================================================
LIFECYCLE — Phase 1 (State-Maschinen-Übergänge)
============================================================ */
/**
* Setzt einen neuen State und ruft alle registrierten Listener
* (z.B. Autosave-Hooks gemäß Plattform-Konvention 7b).
* @private
*/
function _setState(game, newState, reason) {
if (game.state === newState) return;
const oldState = game.state;
game.state = newState;
const evt = { from: oldState, to: newState, reason: reason || null, at: game.simulationTime };
(game._stateChangeListeners || []).forEach(cb => {
try { cb(evt, game); } catch (e) { /* Listener-Fehler isolieren */ }
});
}
/**
* Registriert einen State-Change-Listener (z.B. für Autosave).
* Listener-Signatur: `({from, to, reason, at}, game) => void`.
* @returns {Function} unregister-Funktion
*/
function onStateChange(game, callback) {
if (!game._stateChangeListeners) game._stateChangeListeners = [];
game._stateChangeListeners.push(callback);
return function unregister() {
const idx = game._stateChangeListeners.indexOf(callback);
if (idx !== -1) game._stateChangeListeners.splice(idx, 1);
};
}
/**
* INIT → LOADING_CONTENT → READY (synchron, weil Seeds bereits via
* window.LOGISTIK_SEEDS injiziert sind).
*/
function loadContent(game, seeds) {
if (game.state !== STATE.INIT) {
throw new Error('loadContent erwartet state=INIT, ist aber ' + game.state);
}
_setState(game, STATE.LOADING_CONTENT, 'loadContent');
const ws = window.LOGISTIK_SEEDS || {};
game.worldState.locations = (seeds && seeds.locations) || ws.locations || [];
game.worldState.vehicleTypes = (seeds && seeds.vehicleTypes) || ws.vehicleTypes || [];
game.worldState.cargoTypes = (seeds && seeds.cargoTypes) || ws.cargoTypes || [];
game.worldState.railnet = (seeds && seeds.railnet) || ws.railnet || { nodes: [], edges: [] };
// Phase 3: Hand-gepflegte Polylines (lg-routes-osm.json)
const rOsm = (seeds && seeds.routesOsm) || ws.routesOsm || {};
game.worldState.routesOsm = Array.isArray(rOsm) ? rOsm : (rOsm.routes || []);
_setState(game, STATE.READY, 'content_loaded');
return game;
}
/** READY → PLANNING (Bearbeiter:in disponiert, Zeit steht still). */
function startPlanning(game) {
if (game.state !== STATE.READY) {
throw new Error('startPlanning erwartet state=READY, ist aber ' + game.state);
}
_setState(game, STATE.PLANNING, 'ui_ready');
return game;
}
/** PLANNING/PAUSED_BY_* → RUNNING. Setzt paused=false. */
function startSimulation(game) {
const allowed = [STATE.PLANNING, STATE.PAUSED_BY_EVENT, STATE.PAUSED_BY_ARRIVAL, STATE.READY];
if (allowed.indexOf(game.state) === -1) {
throw new Error('startSimulation erwartet state in {' + allowed.join(',') + '}, ist aber ' + game.state);
}
game.paused = false;
_setState(game, STATE.RUNNING, 'sim_started');
return game;
}
/** RUNNING → paused (state bleibt RUNNING, nur Flag). User-Pause. */
function pause(game) {
game.paused = true;
return game;
}
/** Aufhebung der User-Pause. */
function resume(game) {
game.paused = false;
return game;
}
/**
* Setzt die Zeitskala. Pflichtwerte: 1 / 2 / 4 / 8 (PH 14.6, 65.4).
* `0` ist erlaubt als Software-Pause-Äquivalent (selten gebraucht).
*/
function setTimeScale(game, scale) {
if (typeof scale !== 'number' || scale < 0) {
throw new Error('setTimeScale: ungültige scale ' + scale);
}
game.timeScale = scale;
return game;
}
/* ============================================================
TICK — Phase 1 (Sim-Zeit läuft, noch keine Vehicle/Contract-Mutation)
============================================================ */
/**
* Konvertiert Echtzeit-Delta in Sim-Minuten.
* Verhältnis: 1× = 1 Sim-Minute pro Echt-Sekunde (PH 65.4).
* @param {number} deltaMs
* @param {number} timeScale
* @returns {number} Sim-Minuten (kann float sein)
*/
function convertRealTimeToGameMinutes(deltaMs, timeScale) {
return (deltaMs / 1000) * (timeScale || 1);
}
/**
* Sim-Stunden seit Start.
* @param {Object} game
* @returns {number}
*/
function getSimHoursElapsed(game) {
const start = new Date(game.simulationStartTime).getTime();
const now = new Date(game.simulationTime).getTime();
return (now - start) / (1000 * 60 * 60);
}
/**
* Tick-Pipeline (PH 45.3) — Phase-1-Ausbau:
* 1. SimulationTime erhöhen (immer)
* 2. updateEvents — Phase 5 (Stub)
* 3. updateVehicles — Phase 2 (Stub)
* 4. updateLoading — Phase 3 (Stub)
* 5. updateContracts — Phase 2 (Stub)
* 6. applyEconomy — Phase 3 (Stub)
* 7. generateNotifications — Phase 1 (kein Output)
* 8. evaluateConditions — Phase 1: nur Zeit-Limit prüfen
*
* Tut nichts wenn paused oder state != RUNNING.
*
* @param {Object} game
* @param {number} deltaMs Echtzeit-Delta seit letztem Tick
* @returns {Object} game
*/
function tick(game, deltaMs) {
if (game.state !== STATE.RUNNING || game.paused) return game;
const simMinutes = convertRealTimeToGameMinutes(deltaMs, game.timeScale);
if (simMinutes <= 0) return game; // timeScale=0 → Pause-Äquivalent
const dt = new Date(game.simulationTime);
dt.setTime(dt.getTime() + simMinutes * 60 * 1000);
game.simulationTime = dt.toISOString();
// Phase 2: Vehicle-Bewegung (Routen-Interpolation, Delivery, Erlös)
_updateVehicles(game, simMinutes);
// Phase 3: Laufende Kosten (Standkosten IDLE/h, Miete/Tag pro Vehicle)
_applyRunningCosts(game, simMinutes);
// Phase 4: Hafen-Schiffsankünfte (deterministisch, periodisch)
_maybeGenerateShipArrival(game);
// Phase 5: Events generieren + auslaufen lassen
_maybeGenerateEvents(game, simMinutes);
_expireEvents(game);
// Auswertung: Zeit-Limit (Fail) und Min-Ziel (Success bei Limit-Erreich)
const limit = game._config && game._config.timeLimitHours;
if (typeof limit === 'number' && getSimHoursElapsed(game) >= limit) {
const cfg = game._config;
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
_setState(game,
profit >= minTarget ? STATE.LEVEL_SUCCESS : STATE.LEVEL_FAILED,
profit >= minTarget ? 'time_limit_with_target_reached' : 'time_limit_reached'
);
} else if ((game.activeContracts || []).length === 0
&& (game.completedContracts || []).length > 0) {
// Alle Aufträge abgeschlossen → Erfolgsprüfung sofort
const cfg = game._config || {};
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
if (profit >= minTarget) {
_setState(game, STATE.LEVEL_SUCCESS, 'all_contracts_done_target_reached');
}
}
return game;
}
/* ============================================================
SERIALIZE / DESERIALIZE (Autosave-Vorbereitung Plattform 7b)
============================================================ */
/**
* Liefert eine JSON-serialisierbare Kopie ohne nicht-serialisierbare
* Felder (Listener, Polylines mit Funktionen etc.).
*/
function serialize(game) {
const clone = JSON.parse(JSON.stringify(game, (key, value) => {
if (key === '_stateChangeListeners') return undefined;
return value;
}));
return clone;
}
/** Reaktiviert Listener-Array nach Deserialisierung. */
function deserialize(json) {
const game = (typeof json === 'string') ? JSON.parse(json) : json;
if (!game._stateChangeListeners) game._stateChangeListeners = [];
return game;
}
/* ============================================================
PLACEHOLDER / STUB FUNKTIONEN
(Logistik-Instanz implementiert diese in Phase 17)
============================================================ */
/* ============================================================
PHASE 2 — calculateRoute, assignContract, Vehicle-Bewegung
============================================================ */
/**
* Berechnet eine Route. Phase-3-Strategie:
* 1. Lookup in `worldState.routesOsm`. Wenn Polyline (auch invers)
* gefunden → nutze die Hand-gepflegte Polyline.
* 2. Fallback: Luftlinie × 1.3 (Phase-2-Modus).
*
* @param {Object} game
* @param {string} originId Location-ID
* @param {string} targetId Location-ID
* @param {'TRUCK_SMALL'|'TRUCK_LARGE'|'TRAIN'} mode
* @returns {Object} Route mit Geometrie, Distanz, Dauer
*/
function calculateRoute(game, originId, targetId, mode) {
const veh = VEHICLE_DEFAULTS[mode];
if (!veh) throw new Error('Unbekannter Fahrzeugmodus: ' + mode);
const locs = (game.worldState && game.worldState.locations) || [];
const o = locs.find(l => l.id === originId);
const t = locs.find(l => l.id === targetId);
if (!o) throw new Error('Origin nicht gefunden: ' + originId);
if (!t) throw new Error('Target nicht gefunden: ' + targetId);
let geometry = null;
let provider = 'airline_x1.3';
let distanceKm;
let segments = null;
// Phase 4: TRAIN nutzt railnet-Dijkstra (wenn beide Locations Bahnknoten sind)
if (mode === 'TRAIN') {
const rail = (game.worldState && game.worldState.railnet) || { nodes: [], edges: [] };
const node = (locId) => rail.nodes.find(n => n.locationId === locId);
if (node(originId) && node(targetId)) {
const dij = railShortestPath(
{ nodes: rail.nodes, edges: rail.edges },
node(originId).id, node(targetId).id
);
if (dij && dij.path) {
geometry = dij.path.map(nodeId => {
const n = rail.nodes.find(x => x.id === nodeId);
const loc = locs.find(l => l.id === n.locationId);
return [loc.lat, loc.lon];
});
distanceKm = dij.distanceKm;
provider = 'rail_dijkstra';
// Segments pro Bahnkante
segments = [];
for (let i = 0; i < dij.path.length - 1; i++) {
const a = rail.nodes.find(n => n.id === dij.path[i]);
const b = rail.nodes.find(n => n.id === dij.path[i + 1]);
const edge = rail.edges.find(e =>
(e.from === a.id && e.to === b.id) ||
(e.from === b.id && e.to === a.id));
const la = locs.find(l => l.id === a.locationId);
const lb = locs.find(l => l.id === b.locationId);
segments.push({
sequenceNo: i, segmentType: 'RAIL',
startLat: la.lat, startLon: la.lon,
endLat: lb.lat, endLon: lb.lon,
distanceKm: edge ? edge.distanceKm : 0,
durationMinutes: edge && edge.durationMinutes ? edge.durationMinutes
: ((edge ? edge.distanceKm : 0) / veh.speedKmh * 60),
});
}
}
}
}
// Phase 3: Polyline-Lookup für Straße (oder TRAIN-Fallback wenn kein Bahnknoten)
if (!geometry) {
const osmRoutes = (game.worldState && game.worldState.routesOsm) || [];
for (const r of osmRoutes) {
if (r.originLocationId === originId && r.targetLocationId === targetId) {
geometry = r.polyline.slice();
provider = 'osm_polyline';
break;
}
if (r.originLocationId === targetId && r.targetLocationId === originId) {
geometry = r.polyline.slice().reverse();
provider = 'osm_polyline_reversed';
break;
}
}
if (geometry) {
distanceKm = _polylineLength(geometry);
} else {
// Fallback Luftlinie × 1.3 (Phase 2)
const airlineKm = _segmentLength([o.lat, o.lon], [t.lat, t.lon]);
distanceKm = airlineKm * 1.3;
geometry = [[o.lat, o.lon], [t.lat, t.lon]];
}
}
const durationMinutes = (distanceKm / veh.speedKmh) * 60;
return {
id: 'route_' + originId + '_' + targetId + '_' + mode + '_' + Date.now(),
originLocationId: originId,
targetLocationId: targetId,
mode,
provider,
totalDistanceKm: distanceKm,
totalDurationMinutes: durationMinutes,
geometry,
segments: segments || [{
sequenceNo: 0,
segmentType: mode === 'TRAIN' ? 'RAIL' : 'ROAD',
startLat: o.lat, startLon: o.lon,
endLat: t.lat, endLon: t.lon,
distanceKm,
durationMinutes,
}],
};
}
/**
* Weist einen offenen Auftrag einem freien Fahrzeug zu.
* Berechnet Route, fügt sie zu activeRoutes, setzt States.
*
* Phase-2-Annahme: Fahrzeug startet **direkt am Auftrags-Origin**
* (kein Pickup-Drive aus Vorposition). Vereinfacht den Loop massiv;
* realistisches Pickup-Drive kommt mit Phase 3.
*/
function assignContract(game, contractId, vehicleId, legNum) {
const c = (game.activeContracts || []).find(x => x.id === contractId);
if (!c) throw new Error('Auftrag nicht gefunden: ' + contractId);
const v = (game.vehicles || []).find(x => x.id === vehicleId);
if (!v) throw new Error('Fahrzeug nicht gefunden: ' + vehicleId);
if (v.state !== VEHICLE_STATE.IDLE) {
throw new Error('Fahrzeug nicht IDLE, sondern ' + v.state);
}
// Phase 4b: Intermodal — leg-basierte Zuweisung
let originLocationId, targetLocationId, leg;
if (c.intermodal) {
const legIdx = (typeof legNum === 'number') ? legNum : c.currentLegNum;
leg = c.legs[legIdx];
if (!leg) throw new Error('Leg ' + legIdx + ' existiert nicht');
if (leg.state !== CONTRACT_STATE.OPEN) {
throw new Error('Leg ' + legIdx + ' nicht OPEN, sondern ' + leg.state);
}
// Vehicle-Mode muss zum Leg passen (bei TRAIN-Leg muss ein Zug fahren)
if (leg.mode === 'TRAIN' && v.mode !== 'TRAIN') {
throw new Error('Leg verlangt TRAIN, Vehicle ist ' + v.mode);
}
if (leg.mode !== 'TRAIN' && v.mode === 'TRAIN') {
throw new Error('Leg verlangt LKW, Vehicle ist Zug');
}
originLocationId = leg.originLocationId;
targetLocationId = leg.targetLocationId;
} else {
if (c.state !== CONTRACT_STATE.OPEN) {
throw new Error('Auftrag nicht OPEN, sondern ' + c.state);
}
originLocationId = c.originLocationId;
targetLocationId = c.targetLocationId;
}
const veh = VEHICLE_DEFAULTS[v.mode];
const containers = c.quantityContainers || 1;
const loadMin = (veh.loadMinutes || 5) * containers;
const unloadMin = (veh.unloadMinutes || 5) * containers;
if (!game.activeRoutes) game.activeRoutes = [];
let pickupRoute = null;
if (v.currentLocationId !== originLocationId) {
pickupRoute = calculateRoute(game, v.currentLocationId, originLocationId, v.mode);
pickupRoute.vehicleId = v.id;
pickupRoute.vehicleColor = v.color || null;
pickupRoute.isPickup = true;
game.activeRoutes.push(pickupRoute);
}
const deliveryRoute = calculateRoute(game, originLocationId, targetLocationId, v.mode);
deliveryRoute.vehicleId = v.id;
deliveryRoute.vehicleColor = v.color || null;
deliveryRoute.isPickup = false;
game.activeRoutes.push(deliveryRoute);
v.assignedContractId = contractId;
v.assignedLegNum = leg ? leg.legNum : null;
v.pickupRouteId = pickupRoute ? pickupRoute.id : null;
v.deliveryRouteId = deliveryRoute.id;
v.loadMinutes = loadMin;
v.unloadMinutes = unloadMin;
v.phaseRemainingMinutes = 0;
v.segmentProgress = 0;
if (pickupRoute) {
v.tripPhase = 'pickup_drive';
v.activeRouteId = pickupRoute.id;
v.state = VEHICLE_STATE.MOVING;
if (leg) leg.state = CONTRACT_STATE.PICKUP_PENDING;
else c.state = CONTRACT_STATE.PICKUP_PENDING;
} else {
v.tripPhase = 'loading';
v.activeRouteId = null;
v.state = VEHICLE_STATE.LOADING;
// Phase 6c: Minigame-Modifier wirkt auf nächste LOADING (dann reset)
const mgFactor = game._loadingTimeModifier || 1;
v.phaseRemainingMinutes = loadMin * mgFactor;
if (mgFactor !== 1) game._loadingTimeModifier = 1;
if (leg) leg.state = CONTRACT_STATE.LOADING;
else c.state = CONTRACT_STATE.LOADING;
}
if (leg) {
leg.assignedVehicleId = vehicleId;
leg.routeId = deliveryRoute.id;
leg.departTime = game.simulationTime;
} else {
c.assignedVehicleId = vehicleId;
c.routeId = deliveryRoute.id;
c.departTime = game.simulationTime;
}
return { contract: c, leg, vehicle: v, route: deliveryRoute, pickupRoute };
}
/**
* Phase-3-Trip-Pipeline: pickup_drive → loading → delivery_drive → unloading.
* Pro Vehicle wird der aktuelle TripPhase getickt; bei Phasen-Ende wird
* der nächste Phase initialisiert. Bei UNLOADING-Ende: Contract abschließen.
* @private
*/
function _updateVehicles(game, simMinutes) {
(game.vehicles || []).forEach(v => {
// Pro Vehicle: in einer Schleife alle Phasen abarbeiten, falls die
// Phase mitten im Tick endet (sonst Drift bei großen Ticks).
let remaining = simMinutes;
let safety = 8; // max 8 Phasen pro Tick (4 Phasen × Sicherheits-Reserve)
while (remaining > 0 && v.tripPhase && safety-- > 0) {
const consumed = _stepVehiclePhase(game, v, remaining);
if (consumed <= 0) break; // Phase noch aktiv, kein Übertrag
remaining -= consumed;
}
});
}
/**
* Verarbeitet EINEN Phasen-Schritt; gibt zurück, wie viele Sim-Minuten
* verbraucht wurden. Wenn Phase fertig vor simMinutes-Ende: konsumiert
* exakt die nötige Restzeit der Phase und wechselt; Caller läuft mit
* Übertrag in der neuen Phase weiter.
* @private
* @returns {number} verbrauchte Minuten oder simMinutes wenn Phase weiterläuft
*/
function _stepVehiclePhase(game, v, simMinutes) {
const routesById = {};
(game.activeRoutes || []).forEach(r => { routesById[r.id] = r; });
/* -------- Phase 8: Tour-Modus (Multi-Contract) -------- */
if (v.tripPhase === 'tour_driving') {
const stop = v.tourStops[v.currentStopIndex];
const route = routesById[v.activeRouteId];
if (!route || !stop) return simMinutes;
const speedMul = _vehicleSpeedMultiplier(game);
const totalRaw = route.totalDurationMinutes || 1;
const total = totalRaw / Math.max(0.05, speedMul);
const remainingProgress = 1 - (v.segmentProgress || 0);
const minutesToEnd = remainingProgress * total;
const consume = Math.min(simMinutes, minutesToEnd);
const next = (v.segmentProgress || 0) + (consume / total);
const [lat, lon] = interpolateAlongPolyline(route.geometry, Math.min(next, 1));
v.segmentProgress = next;
v.lat = lat;
v.lon = lon;
if (next >= 0.99999) {
v.currentLocationId = stop.locationId;
if (!game.analytics) game.analytics = {};
game.analytics.totalKm = (game.analytics.totalKm || 0) + route.totalDistanceKm;
if (route.isPickup) {
game.analytics.emptyKm = (game.analytics.emptyKm || 0) + route.totalDistanceKm;
}
// Fahrkosten sofort buchen
const fareCost = travelCost(route.totalDistanceKm, route.totalDurationMinutes / 60, v.mode);
game.balance -= fareCost;
game.analytics.totalFareCostPaid = (game.analytics.totalFareCostPaid || 0) + fareCost;
// Route entfernen
game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== route.id);
// Handling beginnen
v.tripPhase = 'tour_handling';
v.activeRouteId = null;
v.segmentProgress = 0;
v.state = stop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING;
let handlingMinutes = stop.handlingMinutes || 5;
if (stop.type === 'PICKUP') {
const mgFactor = game._loadingTimeModifier || 1;
handlingMinutes *= mgFactor;
if (mgFactor !== 1) game._loadingTimeModifier = 1;
}
v.phaseRemainingMinutes = handlingMinutes;
stop.contractIds.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (c) c.state = stop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING;
});
}
return consume;
}
if (v.tripPhase === 'tour_handling') {
const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0);
v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume;
if (v.phaseRemainingMinutes <= 0) {
const stop = v.tourStops[v.currentStopIndex];
// Contract-States nach Handling
stop.contractIds.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c) return;
if (stop.type === 'PICKUP') {
c.state = CONTRACT_STATE.IN_TRANSIT;
} else {
// Dropoff → Contract komplett abschließen
_completeContractMulti(game, c, v);
}
});
v.currentStopIndex++;
if (v.currentStopIndex >= v.tourStops.length) {
_endTour(game, v);
} else {
const nextStop = v.tourStops[v.currentStopIndex];
if (nextStop.inRouteId) {
v.tripPhase = 'tour_driving';
v.activeRouteId = nextStop.inRouteId;
v.segmentProgress = 0;
v.state = VEHICLE_STATE.MOVING;
} else {
v.tripPhase = 'tour_handling';
v.activeRouteId = null;
v.state = nextStop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING;
let m = nextStop.handlingMinutes || 5;
if (nextStop.type === 'PICKUP') {
const mgFactor = game._loadingTimeModifier || 1;
m *= mgFactor;
if (mgFactor !== 1) game._loadingTimeModifier = 1;
}
v.phaseRemainingMinutes = m;
nextStop.contractIds.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (c) c.state = nextStop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING;
});
}
}
}
return consume;
}
/* -------- Legacy-Pfade (Single-Contract + Intermodal) -------- */
if (v.tripPhase === 'pickup_drive' || v.tripPhase === 'delivery_drive') {
const route = routesById[v.activeRouteId];
if (!route) return simMinutes;
// Phase 5: Event-Speed-Multiplikator (Unfall halbiert, Schnee × 0.7 etc.)
const speedMul = _vehicleSpeedMultiplier(game);
const totalRaw = route.totalDurationMinutes || 1;
const total = totalRaw / Math.max(0.05, speedMul); // bei speedMul<1 dauert länger
const remainingProgress = 1 - (v.segmentProgress || 0);
const minutesToEnd = remainingProgress * total;
const consume = Math.min(simMinutes, minutesToEnd);
const next = (v.segmentProgress || 0) + (consume / total);
const [lat, lon] = interpolateAlongPolyline(route.geometry, Math.min(next, 1));
v.segmentProgress = next;
v.lat = lat;
v.lon = lon;
if (next >= 0.99999) {
v.currentLocationId = route.targetLocationId;
if (!game.analytics) game.analytics = {};
game.analytics.totalKm = (game.analytics.totalKm || 0) + route.totalDistanceKm;
if (v.tripPhase === 'pickup_drive') {
game.analytics.emptyKm = (game.analytics.emptyKm || 0) + route.totalDistanceKm;
v.tripPhase = 'loading';
v.activeRouteId = null;
v.segmentProgress = 0;
v.state = VEHICLE_STATE.LOADING;
v.phaseRemainingMinutes = v.loadMinutes || 5;
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
if (c) { c.state = CONTRACT_STATE.LOADING; c.pickupKm = route.totalDistanceKm; }
game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== route.id);
} else {
v.tripPhase = 'unloading';
v.activeRouteId = null;
v.segmentProgress = 0;
v.state = VEHICLE_STATE.UNLOADING;
v.phaseRemainingMinutes = v.unloadMinutes || 5;
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
if (c) c.state = CONTRACT_STATE.AT_TRANSFER;
}
}
return consume;
}
if (v.tripPhase === 'loading') {
const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0);
v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume;
if (v.phaseRemainingMinutes <= 0) {
v.tripPhase = 'delivery_drive';
v.activeRouteId = v.deliveryRouteId;
v.segmentProgress = 0;
v.state = VEHICLE_STATE.MOVING;
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
if (c) c.state = CONTRACT_STATE.IN_TRANSIT;
}
return consume;
}
if (v.tripPhase === 'unloading') {
const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0);
v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume;
if (v.phaseRemainingMinutes <= 0) {
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
const deliveryRoute = routesById[v.deliveryRouteId];
game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== v.deliveryRouteId);
if (c && deliveryRoute) {
if (c.intermodal) {
_completeLeg(game, c, v.assignedLegNum, deliveryRoute, v);
} else {
_completeContract(game, c, deliveryRoute, v);
}
}
v.tripPhase = null;
v.assignedContractId = null;
v.assignedLegNum = null;
v.pickupRouteId = null;
v.deliveryRouteId = null;
v.activeRouteId = null;
v.segmentProgress = 0;
v.phaseRemainingMinutes = 0;
v.state = VEHICLE_STATE.IDLE;
}
return consume;
}
return simMinutes;
}
/**
* Phase 4b: Vollendet ein einzelnes Leg eines intermodalen Contracts.
* Bucht Fahrkosten sofort. Wenn letztes Leg → ganzer Contract DELIVERED
* mit Reward + Bonus + Strafe.
* @private
*/
function _completeLeg(game, contract, legNum, route, vehicle) {
const leg = contract.legs[legNum];
if (!leg) return;
leg.state = CONTRACT_STATE.DELIVERED;
leg.arrivalTime = game.simulationTime;
contract.currentLocationId = leg.targetLocationId;
// Fahrkosten dieses Legs sofort buchen (Reward kommt am Schluss)
const fareCost = travelCost(route.totalDistanceKm, route.totalDurationMinutes / 60, route.mode);
let pickupFareCost = 0;
if (vehicle && vehicle.pickupRouteId && contract.pickupKm) {
const veh = VEHICLE_DEFAULTS[route.mode];
const pickupHours = contract.pickupKm / veh.speedKmh;
pickupFareCost = contract.pickupKm * veh.costPerKm + pickupHours * veh.costPerHour;
contract.pickupKm = 0; // reset für nächsten Leg
}
game.balance -= (fareCost + pickupFareCost);
contract.legCostsPaid = (contract.legCostsPaid || 0) + fareCost + pickupFareCost;
if (legNum + 1 < contract.legs.length) {
// Nächster Leg → OPEN
contract.currentLegNum = legNum + 1;
contract.legs[legNum + 1].state = CONTRACT_STATE.OPEN;
// Notification für Übergabe
if (!game.pendingNotifications) game.pendingNotifications = [];
const locs = game.worldState.locations || [];
const locName = (id) => (locs.find(l => l.id === id) || {}).name || id;
game.pendingNotifications.push({
id: 'leg_' + contract.id + '_' + legNum,
type: 'LEG_TRANSFERRED',
contractId: contract.id,
messageShort: 'Auftrag „' + (contract.code || contract.id) + '": Container in '
+ locName(leg.targetLocationId) + ' angekommen, nächster Leg offen.',
simTime: game.simulationTime,
});
} else {
// Letztes Leg → ganzer Contract abschließen mit Bonus/Strafe/Reward
const cfg = game._config || {};
const arrivalIso = game.simulationTime;
const dueMs = new Date(contract.dueTime || arrivalIso).getTime();
const arrMs = new Date(arrivalIso).getTime();
const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60));
const baseReward = contract.rewardBase || ECONOMY.standardContractBase;
const penaltyRate = (typeof cfg.latePenaltyOverride === 'number')
? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour;
const penalty = Math.max(0, baseReward * penaltyRate * lateHours);
const onTime = lateHours === 0;
// Intermodal-Bonus: noEmpty true wenn überall am Übergabe-Ort gestartet
// (vereinfachte Annahme: Pickup-Drive war bei _stepVehiclePhase pickupRouteId markiert)
const noEmpty = true; // Intermodal: separate Bonus-Logik möglich, hier vereinfacht
const bonus = calculateBonus(baseReward, { onTime, noEmpty });
const net = baseReward + bonus - penalty - (contract.legCostsPaid || 0);
// Reward am Ende buchen (Fahrkosten waren schon pro Leg abgezogen)
game.balance += baseReward + bonus - penalty;
game.score += Math.max(0, Math.round(net / 100));
contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED;
contract.finalBalance = net;
contract.lateHours = lateHours;
if (!game.completedContracts) game.completedContracts = [];
game.completedContracts.push(contract);
game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id);
if (!game.analytics) game.analytics = {};
if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1;
if (!game.pendingNotifications) game.pendingNotifications = [];
game.pendingNotifications.push({
id: 'notif_intermodal_' + contract.id,
type: 'CONTRACT_DELIVERED',
contractId: contract.id,
messageShort: 'Intermodaler Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.',
simTime: arrivalIso,
net,
});
}
}
/**
* Schließt einen Vertrag ab: Erlös buchen, Strafen prüfen, Bonus prüfen,
* Vertrag in completedContracts verschieben.
* Phase 3: vehicle-Parameter ermöglicht Leerfahrten-Erkennung
* (pickupRouteId vorhanden = Leerfahrt = noEmpty=false).
* @private
*/
function _completeContract(game, contract, route, vehicle) {
const cfg = game._config || {};
const arrivalIso = game.simulationTime;
contract.unloadEndTime = arrivalIso;
// Verspätung berechnen
const dueMs = new Date(contract.dueTime || arrivalIso).getTime();
const arrMs = new Date(arrivalIso).getTime();
const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60));
// Wirtschaft
const baseReward = contract.rewardBase || ECONOMY.standardContractBase;
// Fahrkosten = pickup-km + delivery-km (Leerfahrt + Lieferfahrt zählen beide)
let totalKm = route.totalDistanceKm;
let totalDur = route.totalDurationMinutes;
let hadPickupDrive = false;
if (vehicle && vehicle.pickupRouteId) {
// Pickup-Route ist schon aus activeRoutes entfernt — wir berechnen
// approx aus contract+vehicle currentLocation-Diff. Pragmatischer:
// Pickup-Distanz wurde in analytics.emptyKm gespeichert; nutzen wir die.
hadPickupDrive = true;
}
// Wenn pickup vorhanden: Fahrkosten verdoppelt? Nein, nur die echten km
// werden gerechnet. Da wir hier nur die Delivery-Route haben, holen wir
// pickup-km approximativ aus analytics-Diff.
const fareCost = travelCost(totalKm, totalDur / 60, route.mode);
// Pickup-Fahrkosten separat (ohne Erlös) — vereinfacht: identische €/km/h
let pickupFareCost = 0;
if (hadPickupDrive && contract.pickupKm) {
const veh = VEHICLE_DEFAULTS[route.mode];
const pickupHours = contract.pickupKm / veh.speedKmh;
pickupFareCost = contract.pickupKm * veh.costPerKm + pickupHours * veh.costPerHour;
}
const penaltyRate = (typeof cfg.latePenaltyOverride === 'number')
? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour;
const penalty = Math.max(0, baseReward * penaltyRate * lateHours);
const onTime = lateHours === 0;
const noEmpty = !hadPickupDrive; // Phase 3: Leerfahrt erkannt
const bonus = calculateBonus(baseReward, { onTime, noEmpty });
const net = baseReward + bonus - fareCost - pickupFareCost - penalty;
game.balance += net;
game.score += Math.max(0, Math.round(net / 100));
contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED;
contract.finalBalance = net;
contract.lateHours = lateHours;
// Tracking: Auftrags-Dauer
const issueMs = new Date(contract.issueTime || arrivalIso).getTime();
contract.durationHours = (arrMs - issueMs) / (1000 * 60 * 60);
// In completedContracts verschieben
if (!game.completedContracts) game.completedContracts = [];
game.completedContracts.push(contract);
game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id);
// Analytics
if (!game.analytics) game.analytics = {};
if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1;
game.analytics.totalRewardEarned = (game.analytics.totalRewardEarned || 0) + baseReward + bonus;
game.analytics.totalPenaltyPaid = (game.analytics.totalPenaltyPaid || 0) + penalty;
game.analytics.totalFareCostPaid = (game.analytics.totalFareCostPaid || 0) + fareCost + pickupFareCost;
// Notification
if (!game.pendingNotifications) game.pendingNotifications = [];
game.pendingNotifications.push({
id: 'notif_' + contract.id,
type: 'CONTRACT_DELIVERED',
contractId: contract.id,
messageShort: 'Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.',
simTime: arrivalIso,
net,
});
_pushAnalyticsEntry(game, contract, {
assignedVehicle: vehicle && vehicle.typeId,
routeDistanceKm: Math.round((totalKm || 0) + (contract.pickupKm || 0)),
routeMode: route && route.mode,
});
// Phase-2-Vorgriff (für „L1 spielbar"): bei <maxActiveContracts neuen Auftrag generieren
// Phase 3 ersetzt das durch echte Auftrags-Variation (verschiedene Strecken, Eilaufträge, etc.)
_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 cost = 0;
(game.vehicles || []).forEach(v => {
cost += rentalPerDay * simDays;
if (v.state === VEHICLE_STATE.IDLE) {
cost += idleCostPerH * simHours;
}
});
// Phase 4: Container-Standkosten an Häfen
if (portStandPerH > 0) {
const locs = game.worldState.locations || [];
(game.activeContracts || []).forEach(c => {
if (c.state !== CONTRACT_STATE.OPEN) return;
const orig = locs.find(l => l.id === c.originLocationId);
if (!orig || orig.type !== 'PORT') return;
cost += portStandPerH * (c.quantityContainers || 1) * simHours;
});
}
if (cost > 0) {
game.balance -= cost;
if (!game.analytics) game.analytics = {};
game.analytics.runningCostsTotal = (game.analytics.runningCostsTotal || 0) + cost;
}
}
/**
* Phase 5: Würfelt Events deterministisch via Seed pro Sim-Stunden-Block.
* Pro EVENT_TYPE wird `prob = baseProb × eventProbabilityMultiplier`
* auf jeden Stundenblock angewendet. Treffer → Event in
* `game.activeEvents` mit zufälliger Dauer (30-120 min).
* @private
*/
function _maybeGenerateEvents(game, simMinutes) {
const cfg = game._config || {};
const probMul = (typeof cfg.eventProbabilityMultiplier === 'number')
? cfg.eventProbabilityMultiplier : 1;
if (probMul <= 0) return;
if (!game._eventsRngCalls) game._eventsRngCalls = 0;
if (!game.activeEvents) game.activeEvents = [];
// Stundenblock-Tracker — wir würfeln einmal pro vollendete Sim-Stunde
const lastH = Math.floor(game._lastEventCheckSimHours || 0);
const nowH = Math.floor(getSimHoursElapsed(game));
if (nowH <= lastH) return;
game._lastEventCheckSimHours = nowH;
const rng = _makeRng((game.seed || 1) ^ 0xE7E7);
for (let i = 0; i < game._eventsRngCalls; i++) rng();
const checks = nowH - lastH; // wie viele Stunden zu würfeln
const types = Object.keys(EVENT_RULES);
for (let h = 0; h < checks; h++) {
types.forEach(type => {
const rule = EVENT_RULES[type];
const p = rule.probabilityPerHour * probMul;
const v = rng();
game._eventsRngCalls++;
if (v < p) {
// Trigger: Event-Dauer 30-120 min
const v2 = rng(); game._eventsRngCalls++;
const durationMin = 30 + Math.floor(v2 * 90);
// Geo-Position abhängig vom Event-Typ (Phase 7-UX)
const geo = _pickEventLocation(game, type, rng);
game._eventsRngCalls += 2; // _pickEventLocation verbraucht bis zu 2 RNG-Calls
const event = {
id: 'evt_' + Date.now() + '_' + game._eventsRngCalls,
type,
severity: 'normal',
startTime: game.simulationTime,
endTime: new Date(new Date(game.simulationTime).getTime() + durationMin * 60000).toISOString(),
durationMin,
lat: geo.lat,
lon: geo.lon,
locationId: geo.locationId || null,
impact: {
speedMult: rule.speedMult || 1,
loadMult: rule.loadMult || 1,
regions: rule.regions || null,
},
messageShort: _eventMessage(type),
sourceType: 'random_seeded',
};
game.activeEvents.push(event);
if (!game.pendingNotifications) game.pendingNotifications = [];
game.pendingNotifications.push({
id: 'notif_' + event.id,
type: 'EVENT_STARTED',
eventId: event.id,
messageShort: event.messageShort,
simTime: game.simulationTime,
});
}
});
}
}
/**
* Phase 7-UX: Wählt Geo-Position für ein Event-Typ (deterministisch).
* - TRAFFIC_ACCIDENT: Mittelpunkt einer aktiven Route (falls vorhanden),
* sonst Mittel-Europa (Frankfurt-nähe)
* - SNOW: in den Alpen (Brennerpass mit ±0.8° Streuung)
* - PORT_DELAY: an einem zufälligen Hafen
* @private
*/
function _pickEventLocation(game, type, rng) {
const locs = game.worldState.locations || [];
if (type === 'TRAFFIC_ACCIDENT') {
// Mittelpunkt einer aktiven Lieferroute, sonst Frankfurt
const deliveryRoutes = (game.activeRoutes || []).filter(r => !r.isPickup && r.geometry && r.geometry.length);
if (deliveryRoutes.length) {
const idx = Math.floor(rng() * deliveryRoutes.length);
const r = deliveryRoutes[idx];
const m = r.geometry[Math.floor(r.geometry.length / 2)];
return { lat: m[0], lon: m[1] };
}
return { lat: 50.11, lon: 8.68 }; // Frankfurt a.M.
}
if (type === 'SNOW') {
// Alpen: Brennerpass-Region mit Streuung (Innsbruck ~ Bozen)
const baseLat = 47.0, baseLon = 11.5;
const r1 = rng(), r2 = rng();
return { lat: baseLat + (r1 - 0.5) * 1.0, lon: baseLon + (r2 - 0.5) * 1.6 };
}
if (type === 'PORT_DELAY') {
const ports = locs.filter(l => l.type === 'PORT');
if (ports.length) {
const idx = Math.floor(rng() * ports.length);
const p = ports[idx];
return { lat: p.lat, lon: p.lon, locationId: p.id };
}
}
// Fallback: Mittel-Europa
return { lat: 50.5, lon: 10.5 };
}
function _eventMessage(type) {
if (type === 'TRAFFIC_ACCIDENT') return '🚧 Unfall — Speed reduziert';
if (type === 'SNOW') return '❄ Schnee in den Alpen — vorsichtig fahren';
if (type === 'PORT_DELAY') return '⚓ Hafenüberlastung — längere Ladezeit';
if (type === 'STORM') return '🌪 Sturm — Speed reduziert';
if (type === 'RAIL_STRIKE') return '🚆 Bahnstreik — Bahnverkehr eingeschränkt';
return type;
}
/**
* Entfernt abgelaufene Events.
* @private
*/
function _expireEvents(game) {
if (!game.activeEvents || !game.activeEvents.length) return;
const nowMs = new Date(game.simulationTime).getTime();
game.activeEvents = game.activeEvents.filter(e => {
const endMs = new Date(e.endTime).getTime();
return endMs > nowMs;
});
}
/**
* Berechnet Speed-Mult aus aktiven Events für ein Vehicle.
* Phase 5: kombiniert alle aktiven Events multiplikativ.
* @private
*/
function _vehicleSpeedMultiplier(game) {
if (!game.activeEvents || !game.activeEvents.length) return 1;
let m = 1;
game.activeEvents.forEach(e => {
if (e.impact && typeof e.impact.speedMult === 'number') m *= e.impact.speedMult;
});
return m;
}
/**
* Phase 4: Generiert periodisch Schiffsankünfte an Häfen
* (alle 6 Sim-Stunden ein Container-Auftrag pro Hafen, deterministisch).
* @private
*/
/* ============================================================
PHASE 8 — Multi-Contract-Tour (Vehicle lädt mehrere Aufträge,
fährt Multi-Stop-Tour in Klick-Reihenfolge ab)
============================================================ */
/**
* Lädt einen OPEN-Auftrag in den Laderaum eines IDLE-Vehicles.
* Validiert Kapazität, Intermodal-Check. Contract wird auf RESERVED
* gesetzt (unsichtbar für andere).
*/
function loadContract(game, vehicleId, contractId) {
const v = (game.vehicles || []).find(x => x.id === vehicleId);
if (!v) throw new Error('Fahrzeug nicht gefunden: ' + vehicleId);
if (v.state !== VEHICLE_STATE.IDLE) {
throw new Error('Fahrzeug nicht IDLE, sondern ' + v.state);
}
const c = (game.activeContracts || []).find(x => x.id === contractId);
if (!c) throw new Error('Auftrag nicht gefunden: ' + contractId);
if (c.state !== CONTRACT_STATE.OPEN) {
throw new Error('Auftrag nicht OPEN, sondern ' + c.state);
}
if (c.intermodal) {
throw new Error('Intermodal-Aufträge haben eigenen Ablauf (legs)');
}
const loaded = v.loadedContractIds || [];
const usedContainers = loaded.reduce((sum, cid) => {
const lc = (game.activeContracts || []).find(x => x.id === cid);
return sum + (lc ? (lc.quantityContainers || 0) : 0);
}, 0);
const need = c.quantityContainers || 1;
const free = (v.capacityUnits || 1) - usedContainers;
if (need > free) {
throw new Error('Kapazität reicht nicht — ' + free + ' Plätze frei, Auftrag braucht ' + need);
}
v.loadedContractIds = loaded.concat([contractId]);
c.state = CONTRACT_STATE.RESERVED;
c.reservedByVehicleId = v.id;
return { vehicle: v, contract: c, freeSlotsAfter: free - need };
}
/** Nimmt einen geladenen Auftrag vor Fahrt-Start wieder heraus. */
function unloadContract(game, vehicleId, contractId) {
const v = (game.vehicles || []).find(x => x.id === vehicleId);
if (!v) throw new Error('Fahrzeug nicht gefunden');
if (v.state !== VEHICLE_STATE.IDLE) {
throw new Error('Entladen nur vor Fahrt-Start möglich');
}
v.loadedContractIds = (v.loadedContractIds || []).filter(id => id !== contractId);
const c = (game.activeContracts || []).find(x => x.id === contractId);
if (c) { c.state = CONTRACT_STATE.OPEN; c.reservedByVehicleId = null; }
return v;
}
/** Setzt alle Aufträge eines Vehicles zurück auf OPEN. */
function cancelLoading(game, vehicleId) {
const v = (game.vehicles || []).find(x => x.id === vehicleId);
if (!v) return;
(v.loadedContractIds || []).forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (c) { c.state = CONTRACT_STATE.OPEN; c.reservedByVehicleId = null; }
});
v.loadedContractIds = [];
}
/**
* Startet die Multi-Stop-Tour eines beladenen Vehicles.
* Reihenfolge: alle Pickups (in Lade-Reihenfolge), dann alle Dropoffs
* (in derselben Reihenfolge). Aufeinanderfolgende Stops am selben
* Ort mit demselben Typ werden konsolidiert.
*/
function startTour(game, vehicleId) {
const v = (game.vehicles || []).find(x => x.id === vehicleId);
if (!v) throw new Error('Fahrzeug nicht gefunden');
if (v.state !== VEHICLE_STATE.IDLE) throw new Error('Vehicle nicht IDLE');
const loaded = v.loadedContractIds || [];
if (!loaded.length) throw new Error('Kein Auftrag geladen — nichts zu fahren');
const veh = VEHICLE_DEFAULTS[v.mode];
const locs = game.worldState.locations || [];
// Raw-Stops aus Klick-Reihenfolge
const raw = [];
loaded.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c) return;
raw.push({ type: 'PICKUP', locationId: c.originLocationId, contractIds: [cid] });
});
loaded.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c) return;
raw.push({ type: 'DROPOFF', locationId: c.targetLocationId, contractIds: [cid] });
});
// Konsolidieren: aufeinanderfolgende Stops desselben Typs+Orts zusammenlegen
const stops = [];
raw.forEach(s => {
const last = stops[stops.length - 1];
if (last && last.type === s.type && last.locationId === s.locationId) {
last.contractIds = last.contractIds.concat(s.contractIds);
} else {
stops.push({ type: s.type, locationId: s.locationId, contractIds: s.contractIds.slice() });
}
});
// Routen berechnen + Handling-Minuten + Stop-Koordinaten
if (!game.activeRoutes) game.activeRoutes = [];
let prevLocId = v.currentLocationId;
stops.forEach((stop, idx) => {
const loc = locs.find(l => l.id === stop.locationId);
if (loc) { stop.lat = loc.lat; stop.lon = loc.lon; }
if (stop.locationId !== prevLocId) {
const route = calculateRoute(game, prevLocId, stop.locationId, v.mode);
route.vehicleId = v.id;
route.vehicleColor = v.color || null;
// Pickup-Fahrt zählt als "Leerfahrt-Anteil" nur wenn noch nichts geladen war
const carriedBefore = _contractsCarriedBefore(game, stops, idx, v);
route.isPickup = (stop.type === 'PICKUP' && carriedBefore === 0);
stop.inRouteId = route.id;
game.activeRoutes.push(route);
} else {
stop.inRouteId = null;
}
stop.handlingMinutes = stop.contractIds.reduce((sum, cid) => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c) return sum;
const perContainer = stop.type === 'PICKUP' ? veh.loadMinutes : veh.unloadMinutes;
return sum + (perContainer || 5) * (c.quantityContainers || 1);
}, 0);
prevLocId = stop.locationId;
});
v.tourStops = stops;
v.currentStopIndex = 0;
v.assignedContractId = null; // deprecated, wird in Tour-Modus nicht verwendet
v.pickupRouteId = null;
v.deliveryRouteId = null;
// Loaded-Contracts auf PICKUP_PENDING (oder direkt IN_TRANSIT wenn schon am ersten Pickup-Ort)
loaded.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (c) {
c.assignedVehicleId = v.id;
c.departTime = game.simulationTime;
c.state = CONTRACT_STATE.PICKUP_PENDING;
}
});
// Erste Phase starten
const firstStop = stops[0];
if (firstStop.inRouteId) {
v.tripPhase = 'tour_driving';
v.activeRouteId = firstStop.inRouteId;
v.segmentProgress = 0;
v.state = VEHICLE_STATE.MOVING;
} else {
// Vehicle schon am Ort des ersten Stops (z.B. Pickup direkt am Hub)
v.tripPhase = 'tour_handling';
v.activeRouteId = null;
v.segmentProgress = 0;
v.state = firstStop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING;
const mgFactor = (firstStop.type === 'PICKUP' ? (game._loadingTimeModifier || 1) : 1);
v.phaseRemainingMinutes = firstStop.handlingMinutes * mgFactor;
if (firstStop.type === 'PICKUP' && mgFactor !== 1) game._loadingTimeModifier = 1;
firstStop.contractIds.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (c) c.state = firstStop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING;
});
}
return { vehicle: v, stops };
}
/** Zählt, wie viele Contracts zum Zeitpunkt vor Stop-Index bereits
* im Laderaum sind (Pickup-Stops vor diesem Index addieren, Dropoff subtrahieren). */
function _contractsCarriedBefore(game, stops, idx, v) {
let count = 0;
for (let i = 0; i < idx; i++) {
const s = stops[i];
s.contractIds.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c) return;
count += (s.type === 'PICKUP' ? 1 : -1) * (c.quantityContainers || 1);
});
}
return count;
}
function _maybeGenerateShipArrival(game) {
const cfg = game._config || {};
if (!cfg.portsEnabled) return;
const intervalH = 6;
const nowH = getSimHoursElapsed(game);
const lastH = game._lastShipArrivalSimHours || 0;
if (nowH - lastH < intervalH) return;
game._lastShipArrivalSimHours = nowH;
const locs = game.worldState.locations || [];
const ports = locs.filter(l => l.type === 'PORT');
if (!ports.length) return;
const counter = (game._shipArrivalCounter || 0);
game._shipArrivalCounter = counter + 1;
const port = ports[counter % ports.length];
// Inland-Ziele für Schiffs-Container: Hauptstädte (deterministisch rotierend)
const inlandPool = ['wien', 'muenchen', 'berlin', 'paris', 'warschau'];
const target = inlandPool[counter % inlandPool.length];
if (target === port.id) return;
const code = 'SHIP-' + String(counter + 1).padStart(3, '0');
const contract = _makeContract(game, code, port.id, target, 3, false);
contract.narrativeText = 'Schiff in ' + port.name + ' eingetroffen — '
+ '3 Container nach ' + (locs.find(l => l.id === target) || {}).name;
if (!game.activeContracts) game.activeContracts = [];
game.activeContracts.push(contract);
if (!game.pendingNotifications) game.pendingNotifications = [];
game.pendingNotifications.push({
id: 'ship_' + code,
type: 'SHIP_ARRIVED',
contractId: contract.id,
messageShort: '🚢 Schiff in ' + port.name + ' angekommen — neuer Auftrag.',
simTime: game.simulationTime,
});
}
/**
* Tour abschließen: Vehicle zurück auf IDLE, Tour-Felder leeren.
* @private
*/
function _endTour(game, v) {
v.tripPhase = null;
v.state = VEHICLE_STATE.IDLE;
v.tourStops = [];
v.currentStopIndex = 0;
v.loadedContractIds = [];
v.activeRouteId = null;
v.segmentProgress = 0;
v.phaseRemainingMinutes = 0;
// Reste aus activeRoutes entfernen
game.activeRoutes = (game.activeRoutes || []).filter(r => r.vehicleId !== v.id);
}
/**
* Contract-Abschluss im Multi-Tour-Modus. Fahrkosten wurden während
* der Fahrt schon gebucht — hier nur Reward + Bonus + Penalty.
* @private
*/
function _completeContractMulti(game, contract, vehicle) {
const cfg = game._config || {};
const arrivalIso = game.simulationTime;
contract.unloadEndTime = arrivalIso;
const dueMs = new Date(contract.dueTime || arrivalIso).getTime();
const arrMs = new Date(arrivalIso).getTime();
const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60));
const baseReward = contract.rewardBase || ECONOMY.standardContractBase;
const penaltyRate = (typeof cfg.latePenaltyOverride === 'number')
? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour;
const penalty = Math.max(0, baseReward * penaltyRate * lateHours);
const onTime = lateHours === 0;
// Sammelladung: pauschal kein Leerfahrt-Malus (Leerfahrt-Tracking läuft über analytics.emptyKm global)
const noEmpty = true;
const bonus = calculateBonus(baseReward, { onTime, noEmpty });
const net = baseReward + bonus - penalty;
game.balance += baseReward + bonus - penalty;
game.score += Math.max(0, Math.round(net / 100));
contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED;
contract.finalBalance = net;
contract.lateHours = lateHours;
const issueMs = new Date(contract.issueTime || arrivalIso).getTime();
contract.durationHours = (arrMs - issueMs) / (1000 * 60 * 60);
if (!game.completedContracts) game.completedContracts = [];
game.completedContracts.push(contract);
game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id);
if (!game.analytics) game.analytics = {};
if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1;
game.analytics.totalRewardEarned = (game.analytics.totalRewardEarned || 0) + baseReward + bonus;
game.analytics.totalPenaltyPaid = (game.analytics.totalPenaltyPaid || 0) + penalty;
if (!game.pendingNotifications) game.pendingNotifications = [];
game.pendingNotifications.push({
id: 'notif_' + contract.id,
type: 'CONTRACT_DELIVERED',
contractId: contract.id,
messageShort: 'Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.',
simTime: arrivalIso,
net,
});
_pushAnalyticsEntry(game, contract, {
assignedVehicle: vehicle && vehicle.typeId,
routeDistanceKm: contract.plannedRouteKm || null,
routeMode: vehicle && vehicle.typeId,
});
_maybeGenerateFollowupContract(game);
}
/**
* Schreibt einen Analytics-Eintrag in game.pendingAnalytics. Die UI
* drainiert die Queue und POSTet an /api/logistik-analytics. Die
* Engine selbst fuehrt keinen Netzwerkzugriff aus — bleibt damit in
* Node (headless-runner) und Browser gleich testbar.
* @private
*/
function _pushAnalyticsEntry(game, contract, extra) {
if (!game.pendingAnalytics) game.pendingAnalytics = [];
const levelNum = parseInt(String(game.levelId || 'level_1').replace(/^level_/, ''), 10) || 1;
const lateMinutes = Math.round(((contract.lateHours || 0) * 60));
const stateStr = (contract.state === CONTRACT_STATE.LATE) ? 'LATE'
: (contract.state === CONTRACT_STATE.FAILED) ? 'FAILED'
: (contract.state === CONTRACT_STATE.CANCELLED) ? 'CANCELLED'
: 'DELIVERED';
game.pendingAnalytics.push({
levelId: levelNum,
contractCode: contract.code || contract.id || '',
assignedVehicle: (extra && extra.assignedVehicle) || null,
state: stateStr,
finalBalance: Math.round(game.balance || 0),
lateMinutes: lateMinutes,
hintUsages: (game.analytics && game.analytics.hintUsages) || 0,
intermodal: !!contract.intermodal,
routeDistanceKm: (extra && extra.routeDistanceKm) || null,
routeMode: (extra && extra.routeMode) || null,
});
}
function _maybeGenerateFollowupContract(game) {
const cfg = game._config || {};
const max = cfg.maxActiveContracts || 1;
if ((game.activeContracts || []).length >= max) return;
const generated = (game.completedContracts || []).length + (game.activeContracts || []).length;
const code = 'C-' + String(generated + 1).padStart(3, '0');
let follow;
if (max === 1) {
// L1: pendele Wien ↔ Salzburg (vermeidet Leerfahrten — didaktisch
// illustriert Pendelprinzip, hält naive im Akzeptanzkorridor)
const lastTarget = ((game.completedContracts || []).slice(-1)[0] || {}).targetLocationId;
const nextOrigin = lastTarget || 'wien';
const nextTarget = nextOrigin === 'wien' ? 'salzburg' : 'wien';
follow = _makeContract(game, code, nextOrigin, nextTarget, 1, false);
} else {
// L2/L3: variiert aus CONTRACT_TEMPLATES
const tpl = _nextContractTemplate(game);
follow = _makeContract(game, code, tpl.origin, tpl.target, tpl.containers, tpl.express);
follow.cargoTypeId = tpl.cargoTypeId;
}
game.activeContracts.push(follow);
}
/**
* Erzeugt initiale Fahrzeuge je nach Level-Config.
* Level 1: 1× TRUCK_SMALL in Wien (Tutorial).
* Level 2: 1× TRUCK_SMALL Wien, 1× TRUCK_LARGE München, 1× TRAIN Hamburg.
* Level 3: 2× SMALL + 2× LARGE + 1× TRAIN, verteilt auf 5 Hubs.
*/
// Distinkte Farben für Vehicles (bis 8 Vehicles — für L3 reicht)
const VEHICLE_COLORS = Object.freeze([
'#1f4b37', // moosgrün
'#b45900', // terrakotta
'#1e4f7a', // stahlblau
'#6d2d7a', // traube
'#7a4a1e', // umber
'#2d6d3d', // mittelgrün
'#3a5fad', // königsblau
'#9a3a3a', // ziegelrot
]);
function seedInitialVehicles(game) {
const cfg = game._config || {};
const types = cfg.availableVehicleTypes || ['TRUCK_SMALL'];
const count = cfg.vehicleCount || 1;
const locs = game.worldState.locations || [];
if (!locs.length) { game.vehicles = []; return; }
// Hub-Verteilung pro Level (nach maxActiveContracts als L-Indikator)
const hubsPerLevel = {
1: ['wien'],
2: ['wien', 'muenchen', 'hamburg'],
3: ['wien', 'muenchen', 'hamburg', 'rotterdam', 'paris'],
};
// Vehicle-Typ-Sequenz pro Level
const seqPerLevel = {
1: ['TRUCK_SMALL'],
2: ['TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'],
3: ['TRUCK_SMALL', 'TRUCK_LARGE', 'TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'],
};
const lvlKey = (cfg.maxActiveContracts === 1) ? 1 : (cfg.maxActiveContracts <= 3 ? 2 : 3);
const hubs = hubsPerLevel[lvlKey];
const seq = seqPerLevel[lvlKey];
const out = [];
for (let i = 0; i < count; i++) {
const mode = (seq[i] && types.includes(seq[i])) ? seq[i] : types[i % types.length];
const hubId = hubs[i % hubs.length];
const home = locs.find(l => l.id === hubId) || locs.find(l => l.id === 'wien') || locs[0];
const t = VEHICLE_DEFAULTS[mode];
out.push({
id: 'veh_' + (i + 1),
mode,
displayName: _vehicleLabel(mode) + ' #' + (i + 1),
color: VEHICLE_COLORS[i % VEHICLE_COLORS.length],
state: VEHICLE_STATE.IDLE,
currentLocationId: home.id,
lat: home.lat,
lon: home.lon,
capacityUnits: t.capacity,
speedKmh: t.speedKmh,
assignedContractId: null, // Legacy (für intermodal-Pfad)
pickupRouteId: null,
deliveryRouteId: null,
activeRouteId: null,
tripPhase: null,
segmentProgress: 0,
phaseRemainingMinutes: 0,
loadMinutes: 0,
unloadMinutes: 0,
// Phase 8: Multi-Contract-Tour
loadedContractIds: [],
tourStops: [],
currentStopIndex: 0,
});
}
game.vehicles = out;
}
function _vehicleLabel(mode) {
if (mode === 'TRUCK_SMALL') return 'Kleiner LKW';
if (mode === 'TRUCK_LARGE') return 'Großer LKW';
if (mode === 'TRAIN') return 'Güterzug';
return mode;
}
/**
* Auftrags-Templates für deterministische Generierung (Phase 3).
* Pro Template: Origin, Target, max-Container-Range, Eilauftrags-Flag.
*/
// Phase 8-Balance: mehr 1-Container-Aufträge, damit kleine LKWs nützlich sind.
// Nur wenige „Großaufträge" (2-3 Container) bleiben für Zug / großen LKW.
const CONTRACT_TEMPLATES = Object.freeze([
{ origin: 'wien', target: 'salzburg', containers: [1, 1], express: false, cargoTypeIds: ['food', 'machinery'] },
{ origin: 'wien', target: 'muenchen', containers: [1, 2], express: false, cargoTypeIds: ['machinery', 'auto_parts'] },
{ origin: 'muenchen', target: 'hamburg', containers: [2, 3], express: false, cargoTypeIds: ['machinery', 'chemicals'] }, // Großauftrag
{ origin: 'berlin', target: 'hamburg', containers: [1, 1], express: false, cargoTypeIds: ['electronics', 'clothing'] },
{ origin: 'hamburg', target: 'rotterdam', containers: [2, 3], express: false, cargoTypeIds: ['raw_materials', 'chemicals'] }, // Großauftrag
{ origin: 'rotterdam', target: 'paris', containers: [1, 1], express: true, cargoTypeIds: ['electronics', 'clothing'] },
{ origin: 'paris', target: 'muenchen', containers: [1, 2], express: false, cargoTypeIds: ['food', 'clothing'] },
{ origin: 'wien', target: 'berlin', containers: [1, 1], express: false, cargoTypeIds: ['auto_parts', 'machinery'] },
{ origin: 'muenchen', target: 'mailand', containers: [1, 1], express: true, cargoTypeIds: ['auto_parts', 'electronics'] },
{ origin: 'berlin', target: 'warschau', containers: [1, 2], express: false, cargoTypeIds: ['chemicals', 'machinery'] },
{ origin: 'salzburg', target: 'muenchen', containers: [1, 1], express: false, cargoTypeIds: ['food', 'paper_wood'] },
{ origin: 'kopenhagen', target: 'hamburg', containers: [1, 2], express: false, cargoTypeIds: ['paper_wood', 'food'] },
]);
/**
* Holt deterministisch das nächste Template aus CONTRACT_TEMPLATES.
* Spult den RNG-Stream zum aktuellen Counter vor.
* @private
*/
function _nextContractTemplate(game) {
if (!game._contractRngCalls) game._contractRngCalls = 0;
const rng = _makeRng(game.seed || 1);
for (let i = 0; i < game._contractRngCalls; i++) rng();
const v = rng();
game._contractRngCalls++;
const tpl = CONTRACT_TEMPLATES[Math.floor(v * CONTRACT_TEMPLATES.length)];
// Container-Wahl deterministisch
const v2 = rng();
game._contractRngCalls++;
const [cMin, cMax] = tpl.containers;
const containers = cMin + Math.floor(v2 * (cMax - cMin + 1));
// Cargo-Typ deterministisch
const v3 = rng();
game._contractRngCalls++;
const cargoIds = tpl.cargoTypeIds || ['general'];
const cargoTypeId = cargoIds[Math.floor(v3 * cargoIds.length)];
return {
origin: tpl.origin, target: tpl.target, containers,
express: tpl.express, cargoTypeId,
};
}
/**
* Erzeugt initiale Aufträge je nach Level-Config.
* Level 1: 1 fester Auftrag Wien → Salzburg.
* Level 2/3: deterministisch aus CONTRACT_TEMPLATES (via game.seed).
*/
function seedInitialContracts(game) {
const cfg = game._config || {};
const max = cfg.maxActiveContracts || 1;
const out = [];
if (max === 1) {
// L1 deterministisch und hardcoded — gut für Tutorial
out.push(_makeContract(game, 'C-001', 'wien', 'salzburg', 1, false));
} else {
for (let i = 0; i < max; i++) {
const tpl = _nextContractTemplate(game);
const code = 'C-' + String(i + 1).padStart(3, '0');
const c = _makeContract(game, code, tpl.origin, tpl.target, tpl.containers, tpl.express);
c.cargoTypeId = tpl.cargoTypeId;
out.push(c);
}
}
game.activeContracts = out;
}
/**
* Phase 4b: Intermodaler Contract mit mehreren Legs.
* Jedes Leg = ein Vehicle-Trip. Container wandert von Leg zu Leg
* über Übergabepunkte (Hafen, Terminal).
*
* @param {Object} game
* @param {string} code
* @param {Array<{mode, originId, targetId}>} legs
* @param {number} containers
* @param {boolean} isExpress
*/
function _makeIntermodalContract(game, code, legs, containers, isExpress) {
const cfg = game._config || {};
const locs = game.worldState.locations || [];
let totalDistanceKm = 0;
let totalDurationMinutes = 0;
legs.forEach(l => {
const o = locs.find(x => x.id === l.originId);
const t = locs.find(x => x.id === l.targetId);
if (!o || !t) return;
// Approximation: airline×1.3 — echte Routen werden bei assignContract berechnet
const air = _segmentLength([o.lat, o.lon], [t.lat, t.lon]);
const km = air * 1.3;
const veh = VEHICLE_DEFAULTS[l.mode] || VEHICLE_DEFAULTS.TRUCK_SMALL;
totalDistanceKm += km;
totalDurationMinutes += (km / veh.speedKmh) * 60;
});
const baseFristH = isExpress ? (totalDistanceKm / 70) : (totalDistanceKm / 60 * 1.5);
const fristH = baseFristH * (cfg.deadlineMultiplier || 1);
const due = new Date(game.simulationTime);
due.setTime(due.getTime() + fristH * 60 * 60 * 1000);
const baseReward = Math.round(_computeReward(totalDistanceKm, isExpress, containers) * 1.5); // Intermodal-Bonus +50 %
return {
id: 'contract_' + code,
code,
intermodal: true,
legs: legs.map((l, i) => ({
legNum: i,
mode: l.mode,
originLocationId: l.originId,
targetLocationId: l.targetId,
state: i === 0 ? CONTRACT_STATE.OPEN : 'WAITING',
assignedVehicleId: null,
routeId: null,
departTime: null,
arrivalTime: null,
})),
currentLegNum: 0,
currentLocationId: legs[0].originId,
originLocationId: legs[0].originId, // Erster Origin
targetLocationId: legs[legs.length - 1].targetId, // Letzter Target
cargoTypeId: 'general',
quantityContainers: containers,
state: CONTRACT_STATE.OPEN,
issueTime: game.simulationTime,
dueTime: due.toISOString(),
rewardBase: baseReward,
hintMode: cfg.defaultHintMode || HINT_MODE.NONE,
narrativeText: 'Intermodal: ' +
legs.map(l => (locs.find(x => x.id === l.originId) || {}).name).concat(
[(locs.find(x => x.id === legs[legs.length - 1].targetId) || {}).name]
).join(' → '),
};
}
/**
* Phase 8-Tuning: Reward hat Distanz- und Container-Komponente.
* - PH-Base bleibt
* - Distanz-Bonus: +1.50 €/km über 300 km
* - Container-Bonus: +250 € pro zusätzlichem Container (über 1 hinaus)
*
* Damit sind 1-Container-Aufträge für kleine LKWs wirtschaftlich,
* und 2-3-Container-Aufträge bleiben für große LKW / Zug interessant.
*/
function _computeReward(distanceKm, isExpress, containers) {
const base = isExpress ? ECONOMY.expressContractBase : ECONOMY.standardContractBase;
const distanceBonus = Math.max(0, (distanceKm - 300) * 1.5);
const containerBonus = Math.max(0, ((containers || 1) - 1)) * 250;
return Math.round(base + distanceBonus + containerBonus);
}
function _makeContract(game, code, originId, targetId, containers, isExpress) {
const locs = game.worldState.locations || [];
const o = locs.find(l => l.id === originId);
const t = locs.find(l => l.id === targetId);
const cfg = game._config || {};
// Frist nach Pflichtenheft 65.6: Standard = Distanz/60 × 1.5 × deadlineMultiplier
const airlineKm = (o && t) ? _segmentLength([o.lat, o.lon], [t.lat, t.lon]) : 0;
const distanceKm = airlineKm * 1.3;
const baseFristH = isExpress ? (distanceKm / 70)
: (distanceKm / 60 * 1.5);
const fristH = baseFristH * (cfg.deadlineMultiplier || 1);
const due = new Date(game.simulationTime);
due.setTime(due.getTime() + fristH * 60 * 60 * 1000);
return {
id: 'contract_' + code,
code,
originLocationId: originId,
targetLocationId: targetId,
cargoTypeId: 'general',
quantityContainers: containers,
state: CONTRACT_STATE.OPEN,
issueTime: game.simulationTime,
dueTime: due.toISOString(),
rewardBase: _computeReward(distanceKm, isExpress, containers),
recommendedModes: ['TRUCK_SMALL'],
hintMode: cfg.defaultHintMode || HINT_MODE.BLINK_EXACT,
narrativeText: isExpress
? 'Eilauftrag: ' + (o ? o.name : originId) + ' → ' + (t ? t.name : targetId)
: 'Standard: ' + (o ? o.name : originId) + ' → ' + (t ? t.name : targetId),
};
}
/** @throws nicht implementiert — Phase 5 */
function useHint(game, contractId, hintMode) {
throw new Error('LogistikEngine.useHint: nicht implementiert (Phase 5)');
}
/**
* Phase 6c: Minigame-Result wird auf nächste Verladung angewendet.
* `loadingTimeFactor` (aus result.metadata) modifiziert phaseRemainingMinutes
* der nächsten LOADING-Phase.
*/
function applyMinigameResult(game, result) {
if (!result) return;
const factor = (result.metadata && typeof result.metadata.loadingTimeFactor === 'number')
? result.metadata.loadingTimeFactor : 1;
game._loadingTimeModifier = factor;
if (!game.analytics) game.analytics = {};
game.analytics.minigamesPlayed = (game.analytics.minigamesPlayed || 0) + 1;
if (result.success) {
game.analytics.minigamesSuccessful = (game.analytics.minigamesSuccessful || 0) + 1;
}
if (!game.pendingNotifications) game.pendingNotifications = [];
const q = (result.metadata && result.metadata.quality) || (result.success ? 'ok' : 'miss');
game.pendingNotifications.push({
id: 'mg_' + Date.now(),
type: 'MINIGAME_DONE',
messageShort: '🎮 Mini-Übung: ' + q,
simTime: game.simulationTime,
});
return game;
}
/* ============================================================
REINE HELPER (können / sollen Phase 0 schon getestet werden)
============================================================ */
/**
* Fahrkosten für eine Strecke. Pflichttest #1 aus Kickoff-Briefing.
* @param {number} distanceKm
* @param {number} durationHours
* @param {'TRUCK_SMALL'|'TRUCK_LARGE'|'TRAIN'} mode
* @returns {number} Kosten in €
*/
function travelCost(distanceKm, durationHours, mode) {
const v = VEHICLE_DEFAULTS[mode];
if (!v) throw new Error('Unbekannter Fahrzeugmodus: ' + mode);
return distanceKm * v.costPerKm + durationHours * v.costPerHour;
}
/**
* Strafkosten bei Verspätung. Pflichttest #3.
* @param {number} contractValue
* @param {number} hoursLate
* @returns {number} Strafe in €
*/
function latePenalty(contractValue, hoursLate) {
return Math.max(0, contractValue * ECONOMY.latePenaltyPercentPerHour * hoursLate);
}
/**
* Bonus-Kombi: pünktlich + optimal. Pflichttest #4.
* @param {number} contractValue
* @param {{ onTime: boolean, noEmpty: boolean }} flags
* @returns {number} Bonus in €
*/
function calculateBonus(contractValue, flags) {
let bonus = 0;
if (flags.onTime) bonus += contractValue * ECONOMY.punctualBonusPercent;
if (flags.noEmpty) bonus += contractValue * ECONOMY.optimalBonusPercent;
return bonus;
}
/**
* Interpolation entlang einer Polyline (lat/lon-Paare).
* Pflichttest #2.
* @param {[number,number][]} polyline
* @param {number} progress 0..1
* @returns {[number,number]} [lat, lon]
*/
function interpolateAlongPolyline(polyline, progress) {
if (!polyline || polyline.length < 2) throw new Error('Polyline zu kurz');
const p = Math.max(0, Math.min(1, progress));
if (p === 0) return polyline[0];
if (p === 1) return polyline[polyline.length - 1];
const total = _polylineLength(polyline);
const target = p * total;
let acc = 0;
for (let i = 0; i < polyline.length - 1; i++) {
const d = _segmentLength(polyline[i], polyline[i + 1]);
if (acc + d >= target) {
const local = (target - acc) / d;
const [lat1, lon1] = polyline[i];
const [lat2, lon2] = polyline[i + 1];
return [lat1 + (lat2 - lat1) * local, lon1 + (lon2 - lon1) * local];
}
acc += d;
}
return polyline[polyline.length - 1];
}
/**
* Dijkstra über Bahnnetz. Pflichttest #7.
* Graph: { nodes:[{id}], edges:[{from,to,distanceKm}] } — ungerichtet.
* @param {{nodes:Array, edges:Array}} graph
* @param {string} startId
* @param {string} endId
* @returns {{path:string[], distanceKm:number}|null}
*/
function railShortestPath(graph, startId, endId) {
const adj = new Map();
graph.nodes.forEach(n => adj.set(n.id, []));
graph.edges.forEach(e => {
if (!adj.has(e.from) || !adj.has(e.to)) return;
adj.get(e.from).push({ to: e.to, d: e.distanceKm });
adj.get(e.to).push({ to: e.from, d: e.distanceKm });
});
const dist = new Map();
const prev = new Map();
const queue = new Set(graph.nodes.map(n => n.id));
graph.nodes.forEach(n => dist.set(n.id, Infinity));
dist.set(startId, 0);
while (queue.size) {
let u = null, best = Infinity;
for (const id of queue) if (dist.get(id) < best) { best = dist.get(id); u = id; }
if (u === null || u === endId) break;
queue.delete(u);
for (const { to, d } of adj.get(u) || []) {
if (!queue.has(to)) continue;
const alt = dist.get(u) + d;
if (alt < dist.get(to)) {
dist.set(to, alt);
prev.set(to, u);
}
}
}
if (!isFinite(dist.get(endId))) return null;
const path = [];
let cur = endId;
while (cur !== undefined) { path.unshift(cur); cur = prev.get(cur); }
return { path, distanceKm: dist.get(endId) };
}
/* ============================================================
PRIVATE
============================================================ */
/** Mulberry32 RNG — gleich wie headless-runner.js, hier intern. */
function _makeRng(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function _hashStr(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
return h >>> 0;
}
function _uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function _segmentLength(a, b) {
const [lat1, lon1] = a;
const [lat2, lon2] = b;
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function _polylineLength(poly) {
let sum = 0;
for (let i = 0; i < poly.length - 1; i++) sum += _segmentLength(poly[i], poly[i + 1]);
return sum;
}
/* ============================================================
EXPORT
============================================================ */
window.LogistikEngine = {
// Enums
STATE, VEHICLE_MODE, VEHICLE_STATE, CONTRACT_STATE,
LOCATION_TYPE, EVENT_TYPE, HINT_MODE,
// Konstanten
VEHICLE_DEFAULTS, ECONOMY, EVENT_RULES,
// Factories
createGame,
// Lifecycle (Phase 1)
loadContent, startPlanning, startSimulation,
pause, resume, setTimeScale, onStateChange,
// Tick (Phase 1: Sim-Zeit + Zeitlimit)
tick, convertRealTimeToGameMinutes, getSimHoursElapsed,
// Persistenz (Plattform 7b)
serialize, deserialize,
// Phase 2 — Aufträge, Fahrzeuge, Routen
calculateRoute, assignContract,
seedInitialVehicles, seedInitialContracts,
// Phase 4b — Intermodal
makeIntermodalContract: _makeIntermodalContract,
// Phase 8 — Multi-Contract-Tour
loadContract, unloadContract, cancelLoading, startTour,
// Stubs (Phase 5/6)
useHint, applyMinigameResult,
// Helper (Phase 0 testbar)
travelCost, latePenalty, calculateBonus,
interpolateAlongPolyline, railShortestPath,
};
})();