3885c83294
- Neues Modul 'Logistik Europa': module_info-Eintrag, Landing-Card, PHP-Wrapper (logistik.php, modul-logistik.php), Engine-Skelett mit Enums + Helpern (travelCost, latePenalty, Bonus, Dijkstra, Polyline-Interpolation), Test-Harness, Kompetenzen-Draft, 5 Seed-Dateien (locations, vehicle-types, cargo-types, railnet, contract-templates), 3 Level-Einträge in game_levels, Phase 0 durch die Logistik-Instanz geliefert und Atlas-Review bestanden - Heli-Fix: heli-game.php mit BASE_PATH (production-sicher) + Asset-Pfad-Injection, geo_waypoints-Tabelle per Seed-Script auffindbar gemacht - Waypoints-Regen-Tool: regen-waypoints-sql.php synchronisiert waypoints.sql aus DB (70 Einträge) - Design-System: .ggs-level-grid / .ggs-level-card als Standard gepromotet (Klimas Muster, iPad-hover-safe) - Inbox-Nachrichten: Kickoff-Briefings für Heli und Logistik, Asset-Map für Heli, DALL-E-Pipeline-Anleitung, Koordinations- nachrichten an Fluss, Klima, Glossar, Lehrplan Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
/**
|
||
* Logistik Europa — Engine (Skelett)
|
||
* ------------------------------------------------------------------
|
||
* Headless Simulationslogik ohne DOM, ohne Rendering. Die UI
|
||
* (game.html, test.html) ruft Methoden auf und verarbeitet
|
||
* zurückgegebene Events selbst.
|
||
*
|
||
* Exportiert als globales `window.LogistikEngine` (kein ES-Module,
|
||
* kein Bundler).
|
||
*
|
||
* Dieses Skelett enthält:
|
||
* - State-Machine-Enum (aus Pflichtenheft Kap 44)
|
||
* - Enum-Konstanten für Fahrzeugmodi, Vertrags-/Fahrzeug-Zustände,
|
||
* Ereignistypen, Hilfestufen (Kap 42)
|
||
* - Platzhalter-Funktionen für createGame, tick, assignContract,
|
||
* calculateRoute, useHint, applyMinigameResult
|
||
* - Test-Hooks für die Regressionstests aus dem Kickoff-Briefing
|
||
*
|
||
* Phase 0 (Balance-Matrix + Test-Harness) soll dieses Skelett nutzen,
|
||
* aber die Implementierung bleibt bewusst Stub, damit die Tests
|
||
* zuerst das Contract testen (TDD-ähnlich).
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ============================================================
|
||
ENUMS (aus Pflichtenheft Kap 42, 44)
|
||
============================================================ */
|
||
|
||
const STATE = Object.freeze({
|
||
INIT: 'INIT',
|
||
LOADING_CONTENT: 'LOADING_CONTENT',
|
||
READY: 'READY',
|
||
PLANNING: 'PLANNING',
|
||
RUNNING: 'RUNNING',
|
||
PAUSED_BY_EVENT: 'PAUSED_BY_EVENT',
|
||
PAUSED_BY_ARRIVAL: 'PAUSED_BY_ARRIVAL',
|
||
MINIGAME: 'MINIGAME',
|
||
LEVEL_SUCCESS: 'LEVEL_SUCCESS',
|
||
LEVEL_FAILED: 'LEVEL_FAILED',
|
||
SAVING: 'SAVING',
|
||
ERROR: 'ERROR',
|
||
});
|
||
|
||
const VEHICLE_MODE = Object.freeze({
|
||
TRUCK_SMALL: 'TRUCK_SMALL',
|
||
TRUCK_LARGE: 'TRUCK_LARGE',
|
||
TRAIN: 'TRAIN',
|
||
});
|
||
|
||
const VEHICLE_STATE = Object.freeze({
|
||
IDLE: 'IDLE',
|
||
RESERVED: 'RESERVED',
|
||
MOVING: 'MOVING',
|
||
LOADING: 'LOADING',
|
||
UNLOADING: 'UNLOADING',
|
||
WAITING: 'WAITING',
|
||
BLOCKED: 'BLOCKED',
|
||
BROKEN: 'BROKEN',
|
||
});
|
||
|
||
const CONTRACT_STATE = Object.freeze({
|
||
OPEN: 'OPEN',
|
||
RESERVED: 'RESERVED',
|
||
ASSIGNED: 'ASSIGNED',
|
||
PICKUP_PENDING: 'PICKUP_PENDING',
|
||
LOADING: 'LOADING',
|
||
IN_TRANSIT: 'IN_TRANSIT',
|
||
AT_TRANSFER: 'AT_TRANSFER',
|
||
DELIVERED: 'DELIVERED',
|
||
LATE: 'LATE',
|
||
FAILED: 'FAILED',
|
||
CANCELLED: 'CANCELLED',
|
||
});
|
||
|
||
const LOCATION_TYPE = Object.freeze({
|
||
CITY: 'CITY',
|
||
CAPITAL: 'CAPITAL',
|
||
PORT: 'PORT',
|
||
TERMINAL: 'TERMINAL',
|
||
INDUSTRY: 'INDUSTRY',
|
||
CUSTOMER: 'CUSTOMER',
|
||
HUB: 'HUB',
|
||
});
|
||
|
||
const EVENT_TYPE = Object.freeze({
|
||
TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT',
|
||
SNOW: 'SNOW',
|
||
STORM: 'STORM',
|
||
PORT_DELAY: 'PORT_DELAY',
|
||
RAIL_STRIKE: 'RAIL_STRIKE',
|
||
SPECIAL_ORDER: 'SPECIAL_ORDER',
|
||
MAINTENANCE: 'MAINTENANCE',
|
||
});
|
||
|
||
const HINT_MODE = Object.freeze({
|
||
BLINK_EXACT: 'BLINK_EXACT',
|
||
SHOW_COUNTRY: 'SHOW_COUNTRY',
|
||
SHOW_REGION: 'SHOW_REGION',
|
||
DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK',
|
||
NONE: 'NONE',
|
||
});
|
||
|
||
/* ============================================================
|
||
KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65
|
||
Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt
|
||
sie ggf. pro Level.
|
||
============================================================ */
|
||
|
||
const VEHICLE_DEFAULTS = Object.freeze({
|
||
TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5 },
|
||
TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 },
|
||
TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 },
|
||
});
|
||
|
||
const ECONOMY = Object.freeze({
|
||
standCostContainerPerHour: 10, // Container im Hafen
|
||
standCostVehicleIdlePerHour: 5,
|
||
latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts
|
||
punctualBonusPercent: 0.1, // +10%
|
||
optimalBonusPercent: 0.2, // +20% (keine Leerfahrt)
|
||
standardContractBase: 1000,
|
||
expressContractBase: 1500,
|
||
});
|
||
|
||
const EVENT_RULES = Object.freeze({
|
||
TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 },
|
||
SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] },
|
||
PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 },
|
||
});
|
||
|
||
/* ============================================================
|
||
FACTORIES
|
||
============================================================ */
|
||
|
||
/**
|
||
* Erzeugt einen neuen Spielzustand für das gegebene Level.
|
||
* Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults.
|
||
*
|
||
* @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,
|
||
};
|
||
})();
|