/** * Logistik Europa — Engine (Skelett) * ------------------------------------------------------------------ * Headless Simulationslogik ohne DOM, ohne Rendering. Die UI * (game.html, test.html) ruft Methoden auf und verarbeitet * zurückgegebene Events selbst. * * Exportiert als globales `window.LogistikEngine` (kein ES-Module, * kein Bundler). * * Dieses Skelett enthält: * - State-Machine-Enum (aus Pflichtenheft Kap 44) * - Enum-Konstanten für Fahrzeugmodi, Vertrags-/Fahrzeug-Zustände, * Ereignistypen, Hilfestufen (Kap 42) * - Platzhalter-Funktionen für createGame, tick, assignContract, * calculateRoute, useHint, applyMinigameResult * - Test-Hooks für die Regressionstests aus dem Kickoff-Briefing * * Phase 0 (Balance-Matrix + Test-Harness) soll dieses Skelett nutzen, * aber die Implementierung bleibt bewusst Stub, damit die Tests * zuerst das Contract testen (TDD-ähnlich). */ (function () { 'use strict'; /* ============================================================ ENUMS (aus Pflichtenheft Kap 42, 44) ============================================================ */ const STATE = Object.freeze({ INIT: 'INIT', LOADING_CONTENT: 'LOADING_CONTENT', READY: 'READY', PLANNING: 'PLANNING', RUNNING: 'RUNNING', PAUSED_BY_EVENT: 'PAUSED_BY_EVENT', PAUSED_BY_ARRIVAL: 'PAUSED_BY_ARRIVAL', MINIGAME: 'MINIGAME', LEVEL_SUCCESS: 'LEVEL_SUCCESS', LEVEL_FAILED: 'LEVEL_FAILED', SAVING: 'SAVING', ERROR: 'ERROR', }); const VEHICLE_MODE = Object.freeze({ TRUCK_SMALL: 'TRUCK_SMALL', TRUCK_LARGE: 'TRUCK_LARGE', TRAIN: 'TRAIN', }); const VEHICLE_STATE = Object.freeze({ IDLE: 'IDLE', RESERVED: 'RESERVED', MOVING: 'MOVING', LOADING: 'LOADING', UNLOADING: 'UNLOADING', WAITING: 'WAITING', BLOCKED: 'BLOCKED', BROKEN: 'BROKEN', }); const CONTRACT_STATE = Object.freeze({ OPEN: 'OPEN', RESERVED: 'RESERVED', ASSIGNED: 'ASSIGNED', PICKUP_PENDING: 'PICKUP_PENDING', LOADING: 'LOADING', IN_TRANSIT: 'IN_TRANSIT', AT_TRANSFER: 'AT_TRANSFER', DELIVERED: 'DELIVERED', LATE: 'LATE', FAILED: 'FAILED', CANCELLED: 'CANCELLED', }); const LOCATION_TYPE = Object.freeze({ CITY: 'CITY', CAPITAL: 'CAPITAL', PORT: 'PORT', TERMINAL: 'TERMINAL', INDUSTRY: 'INDUSTRY', CUSTOMER: 'CUSTOMER', HUB: 'HUB', }); const EVENT_TYPE = Object.freeze({ TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT', SNOW: 'SNOW', STORM: 'STORM', PORT_DELAY: 'PORT_DELAY', RAIL_STRIKE: 'RAIL_STRIKE', SPECIAL_ORDER: 'SPECIAL_ORDER', MAINTENANCE: 'MAINTENANCE', }); const HINT_MODE = Object.freeze({ BLINK_EXACT: 'BLINK_EXACT', SHOW_COUNTRY: 'SHOW_COUNTRY', SHOW_REGION: 'SHOW_REGION', DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK', NONE: 'NONE', }); /* ============================================================ KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65 Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt sie ggf. pro Level. ============================================================ */ const VEHICLE_DEFAULTS = Object.freeze({ TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5 }, TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 }, TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 }, }); const ECONOMY = Object.freeze({ standCostContainerPerHour: 10, // Container im Hafen standCostVehicleIdlePerHour: 5, latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts punctualBonusPercent: 0.1, // +10% optimalBonusPercent: 0.2, // +20% (keine Leerfahrt) standardContractBase: 1000, expressContractBase: 1500, }); const EVENT_RULES = Object.freeze({ TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 }, SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] }, PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 }, }); /* ============================================================ FACTORIES ============================================================ */ /** * Erzeugt einen neuen Spielzustand für das gegebene Level. * Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults. * * @param {number} levelNum * @returns {Object} gameState */ function createGame(levelNum) { const levelCfg = (window.LOGISTIK_LEVELS || []).find(l => l.level === levelNum)?.params || {}; return { sessionId: _uuid(), levelId: 'level_' + levelNum, state: STATE.INIT, simulationTime: new Date().toISOString(), timeScale: levelCfg.initialTimeScale ?? 1, paused: true, balance: levelCfg.startBudget ?? 5000, score: 0, activeContracts: [], completedContracts: [], vehicles: [], activeRoutes: [], activeEvents: [], pendingNotifications: [], usedHints: [], analytics: { totalKm: 0, emptyKm: 0, lateDeliveries: 0, hintUsages: 0, misclicks: 0, }, worldState: {}, version: '0.1.0', _pendingEvents: [], _config: levelCfg, }; } /* ============================================================ PLACEHOLDER / STUB FUNKTIONEN (Logistik-Instanz implementiert diese in Phase 1–7) ============================================================ */ /** @throws nicht implementiert — Phase 2 */ function tick(game, deltaMs) { throw new Error('LogistikEngine.tick: nicht implementiert (Phase 2)'); } /** @throws nicht implementiert — Phase 2 */ function assignContract(game, contractId, vehicleId) { throw new Error('LogistikEngine.assignContract: nicht implementiert (Phase 2)'); } /** @throws nicht implementiert — Phase 2/3 */ function calculateRoute(game, originId, targetId, mode) { throw new Error('LogistikEngine.calculateRoute: nicht implementiert (Phase 2)'); } /** @throws nicht implementiert — Phase 5 */ function useHint(game, contractId, hintMode) { throw new Error('LogistikEngine.useHint: nicht implementiert (Phase 5)'); } /** @throws nicht implementiert — Phase 6 */ function applyMinigameResult(game, result) { throw new Error('LogistikEngine.applyMinigameResult: nicht implementiert (Phase 6)'); } /* ============================================================ 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 ============================================================ */ function _uuid() { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); } function _segmentLength(a, b) { const [lat1, lon1] = a; const [lat2, lon2] = b; const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); } function _polylineLength(poly) { let sum = 0; for (let i = 0; i < poly.length - 1; i++) sum += _segmentLength(poly[i], poly[i + 1]); return sum; } /* ============================================================ EXPORT ============================================================ */ window.LogistikEngine = { // Enums STATE, VEHICLE_MODE, VEHICLE_STATE, CONTRACT_STATE, LOCATION_TYPE, EVENT_TYPE, HINT_MODE, // Konstanten VEHICLE_DEFAULTS, ECONOMY, EVENT_RULES, // Factories createGame, // Stubs (Phase 1+) tick, assignContract, calculateRoute, useHint, applyMinigameResult, // Helper (Phase 0 testbar) travelCost, latePenalty, calculateBonus, interpolateAlongPolyline, railShortestPath, }; })();