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