fc8996f064
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>
2019 lines
81 KiB
JavaScript
2019 lines
81 KiB
JavaScript
/**
|
||
* Logistik Europa — Engine (Skelett)
|
||
* ------------------------------------------------------------------
|
||
* Headless Simulationslogik ohne DOM, ohne Rendering. Die UI
|
||
* (game.html, test.html) ruft Methoden auf und verarbeitet
|
||
* zurückgegebene Events selbst.
|
||
*
|
||
* Exportiert als globales `window.LogistikEngine` (kein ES-Module,
|
||
* kein Bundler).
|
||
*
|
||
* Dieses Skelett enthält:
|
||
* - State-Machine-Enum (aus Pflichtenheft Kap 44)
|
||
* - Enum-Konstanten für Fahrzeugmodi, Vertrags-/Fahrzeug-Zustände,
|
||
* Ereignistypen, Hilfestufen (Kap 42)
|
||
* - Platzhalter-Funktionen für createGame, tick, assignContract,
|
||
* calculateRoute, useHint, applyMinigameResult
|
||
* - Test-Hooks für die Regressionstests aus dem Kickoff-Briefing
|
||
*
|
||
* Phase 0 (Balance-Matrix + Test-Harness) soll dieses Skelett nutzen,
|
||
* aber die Implementierung bleibt bewusst Stub, damit die Tests
|
||
* zuerst das Contract testen (TDD-ähnlich).
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ============================================================
|
||
ENUMS (aus Pflichtenheft Kap 42, 44)
|
||
============================================================ */
|
||
|
||
const STATE = Object.freeze({
|
||
INIT: 'INIT',
|
||
LOADING_CONTENT: 'LOADING_CONTENT',
|
||
READY: 'READY',
|
||
PLANNING: 'PLANNING',
|
||
RUNNING: 'RUNNING',
|
||
PAUSED_BY_EVENT: 'PAUSED_BY_EVENT',
|
||
PAUSED_BY_ARRIVAL: 'PAUSED_BY_ARRIVAL',
|
||
MINIGAME: 'MINIGAME',
|
||
LEVEL_SUCCESS: 'LEVEL_SUCCESS',
|
||
LEVEL_FAILED: 'LEVEL_FAILED',
|
||
SAVING: 'SAVING',
|
||
ERROR: 'ERROR',
|
||
});
|
||
|
||
const VEHICLE_MODE = Object.freeze({
|
||
TRUCK_SMALL: 'TRUCK_SMALL',
|
||
TRUCK_LARGE: 'TRUCK_LARGE',
|
||
TRAIN: 'TRAIN',
|
||
});
|
||
|
||
const VEHICLE_STATE = Object.freeze({
|
||
IDLE: 'IDLE',
|
||
RESERVED: 'RESERVED',
|
||
MOVING: 'MOVING',
|
||
LOADING: 'LOADING',
|
||
UNLOADING: 'UNLOADING',
|
||
WAITING: 'WAITING',
|
||
BLOCKED: 'BLOCKED',
|
||
BROKEN: 'BROKEN',
|
||
});
|
||
|
||
const CONTRACT_STATE = Object.freeze({
|
||
OPEN: 'OPEN',
|
||
RESERVED: 'RESERVED',
|
||
ASSIGNED: 'ASSIGNED',
|
||
PICKUP_PENDING: 'PICKUP_PENDING',
|
||
LOADING: 'LOADING',
|
||
IN_TRANSIT: 'IN_TRANSIT',
|
||
AT_TRANSFER: 'AT_TRANSFER',
|
||
DELIVERED: 'DELIVERED',
|
||
LATE: 'LATE',
|
||
FAILED: 'FAILED',
|
||
CANCELLED: 'CANCELLED',
|
||
});
|
||
|
||
const LOCATION_TYPE = Object.freeze({
|
||
CITY: 'CITY',
|
||
CAPITAL: 'CAPITAL',
|
||
PORT: 'PORT',
|
||
TERMINAL: 'TERMINAL',
|
||
INDUSTRY: 'INDUSTRY',
|
||
CUSTOMER: 'CUSTOMER',
|
||
HUB: 'HUB',
|
||
});
|
||
|
||
const EVENT_TYPE = Object.freeze({
|
||
TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT',
|
||
SNOW: 'SNOW',
|
||
STORM: 'STORM',
|
||
PORT_DELAY: 'PORT_DELAY',
|
||
RAIL_STRIKE: 'RAIL_STRIKE',
|
||
SPECIAL_ORDER: 'SPECIAL_ORDER',
|
||
MAINTENANCE: 'MAINTENANCE',
|
||
});
|
||
|
||
const HINT_MODE = Object.freeze({
|
||
BLINK_EXACT: 'BLINK_EXACT',
|
||
SHOW_COUNTRY: 'SHOW_COUNTRY',
|
||
SHOW_REGION: 'SHOW_REGION',
|
||
DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK',
|
||
NONE: 'NONE',
|
||
});
|
||
|
||
/* ============================================================
|
||
KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65
|
||
Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt
|
||
sie ggf. pro Level.
|
||
============================================================ */
|
||
|
||
const VEHICLE_DEFAULTS = Object.freeze({
|
||
TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5 },
|
||
TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 },
|
||
TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 },
|
||
});
|
||
|
||
const ECONOMY = Object.freeze({
|
||
standCostContainerPerHour: 10, // Container im Hafen
|
||
standCostVehicleIdlePerHour: 5,
|
||
latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts
|
||
punctualBonusPercent: 0.1, // +10%
|
||
optimalBonusPercent: 0.2, // +20% (keine Leerfahrt)
|
||
standardContractBase: 1000,
|
||
expressContractBase: 1500,
|
||
});
|
||
|
||
const EVENT_RULES = Object.freeze({
|
||
TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 },
|
||
SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] },
|
||
PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 },
|
||
});
|
||
|
||
/* ============================================================
|
||
FACTORIES
|
||
============================================================ */
|
||
|
||
/**
|
||
* Erzeugt einen neuen Spielzustand für das gegebene Level.
|
||
* Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults.
|
||
*
|
||
* Tests/Headless-Runs können `opts.deterministic = true` setzen, dann
|
||
* werden `sessionId` und `simulationTime` reproduzierbar (Seed-basiert).
|
||
*
|
||
* @param {number} levelNum
|
||
* @param {{deterministic?:boolean, seed?:number}} [opts]
|
||
* @returns {Object} gameState
|
||
*/
|
||
function createGame(levelNum, opts) {
|
||
const o = opts || {};
|
||
const levelCfg = (window.LOGISTIK_LEVELS || []).find(l => l.level === levelNum)?.params || {};
|
||
const startIso = o.deterministic
|
||
? '2026-01-01T08:00:00.000Z'
|
||
: new Date().toISOString();
|
||
return {
|
||
seed: o.seed || _hashStr('default-seed-' + levelNum),
|
||
sessionId: o.deterministic ? ('sess_' + (o.seed || 0)) : _uuid(),
|
||
levelId: 'level_' + levelNum,
|
||
state: STATE.INIT,
|
||
simulationTime: startIso,
|
||
simulationStartTime: startIso,
|
||
timeScale: levelCfg.initialTimeScale ?? 1,
|
||
paused: true,
|
||
balance: levelCfg.startBudget ?? 5000,
|
||
score: 0,
|
||
activeContracts: [],
|
||
completedContracts: [],
|
||
vehicles: [],
|
||
activeRoutes: [],
|
||
activeEvents: [],
|
||
pendingNotifications: [],
|
||
pendingAnalytics: [],
|
||
usedHints: [],
|
||
analytics: {
|
||
totalKm: 0,
|
||
emptyKm: 0,
|
||
lateDeliveries: 0,
|
||
hintUsages: 0,
|
||
misclicks: 0,
|
||
},
|
||
worldState: {},
|
||
version: '0.1.0',
|
||
_pendingEvents: [],
|
||
_config: levelCfg,
|
||
_stateChangeListeners: [], // nicht-serialisiert (siehe serialize())
|
||
};
|
||
}
|
||
|
||
/* ============================================================
|
||
LIFECYCLE — Phase 1 (State-Maschinen-Übergänge)
|
||
============================================================ */
|
||
|
||
/**
|
||
* Setzt einen neuen State und ruft alle registrierten Listener
|
||
* (z.B. Autosave-Hooks gemäß Plattform-Konvention 7b).
|
||
* @private
|
||
*/
|
||
function _setState(game, newState, reason) {
|
||
if (game.state === newState) return;
|
||
const oldState = game.state;
|
||
game.state = newState;
|
||
const evt = { from: oldState, to: newState, reason: reason || null, at: game.simulationTime };
|
||
(game._stateChangeListeners || []).forEach(cb => {
|
||
try { cb(evt, game); } catch (e) { /* Listener-Fehler isolieren */ }
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Registriert einen State-Change-Listener (z.B. für Autosave).
|
||
* Listener-Signatur: `({from, to, reason, at}, game) => void`.
|
||
* @returns {Function} unregister-Funktion
|
||
*/
|
||
function onStateChange(game, callback) {
|
||
if (!game._stateChangeListeners) game._stateChangeListeners = [];
|
||
game._stateChangeListeners.push(callback);
|
||
return function unregister() {
|
||
const idx = game._stateChangeListeners.indexOf(callback);
|
||
if (idx !== -1) game._stateChangeListeners.splice(idx, 1);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* INIT → LOADING_CONTENT → READY (synchron, weil Seeds bereits via
|
||
* window.LOGISTIK_SEEDS injiziert sind).
|
||
*/
|
||
function loadContent(game, seeds) {
|
||
if (game.state !== STATE.INIT) {
|
||
throw new Error('loadContent erwartet state=INIT, ist aber ' + game.state);
|
||
}
|
||
_setState(game, STATE.LOADING_CONTENT, 'loadContent');
|
||
const ws = window.LOGISTIK_SEEDS || {};
|
||
game.worldState.locations = (seeds && seeds.locations) || ws.locations || [];
|
||
game.worldState.vehicleTypes = (seeds && seeds.vehicleTypes) || ws.vehicleTypes || [];
|
||
game.worldState.cargoTypes = (seeds && seeds.cargoTypes) || ws.cargoTypes || [];
|
||
game.worldState.railnet = (seeds && seeds.railnet) || ws.railnet || { nodes: [], edges: [] };
|
||
// Phase 3: Hand-gepflegte Polylines (lg-routes-osm.json)
|
||
const rOsm = (seeds && seeds.routesOsm) || ws.routesOsm || {};
|
||
game.worldState.routesOsm = Array.isArray(rOsm) ? rOsm : (rOsm.routes || []);
|
||
_setState(game, STATE.READY, 'content_loaded');
|
||
return game;
|
||
}
|
||
|
||
/** READY → PLANNING (Bearbeiter:in disponiert, Zeit steht still). */
|
||
function startPlanning(game) {
|
||
if (game.state !== STATE.READY) {
|
||
throw new Error('startPlanning erwartet state=READY, ist aber ' + game.state);
|
||
}
|
||
_setState(game, STATE.PLANNING, 'ui_ready');
|
||
return game;
|
||
}
|
||
|
||
/** PLANNING/PAUSED_BY_* → RUNNING. Setzt paused=false. */
|
||
function startSimulation(game) {
|
||
const allowed = [STATE.PLANNING, STATE.PAUSED_BY_EVENT, STATE.PAUSED_BY_ARRIVAL, STATE.READY];
|
||
if (allowed.indexOf(game.state) === -1) {
|
||
throw new Error('startSimulation erwartet state in {' + allowed.join(',') + '}, ist aber ' + game.state);
|
||
}
|
||
game.paused = false;
|
||
_setState(game, STATE.RUNNING, 'sim_started');
|
||
return game;
|
||
}
|
||
|
||
/** RUNNING → paused (state bleibt RUNNING, nur Flag). User-Pause. */
|
||
function pause(game) {
|
||
game.paused = true;
|
||
return game;
|
||
}
|
||
|
||
/** Aufhebung der User-Pause. */
|
||
function resume(game) {
|
||
game.paused = false;
|
||
return game;
|
||
}
|
||
|
||
/**
|
||
* Setzt die Zeitskala. Pflichtwerte: 1 / 2 / 4 / 8 (PH 14.6, 65.4).
|
||
* `0` ist erlaubt als Software-Pause-Äquivalent (selten gebraucht).
|
||
*/
|
||
function setTimeScale(game, scale) {
|
||
if (typeof scale !== 'number' || scale < 0) {
|
||
throw new Error('setTimeScale: ungültige scale ' + scale);
|
||
}
|
||
game.timeScale = scale;
|
||
return game;
|
||
}
|
||
|
||
/* ============================================================
|
||
TICK — Phase 1 (Sim-Zeit läuft, noch keine Vehicle/Contract-Mutation)
|
||
============================================================ */
|
||
|
||
/**
|
||
* Konvertiert Echtzeit-Delta in Sim-Minuten.
|
||
* Verhältnis: 1× = 1 Sim-Minute pro Echt-Sekunde (PH 65.4).
|
||
* @param {number} deltaMs
|
||
* @param {number} timeScale
|
||
* @returns {number} Sim-Minuten (kann float sein)
|
||
*/
|
||
function convertRealTimeToGameMinutes(deltaMs, timeScale) {
|
||
return (deltaMs / 1000) * (timeScale || 1);
|
||
}
|
||
|
||
/**
|
||
* Sim-Stunden seit Start.
|
||
* @param {Object} game
|
||
* @returns {number}
|
||
*/
|
||
function getSimHoursElapsed(game) {
|
||
const start = new Date(game.simulationStartTime).getTime();
|
||
const now = new Date(game.simulationTime).getTime();
|
||
return (now - start) / (1000 * 60 * 60);
|
||
}
|
||
|
||
/**
|
||
* Tick-Pipeline (PH 45.3) — Phase-1-Ausbau:
|
||
* 1. SimulationTime erhöhen (immer)
|
||
* 2. updateEvents — Phase 5 (Stub)
|
||
* 3. updateVehicles — Phase 2 (Stub)
|
||
* 4. updateLoading — Phase 3 (Stub)
|
||
* 5. updateContracts — Phase 2 (Stub)
|
||
* 6. applyEconomy — Phase 3 (Stub)
|
||
* 7. generateNotifications — Phase 1 (kein Output)
|
||
* 8. evaluateConditions — Phase 1: nur Zeit-Limit prüfen
|
||
*
|
||
* Tut nichts wenn paused oder state != RUNNING.
|
||
*
|
||
* @param {Object} game
|
||
* @param {number} deltaMs Echtzeit-Delta seit letztem Tick
|
||
* @returns {Object} game
|
||
*/
|
||
function tick(game, deltaMs) {
|
||
if (game.state !== STATE.RUNNING || game.paused) return game;
|
||
const simMinutes = convertRealTimeToGameMinutes(deltaMs, game.timeScale);
|
||
if (simMinutes <= 0) return game; // timeScale=0 → Pause-Äquivalent
|
||
const dt = new Date(game.simulationTime);
|
||
dt.setTime(dt.getTime() + simMinutes * 60 * 1000);
|
||
game.simulationTime = dt.toISOString();
|
||
|
||
// Phase 2: Vehicle-Bewegung (Routen-Interpolation, Delivery, Erlös)
|
||
_updateVehicles(game, simMinutes);
|
||
|
||
// Phase 3: Laufende Kosten (Standkosten IDLE/h, Miete/Tag pro Vehicle)
|
||
_applyRunningCosts(game, simMinutes);
|
||
|
||
// Phase 4: Hafen-Schiffsankünfte (deterministisch, periodisch)
|
||
_maybeGenerateShipArrival(game);
|
||
|
||
// Phase 5: Events generieren + auslaufen lassen
|
||
_maybeGenerateEvents(game, simMinutes);
|
||
_expireEvents(game);
|
||
|
||
// Auswertung: Zeit-Limit (Fail) und Min-Ziel (Success bei Limit-Erreich)
|
||
const limit = game._config && game._config.timeLimitHours;
|
||
if (typeof limit === 'number' && getSimHoursElapsed(game) >= limit) {
|
||
const cfg = game._config;
|
||
const startBudget = cfg.startBudget || 5000;
|
||
const profit = game.balance - startBudget;
|
||
const minTarget = cfg.minTargetEarnings || 0;
|
||
_setState(game,
|
||
profit >= minTarget ? STATE.LEVEL_SUCCESS : STATE.LEVEL_FAILED,
|
||
profit >= minTarget ? 'time_limit_with_target_reached' : 'time_limit_reached'
|
||
);
|
||
} else if ((game.activeContracts || []).length === 0
|
||
&& (game.completedContracts || []).length > 0) {
|
||
// Alle Aufträge abgeschlossen → Erfolgsprüfung sofort
|
||
const cfg = game._config || {};
|
||
const startBudget = cfg.startBudget || 5000;
|
||
const profit = game.balance - startBudget;
|
||
const minTarget = cfg.minTargetEarnings || 0;
|
||
if (profit >= minTarget) {
|
||
_setState(game, STATE.LEVEL_SUCCESS, 'all_contracts_done_target_reached');
|
||
}
|
||
}
|
||
return game;
|
||
}
|
||
|
||
/* ============================================================
|
||
SERIALIZE / DESERIALIZE (Autosave-Vorbereitung Plattform 7b)
|
||
============================================================ */
|
||
|
||
/**
|
||
* Liefert eine JSON-serialisierbare Kopie ohne nicht-serialisierbare
|
||
* Felder (Listener, Polylines mit Funktionen etc.).
|
||
*/
|
||
function serialize(game) {
|
||
const clone = JSON.parse(JSON.stringify(game, (key, value) => {
|
||
if (key === '_stateChangeListeners') return undefined;
|
||
return value;
|
||
}));
|
||
return clone;
|
||
}
|
||
|
||
/** Reaktiviert Listener-Array nach Deserialisierung. */
|
||
function deserialize(json) {
|
||
const game = (typeof json === 'string') ? JSON.parse(json) : json;
|
||
if (!game._stateChangeListeners) game._stateChangeListeners = [];
|
||
return game;
|
||
}
|
||
|
||
/* ============================================================
|
||
PLACEHOLDER / STUB FUNKTIONEN
|
||
(Logistik-Instanz implementiert diese in Phase 1–7)
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
PHASE 2 — calculateRoute, assignContract, Vehicle-Bewegung
|
||
============================================================ */
|
||
|
||
/**
|
||
* Berechnet eine Route. Phase-3-Strategie:
|
||
* 1. Lookup in `worldState.routesOsm`. Wenn Polyline (auch invers)
|
||
* gefunden → nutze die Hand-gepflegte Polyline.
|
||
* 2. Fallback: Luftlinie × 1.3 (Phase-2-Modus).
|
||
*
|
||
* @param {Object} game
|
||
* @param {string} originId Location-ID
|
||
* @param {string} targetId Location-ID
|
||
* @param {'TRUCK_SMALL'|'TRUCK_LARGE'|'TRAIN'} mode
|
||
* @returns {Object} Route mit Geometrie, Distanz, Dauer
|
||
*/
|
||
function calculateRoute(game, originId, targetId, mode) {
|
||
const veh = VEHICLE_DEFAULTS[mode];
|
||
if (!veh) throw new Error('Unbekannter Fahrzeugmodus: ' + mode);
|
||
const locs = (game.worldState && game.worldState.locations) || [];
|
||
const o = locs.find(l => l.id === originId);
|
||
const t = locs.find(l => l.id === targetId);
|
||
if (!o) throw new Error('Origin nicht gefunden: ' + originId);
|
||
if (!t) throw new Error('Target nicht gefunden: ' + targetId);
|
||
|
||
let geometry = null;
|
||
let provider = 'airline_x1.3';
|
||
let distanceKm;
|
||
let segments = null;
|
||
|
||
// Phase 4: TRAIN nutzt railnet-Dijkstra (wenn beide Locations Bahnknoten sind)
|
||
if (mode === 'TRAIN') {
|
||
const rail = (game.worldState && game.worldState.railnet) || { nodes: [], edges: [] };
|
||
const node = (locId) => rail.nodes.find(n => n.locationId === locId);
|
||
if (node(originId) && node(targetId)) {
|
||
const dij = railShortestPath(
|
||
{ nodes: rail.nodes, edges: rail.edges },
|
||
node(originId).id, node(targetId).id
|
||
);
|
||
if (dij && dij.path) {
|
||
geometry = dij.path.map(nodeId => {
|
||
const n = rail.nodes.find(x => x.id === nodeId);
|
||
const loc = locs.find(l => l.id === n.locationId);
|
||
return [loc.lat, loc.lon];
|
||
});
|
||
distanceKm = dij.distanceKm;
|
||
provider = 'rail_dijkstra';
|
||
// Segments pro Bahnkante
|
||
segments = [];
|
||
for (let i = 0; i < dij.path.length - 1; i++) {
|
||
const a = rail.nodes.find(n => n.id === dij.path[i]);
|
||
const b = rail.nodes.find(n => n.id === dij.path[i + 1]);
|
||
const edge = rail.edges.find(e =>
|
||
(e.from === a.id && e.to === b.id) ||
|
||
(e.from === b.id && e.to === a.id));
|
||
const la = locs.find(l => l.id === a.locationId);
|
||
const lb = locs.find(l => l.id === b.locationId);
|
||
segments.push({
|
||
sequenceNo: i, segmentType: 'RAIL',
|
||
startLat: la.lat, startLon: la.lon,
|
||
endLat: lb.lat, endLon: lb.lon,
|
||
distanceKm: edge ? edge.distanceKm : 0,
|
||
durationMinutes: edge && edge.durationMinutes ? edge.durationMinutes
|
||
: ((edge ? edge.distanceKm : 0) / veh.speedKmh * 60),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase 3: Polyline-Lookup für Straße (oder TRAIN-Fallback wenn kein Bahnknoten)
|
||
if (!geometry) {
|
||
const osmRoutes = (game.worldState && game.worldState.routesOsm) || [];
|
||
for (const r of osmRoutes) {
|
||
if (r.originLocationId === originId && r.targetLocationId === targetId) {
|
||
geometry = r.polyline.slice();
|
||
provider = 'osm_polyline';
|
||
break;
|
||
}
|
||
if (r.originLocationId === targetId && r.targetLocationId === originId) {
|
||
geometry = r.polyline.slice().reverse();
|
||
provider = 'osm_polyline_reversed';
|
||
break;
|
||
}
|
||
}
|
||
if (geometry) {
|
||
distanceKm = _polylineLength(geometry);
|
||
} else {
|
||
// Fallback Luftlinie × 1.3 (Phase 2)
|
||
const airlineKm = _segmentLength([o.lat, o.lon], [t.lat, t.lon]);
|
||
distanceKm = airlineKm * 1.3;
|
||
geometry = [[o.lat, o.lon], [t.lat, t.lon]];
|
||
}
|
||
}
|
||
const durationMinutes = (distanceKm / veh.speedKmh) * 60;
|
||
return {
|
||
id: 'route_' + originId + '_' + targetId + '_' + mode + '_' + Date.now(),
|
||
originLocationId: originId,
|
||
targetLocationId: targetId,
|
||
mode,
|
||
provider,
|
||
totalDistanceKm: distanceKm,
|
||
totalDurationMinutes: durationMinutes,
|
||
geometry,
|
||
segments: segments || [{
|
||
sequenceNo: 0,
|
||
segmentType: mode === 'TRAIN' ? 'RAIL' : 'ROAD',
|
||
startLat: o.lat, startLon: o.lon,
|
||
endLat: t.lat, endLon: t.lon,
|
||
distanceKm,
|
||
durationMinutes,
|
||
}],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Weist einen offenen Auftrag einem freien Fahrzeug zu.
|
||
* Berechnet Route, fügt sie zu activeRoutes, setzt States.
|
||
*
|
||
* Phase-2-Annahme: Fahrzeug startet **direkt am Auftrags-Origin**
|
||
* (kein Pickup-Drive aus Vorposition). Vereinfacht den Loop massiv;
|
||
* realistisches Pickup-Drive kommt mit Phase 3.
|
||
*/
|
||
function assignContract(game, contractId, vehicleId, legNum) {
|
||
const c = (game.activeContracts || []).find(x => x.id === contractId);
|
||
if (!c) throw new Error('Auftrag nicht gefunden: ' + contractId);
|
||
const v = (game.vehicles || []).find(x => x.id === vehicleId);
|
||
if (!v) throw new Error('Fahrzeug nicht gefunden: ' + vehicleId);
|
||
if (v.state !== VEHICLE_STATE.IDLE) {
|
||
throw new Error('Fahrzeug nicht IDLE, sondern ' + v.state);
|
||
}
|
||
|
||
// Phase 4b: Intermodal — leg-basierte Zuweisung
|
||
let originLocationId, targetLocationId, leg;
|
||
if (c.intermodal) {
|
||
const legIdx = (typeof legNum === 'number') ? legNum : c.currentLegNum;
|
||
leg = c.legs[legIdx];
|
||
if (!leg) throw new Error('Leg ' + legIdx + ' existiert nicht');
|
||
if (leg.state !== CONTRACT_STATE.OPEN) {
|
||
throw new Error('Leg ' + legIdx + ' nicht OPEN, sondern ' + leg.state);
|
||
}
|
||
// Vehicle-Mode muss zum Leg passen (bei TRAIN-Leg muss ein Zug fahren)
|
||
if (leg.mode === 'TRAIN' && v.mode !== 'TRAIN') {
|
||
throw new Error('Leg verlangt TRAIN, Vehicle ist ' + v.mode);
|
||
}
|
||
if (leg.mode !== 'TRAIN' && v.mode === 'TRAIN') {
|
||
throw new Error('Leg verlangt LKW, Vehicle ist Zug');
|
||
}
|
||
originLocationId = leg.originLocationId;
|
||
targetLocationId = leg.targetLocationId;
|
||
} else {
|
||
if (c.state !== CONTRACT_STATE.OPEN) {
|
||
throw new Error('Auftrag nicht OPEN, sondern ' + c.state);
|
||
}
|
||
originLocationId = c.originLocationId;
|
||
targetLocationId = c.targetLocationId;
|
||
}
|
||
|
||
const veh = VEHICLE_DEFAULTS[v.mode];
|
||
const containers = c.quantityContainers || 1;
|
||
const loadMin = (veh.loadMinutes || 5) * containers;
|
||
const unloadMin = (veh.unloadMinutes || 5) * containers;
|
||
if (!game.activeRoutes) game.activeRoutes = [];
|
||
|
||
let pickupRoute = null;
|
||
if (v.currentLocationId !== originLocationId) {
|
||
pickupRoute = calculateRoute(game, v.currentLocationId, originLocationId, v.mode);
|
||
pickupRoute.vehicleId = v.id;
|
||
pickupRoute.vehicleColor = v.color || null;
|
||
pickupRoute.isPickup = true;
|
||
game.activeRoutes.push(pickupRoute);
|
||
}
|
||
const deliveryRoute = calculateRoute(game, originLocationId, targetLocationId, v.mode);
|
||
deliveryRoute.vehicleId = v.id;
|
||
deliveryRoute.vehicleColor = v.color || null;
|
||
deliveryRoute.isPickup = false;
|
||
game.activeRoutes.push(deliveryRoute);
|
||
|
||
v.assignedContractId = contractId;
|
||
v.assignedLegNum = leg ? leg.legNum : null;
|
||
v.pickupRouteId = pickupRoute ? pickupRoute.id : null;
|
||
v.deliveryRouteId = deliveryRoute.id;
|
||
v.loadMinutes = loadMin;
|
||
v.unloadMinutes = unloadMin;
|
||
v.phaseRemainingMinutes = 0;
|
||
v.segmentProgress = 0;
|
||
|
||
if (pickupRoute) {
|
||
v.tripPhase = 'pickup_drive';
|
||
v.activeRouteId = pickupRoute.id;
|
||
v.state = VEHICLE_STATE.MOVING;
|
||
if (leg) leg.state = CONTRACT_STATE.PICKUP_PENDING;
|
||
else c.state = CONTRACT_STATE.PICKUP_PENDING;
|
||
} else {
|
||
v.tripPhase = 'loading';
|
||
v.activeRouteId = null;
|
||
v.state = VEHICLE_STATE.LOADING;
|
||
// Phase 6c: Minigame-Modifier wirkt auf nächste LOADING (dann reset)
|
||
const mgFactor = game._loadingTimeModifier || 1;
|
||
v.phaseRemainingMinutes = loadMin * mgFactor;
|
||
if (mgFactor !== 1) game._loadingTimeModifier = 1;
|
||
if (leg) leg.state = CONTRACT_STATE.LOADING;
|
||
else c.state = CONTRACT_STATE.LOADING;
|
||
}
|
||
|
||
if (leg) {
|
||
leg.assignedVehicleId = vehicleId;
|
||
leg.routeId = deliveryRoute.id;
|
||
leg.departTime = game.simulationTime;
|
||
} else {
|
||
c.assignedVehicleId = vehicleId;
|
||
c.routeId = deliveryRoute.id;
|
||
c.departTime = game.simulationTime;
|
||
}
|
||
return { contract: c, leg, vehicle: v, route: deliveryRoute, pickupRoute };
|
||
}
|
||
|
||
/**
|
||
* Phase-3-Trip-Pipeline: pickup_drive → loading → delivery_drive → unloading.
|
||
* Pro Vehicle wird der aktuelle TripPhase getickt; bei Phasen-Ende wird
|
||
* der nächste Phase initialisiert. Bei UNLOADING-Ende: Contract abschließen.
|
||
* @private
|
||
*/
|
||
function _updateVehicles(game, simMinutes) {
|
||
(game.vehicles || []).forEach(v => {
|
||
// Pro Vehicle: in einer Schleife alle Phasen abarbeiten, falls die
|
||
// Phase mitten im Tick endet (sonst Drift bei großen Ticks).
|
||
let remaining = simMinutes;
|
||
let safety = 8; // max 8 Phasen pro Tick (4 Phasen × Sicherheits-Reserve)
|
||
while (remaining > 0 && v.tripPhase && safety-- > 0) {
|
||
const consumed = _stepVehiclePhase(game, v, remaining);
|
||
if (consumed <= 0) break; // Phase noch aktiv, kein Übertrag
|
||
remaining -= consumed;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Verarbeitet EINEN Phasen-Schritt; gibt zurück, wie viele Sim-Minuten
|
||
* verbraucht wurden. Wenn Phase fertig vor simMinutes-Ende: konsumiert
|
||
* exakt die nötige Restzeit der Phase und wechselt; Caller läuft mit
|
||
* Übertrag in der neuen Phase weiter.
|
||
* @private
|
||
* @returns {number} verbrauchte Minuten oder simMinutes wenn Phase weiterläuft
|
||
*/
|
||
function _stepVehiclePhase(game, v, simMinutes) {
|
||
const routesById = {};
|
||
(game.activeRoutes || []).forEach(r => { routesById[r.id] = r; });
|
||
|
||
/* -------- Phase 8: Tour-Modus (Multi-Contract) -------- */
|
||
if (v.tripPhase === 'tour_driving') {
|
||
const stop = v.tourStops[v.currentStopIndex];
|
||
const route = routesById[v.activeRouteId];
|
||
if (!route || !stop) return simMinutes;
|
||
const speedMul = _vehicleSpeedMultiplier(game);
|
||
const totalRaw = route.totalDurationMinutes || 1;
|
||
const total = totalRaw / Math.max(0.05, speedMul);
|
||
const remainingProgress = 1 - (v.segmentProgress || 0);
|
||
const minutesToEnd = remainingProgress * total;
|
||
const consume = Math.min(simMinutes, minutesToEnd);
|
||
const next = (v.segmentProgress || 0) + (consume / total);
|
||
const [lat, lon] = interpolateAlongPolyline(route.geometry, Math.min(next, 1));
|
||
v.segmentProgress = next;
|
||
v.lat = lat;
|
||
v.lon = lon;
|
||
if (next >= 0.99999) {
|
||
v.currentLocationId = stop.locationId;
|
||
if (!game.analytics) game.analytics = {};
|
||
game.analytics.totalKm = (game.analytics.totalKm || 0) + route.totalDistanceKm;
|
||
if (route.isPickup) {
|
||
game.analytics.emptyKm = (game.analytics.emptyKm || 0) + route.totalDistanceKm;
|
||
}
|
||
// Fahrkosten sofort buchen
|
||
const fareCost = travelCost(route.totalDistanceKm, route.totalDurationMinutes / 60, v.mode);
|
||
game.balance -= fareCost;
|
||
game.analytics.totalFareCostPaid = (game.analytics.totalFareCostPaid || 0) + fareCost;
|
||
// Route entfernen
|
||
game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== route.id);
|
||
// Handling beginnen
|
||
v.tripPhase = 'tour_handling';
|
||
v.activeRouteId = null;
|
||
v.segmentProgress = 0;
|
||
v.state = stop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING;
|
||
let handlingMinutes = stop.handlingMinutes || 5;
|
||
if (stop.type === 'PICKUP') {
|
||
const mgFactor = game._loadingTimeModifier || 1;
|
||
handlingMinutes *= mgFactor;
|
||
if (mgFactor !== 1) game._loadingTimeModifier = 1;
|
||
}
|
||
v.phaseRemainingMinutes = handlingMinutes;
|
||
stop.contractIds.forEach(cid => {
|
||
const c = (game.activeContracts || []).find(x => x.id === cid);
|
||
if (c) c.state = stop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING;
|
||
});
|
||
}
|
||
return consume;
|
||
}
|
||
|
||
if (v.tripPhase === 'tour_handling') {
|
||
const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0);
|
||
v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume;
|
||
if (v.phaseRemainingMinutes <= 0) {
|
||
const stop = v.tourStops[v.currentStopIndex];
|
||
// Contract-States nach Handling
|
||
stop.contractIds.forEach(cid => {
|
||
const c = (game.activeContracts || []).find(x => x.id === cid);
|
||
if (!c) return;
|
||
if (stop.type === 'PICKUP') {
|
||
c.state = CONTRACT_STATE.IN_TRANSIT;
|
||
} else {
|
||
// Dropoff → Contract komplett abschließen
|
||
_completeContractMulti(game, c, v);
|
||
}
|
||
});
|
||
v.currentStopIndex++;
|
||
if (v.currentStopIndex >= v.tourStops.length) {
|
||
_endTour(game, v);
|
||
} else {
|
||
const nextStop = v.tourStops[v.currentStopIndex];
|
||
if (nextStop.inRouteId) {
|
||
v.tripPhase = 'tour_driving';
|
||
v.activeRouteId = nextStop.inRouteId;
|
||
v.segmentProgress = 0;
|
||
v.state = VEHICLE_STATE.MOVING;
|
||
} else {
|
||
v.tripPhase = 'tour_handling';
|
||
v.activeRouteId = null;
|
||
v.state = nextStop.type === 'PICKUP' ? VEHICLE_STATE.LOADING : VEHICLE_STATE.UNLOADING;
|
||
let m = nextStop.handlingMinutes || 5;
|
||
if (nextStop.type === 'PICKUP') {
|
||
const mgFactor = game._loadingTimeModifier || 1;
|
||
m *= mgFactor;
|
||
if (mgFactor !== 1) game._loadingTimeModifier = 1;
|
||
}
|
||
v.phaseRemainingMinutes = m;
|
||
nextStop.contractIds.forEach(cid => {
|
||
const c = (game.activeContracts || []).find(x => x.id === cid);
|
||
if (c) c.state = nextStop.type === 'PICKUP' ? CONTRACT_STATE.LOADING : CONTRACT_STATE.UNLOADING;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return consume;
|
||
}
|
||
|
||
/* -------- Legacy-Pfade (Single-Contract + Intermodal) -------- */
|
||
if (v.tripPhase === 'pickup_drive' || v.tripPhase === 'delivery_drive') {
|
||
const route = routesById[v.activeRouteId];
|
||
if (!route) return simMinutes;
|
||
// Phase 5: Event-Speed-Multiplikator (Unfall halbiert, Schnee × 0.7 etc.)
|
||
const speedMul = _vehicleSpeedMultiplier(game);
|
||
const totalRaw = route.totalDurationMinutes || 1;
|
||
const total = totalRaw / Math.max(0.05, speedMul); // bei speedMul<1 dauert länger
|
||
const remainingProgress = 1 - (v.segmentProgress || 0);
|
||
const minutesToEnd = remainingProgress * total;
|
||
const consume = Math.min(simMinutes, minutesToEnd);
|
||
const next = (v.segmentProgress || 0) + (consume / total);
|
||
const [lat, lon] = interpolateAlongPolyline(route.geometry, Math.min(next, 1));
|
||
v.segmentProgress = next;
|
||
v.lat = lat;
|
||
v.lon = lon;
|
||
if (next >= 0.99999) {
|
||
v.currentLocationId = route.targetLocationId;
|
||
if (!game.analytics) game.analytics = {};
|
||
game.analytics.totalKm = (game.analytics.totalKm || 0) + route.totalDistanceKm;
|
||
if (v.tripPhase === 'pickup_drive') {
|
||
game.analytics.emptyKm = (game.analytics.emptyKm || 0) + route.totalDistanceKm;
|
||
v.tripPhase = 'loading';
|
||
v.activeRouteId = null;
|
||
v.segmentProgress = 0;
|
||
v.state = VEHICLE_STATE.LOADING;
|
||
v.phaseRemainingMinutes = v.loadMinutes || 5;
|
||
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
|
||
if (c) { c.state = CONTRACT_STATE.LOADING; c.pickupKm = route.totalDistanceKm; }
|
||
game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== route.id);
|
||
} else {
|
||
v.tripPhase = 'unloading';
|
||
v.activeRouteId = null;
|
||
v.segmentProgress = 0;
|
||
v.state = VEHICLE_STATE.UNLOADING;
|
||
v.phaseRemainingMinutes = v.unloadMinutes || 5;
|
||
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
|
||
if (c) c.state = CONTRACT_STATE.AT_TRANSFER;
|
||
}
|
||
}
|
||
return consume;
|
||
}
|
||
if (v.tripPhase === 'loading') {
|
||
const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0);
|
||
v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume;
|
||
if (v.phaseRemainingMinutes <= 0) {
|
||
v.tripPhase = 'delivery_drive';
|
||
v.activeRouteId = v.deliveryRouteId;
|
||
v.segmentProgress = 0;
|
||
v.state = VEHICLE_STATE.MOVING;
|
||
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
|
||
if (c) c.state = CONTRACT_STATE.IN_TRANSIT;
|
||
}
|
||
return consume;
|
||
}
|
||
if (v.tripPhase === 'unloading') {
|
||
const consume = Math.min(simMinutes, v.phaseRemainingMinutes || 0);
|
||
v.phaseRemainingMinutes = (v.phaseRemainingMinutes || 0) - consume;
|
||
if (v.phaseRemainingMinutes <= 0) {
|
||
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
|
||
const deliveryRoute = routesById[v.deliveryRouteId];
|
||
game.activeRoutes = (game.activeRoutes || []).filter(r => r.id !== v.deliveryRouteId);
|
||
if (c && deliveryRoute) {
|
||
if (c.intermodal) {
|
||
_completeLeg(game, c, v.assignedLegNum, deliveryRoute, v);
|
||
} else {
|
||
_completeContract(game, c, deliveryRoute, v);
|
||
}
|
||
}
|
||
v.tripPhase = null;
|
||
v.assignedContractId = null;
|
||
v.assignedLegNum = null;
|
||
v.pickupRouteId = null;
|
||
v.deliveryRouteId = null;
|
||
v.activeRouteId = null;
|
||
v.segmentProgress = 0;
|
||
v.phaseRemainingMinutes = 0;
|
||
v.state = VEHICLE_STATE.IDLE;
|
||
}
|
||
return consume;
|
||
}
|
||
return simMinutes;
|
||
}
|
||
|
||
/**
|
||
* Phase 4b: Vollendet ein einzelnes Leg eines intermodalen Contracts.
|
||
* Bucht Fahrkosten sofort. Wenn letztes Leg → ganzer Contract DELIVERED
|
||
* mit Reward + Bonus + Strafe.
|
||
* @private
|
||
*/
|
||
function _completeLeg(game, contract, legNum, route, vehicle) {
|
||
const leg = contract.legs[legNum];
|
||
if (!leg) return;
|
||
leg.state = CONTRACT_STATE.DELIVERED;
|
||
leg.arrivalTime = game.simulationTime;
|
||
contract.currentLocationId = leg.targetLocationId;
|
||
|
||
// Fahrkosten dieses Legs sofort buchen (Reward kommt am Schluss)
|
||
const fareCost = travelCost(route.totalDistanceKm, route.totalDurationMinutes / 60, route.mode);
|
||
let pickupFareCost = 0;
|
||
if (vehicle && vehicle.pickupRouteId && contract.pickupKm) {
|
||
const veh = VEHICLE_DEFAULTS[route.mode];
|
||
const pickupHours = contract.pickupKm / veh.speedKmh;
|
||
pickupFareCost = contract.pickupKm * veh.costPerKm + pickupHours * veh.costPerHour;
|
||
contract.pickupKm = 0; // reset für nächsten Leg
|
||
}
|
||
game.balance -= (fareCost + pickupFareCost);
|
||
contract.legCostsPaid = (contract.legCostsPaid || 0) + fareCost + pickupFareCost;
|
||
|
||
if (legNum + 1 < contract.legs.length) {
|
||
// Nächster Leg → OPEN
|
||
contract.currentLegNum = legNum + 1;
|
||
contract.legs[legNum + 1].state = CONTRACT_STATE.OPEN;
|
||
// Notification für Übergabe
|
||
if (!game.pendingNotifications) game.pendingNotifications = [];
|
||
const locs = game.worldState.locations || [];
|
||
const locName = (id) => (locs.find(l => l.id === id) || {}).name || id;
|
||
game.pendingNotifications.push({
|
||
id: 'leg_' + contract.id + '_' + legNum,
|
||
type: 'LEG_TRANSFERRED',
|
||
contractId: contract.id,
|
||
messageShort: 'Auftrag „' + (contract.code || contract.id) + '": Container in '
|
||
+ locName(leg.targetLocationId) + ' angekommen, nächster Leg offen.',
|
||
simTime: game.simulationTime,
|
||
});
|
||
} else {
|
||
// Letztes Leg → ganzer Contract abschließen mit Bonus/Strafe/Reward
|
||
const cfg = game._config || {};
|
||
const arrivalIso = game.simulationTime;
|
||
const dueMs = new Date(contract.dueTime || arrivalIso).getTime();
|
||
const arrMs = new Date(arrivalIso).getTime();
|
||
const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60));
|
||
const baseReward = contract.rewardBase || ECONOMY.standardContractBase;
|
||
const penaltyRate = (typeof cfg.latePenaltyOverride === 'number')
|
||
? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour;
|
||
const penalty = Math.max(0, baseReward * penaltyRate * lateHours);
|
||
const onTime = lateHours === 0;
|
||
// Intermodal-Bonus: noEmpty true wenn überall am Übergabe-Ort gestartet
|
||
// (vereinfachte Annahme: Pickup-Drive war bei _stepVehiclePhase pickupRouteId markiert)
|
||
const noEmpty = true; // Intermodal: separate Bonus-Logik möglich, hier vereinfacht
|
||
const bonus = calculateBonus(baseReward, { onTime, noEmpty });
|
||
const net = baseReward + bonus - penalty - (contract.legCostsPaid || 0);
|
||
|
||
// Reward am Ende buchen (Fahrkosten waren schon pro Leg abgezogen)
|
||
game.balance += baseReward + bonus - penalty;
|
||
game.score += Math.max(0, Math.round(net / 100));
|
||
contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED;
|
||
contract.finalBalance = net;
|
||
contract.lateHours = lateHours;
|
||
|
||
if (!game.completedContracts) game.completedContracts = [];
|
||
game.completedContracts.push(contract);
|
||
game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id);
|
||
|
||
if (!game.analytics) game.analytics = {};
|
||
if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1;
|
||
if (!game.pendingNotifications) game.pendingNotifications = [];
|
||
game.pendingNotifications.push({
|
||
id: 'notif_intermodal_' + contract.id,
|
||
type: 'CONTRACT_DELIVERED',
|
||
contractId: contract.id,
|
||
messageShort: 'Intermodaler Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.',
|
||
simTime: arrivalIso,
|
||
net,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schließt einen Vertrag ab: Erlös buchen, Strafen prüfen, Bonus prüfen,
|
||
* Vertrag in completedContracts verschieben.
|
||
* Phase 3: vehicle-Parameter ermöglicht Leerfahrten-Erkennung
|
||
* (pickupRouteId vorhanden = Leerfahrt = noEmpty=false).
|
||
* @private
|
||
*/
|
||
function _completeContract(game, contract, route, vehicle) {
|
||
const cfg = game._config || {};
|
||
const arrivalIso = game.simulationTime;
|
||
contract.unloadEndTime = arrivalIso;
|
||
// Verspätung berechnen
|
||
const dueMs = new Date(contract.dueTime || arrivalIso).getTime();
|
||
const arrMs = new Date(arrivalIso).getTime();
|
||
const lateHours = Math.max(0, (arrMs - dueMs) / (1000 * 60 * 60));
|
||
// Wirtschaft
|
||
const baseReward = contract.rewardBase || ECONOMY.standardContractBase;
|
||
// Fahrkosten = pickup-km + delivery-km (Leerfahrt + Lieferfahrt zählen beide)
|
||
let totalKm = route.totalDistanceKm;
|
||
let totalDur = route.totalDurationMinutes;
|
||
let hadPickupDrive = false;
|
||
if (vehicle && vehicle.pickupRouteId) {
|
||
// Pickup-Route ist schon aus activeRoutes entfernt — wir berechnen
|
||
// approx aus contract+vehicle currentLocation-Diff. Pragmatischer:
|
||
// Pickup-Distanz wurde in analytics.emptyKm gespeichert; nutzen wir die.
|
||
hadPickupDrive = true;
|
||
}
|
||
// Wenn pickup vorhanden: Fahrkosten verdoppelt? Nein, nur die echten km
|
||
// werden gerechnet. Da wir hier nur die Delivery-Route haben, holen wir
|
||
// pickup-km approximativ aus analytics-Diff.
|
||
const fareCost = travelCost(totalKm, totalDur / 60, route.mode);
|
||
// Pickup-Fahrkosten separat (ohne Erlös) — vereinfacht: identische €/km/h
|
||
let pickupFareCost = 0;
|
||
if (hadPickupDrive && contract.pickupKm) {
|
||
const veh = VEHICLE_DEFAULTS[route.mode];
|
||
const pickupHours = contract.pickupKm / veh.speedKmh;
|
||
pickupFareCost = contract.pickupKm * veh.costPerKm + pickupHours * veh.costPerHour;
|
||
}
|
||
const penaltyRate = (typeof cfg.latePenaltyOverride === 'number')
|
||
? cfg.latePenaltyOverride : ECONOMY.latePenaltyPercentPerHour;
|
||
const penalty = Math.max(0, baseReward * penaltyRate * lateHours);
|
||
const onTime = lateHours === 0;
|
||
const noEmpty = !hadPickupDrive; // Phase 3: Leerfahrt erkannt
|
||
const bonus = calculateBonus(baseReward, { onTime, noEmpty });
|
||
const net = baseReward + bonus - fareCost - pickupFareCost - penalty;
|
||
game.balance += net;
|
||
game.score += Math.max(0, Math.round(net / 100));
|
||
contract.state = lateHours > 0 ? CONTRACT_STATE.LATE : CONTRACT_STATE.DELIVERED;
|
||
contract.finalBalance = net;
|
||
contract.lateHours = lateHours;
|
||
// Tracking: Auftrags-Dauer
|
||
const issueMs = new Date(contract.issueTime || arrivalIso).getTime();
|
||
contract.durationHours = (arrMs - issueMs) / (1000 * 60 * 60);
|
||
// In completedContracts verschieben
|
||
if (!game.completedContracts) game.completedContracts = [];
|
||
game.completedContracts.push(contract);
|
||
game.activeContracts = (game.activeContracts || []).filter(x => x.id !== contract.id);
|
||
// Analytics
|
||
if (!game.analytics) game.analytics = {};
|
||
if (lateHours > 0) game.analytics.lateDeliveries = (game.analytics.lateDeliveries || 0) + 1;
|
||
game.analytics.totalRewardEarned = (game.analytics.totalRewardEarned || 0) + baseReward + bonus;
|
||
game.analytics.totalPenaltyPaid = (game.analytics.totalPenaltyPaid || 0) + penalty;
|
||
game.analytics.totalFareCostPaid = (game.analytics.totalFareCostPaid || 0) + fareCost + pickupFareCost;
|
||
// Notification
|
||
if (!game.pendingNotifications) game.pendingNotifications = [];
|
||
game.pendingNotifications.push({
|
||
id: 'notif_' + contract.id,
|
||
type: 'CONTRACT_DELIVERED',
|
||
contractId: contract.id,
|
||
messageShort: 'Auftrag „' + (contract.code || contract.id) + '" abgeschlossen.',
|
||
simTime: arrivalIso,
|
||
net,
|
||
});
|
||
_pushAnalyticsEntry(game, contract, {
|
||
assignedVehicle: vehicle && vehicle.typeId,
|
||
routeDistanceKm: Math.round((totalKm || 0) + (contract.pickupKm || 0)),
|
||
routeMode: route && route.mode,
|
||
});
|
||
// Phase-2-Vorgriff (für „L1 spielbar"): bei <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,
|
||
};
|
||
})();
|