Files
geograsim/App/sims/logistik/engine.js
T
Adminator 3885c83294 Atlas: Logistik-Gerüst + Heli-Fix + Waypoints-Regen + Level-Picker
- 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>
2026-04-19 20:43:48 +02:00

362 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Logistik Europa — Engine (Skelett)
* ------------------------------------------------------------------
* Headless Simulationslogik ohne DOM, ohne Rendering. Die UI
* (game.html, test.html) ruft Methoden auf und verarbeitet
* zurückgegebene Events selbst.
*
* Exportiert als globales `window.LogistikEngine` (kein ES-Module,
* kein Bundler).
*
* Dieses Skelett enthält:
* - State-Machine-Enum (aus Pflichtenheft Kap 44)
* - Enum-Konstanten für Fahrzeugmodi, Vertrags-/Fahrzeug-Zustände,
* Ereignistypen, Hilfestufen (Kap 42)
* - Platzhalter-Funktionen für createGame, tick, assignContract,
* calculateRoute, useHint, applyMinigameResult
* - Test-Hooks für die Regressionstests aus dem Kickoff-Briefing
*
* Phase 0 (Balance-Matrix + Test-Harness) soll dieses Skelett nutzen,
* aber die Implementierung bleibt bewusst Stub, damit die Tests
* zuerst das Contract testen (TDD-ähnlich).
*/
(function () {
'use strict';
/* ============================================================
ENUMS (aus Pflichtenheft Kap 42, 44)
============================================================ */
const STATE = Object.freeze({
INIT: 'INIT',
LOADING_CONTENT: 'LOADING_CONTENT',
READY: 'READY',
PLANNING: 'PLANNING',
RUNNING: 'RUNNING',
PAUSED_BY_EVENT: 'PAUSED_BY_EVENT',
PAUSED_BY_ARRIVAL: 'PAUSED_BY_ARRIVAL',
MINIGAME: 'MINIGAME',
LEVEL_SUCCESS: 'LEVEL_SUCCESS',
LEVEL_FAILED: 'LEVEL_FAILED',
SAVING: 'SAVING',
ERROR: 'ERROR',
});
const VEHICLE_MODE = Object.freeze({
TRUCK_SMALL: 'TRUCK_SMALL',
TRUCK_LARGE: 'TRUCK_LARGE',
TRAIN: 'TRAIN',
});
const VEHICLE_STATE = Object.freeze({
IDLE: 'IDLE',
RESERVED: 'RESERVED',
MOVING: 'MOVING',
LOADING: 'LOADING',
UNLOADING: 'UNLOADING',
WAITING: 'WAITING',
BLOCKED: 'BLOCKED',
BROKEN: 'BROKEN',
});
const CONTRACT_STATE = Object.freeze({
OPEN: 'OPEN',
RESERVED: 'RESERVED',
ASSIGNED: 'ASSIGNED',
PICKUP_PENDING: 'PICKUP_PENDING',
LOADING: 'LOADING',
IN_TRANSIT: 'IN_TRANSIT',
AT_TRANSFER: 'AT_TRANSFER',
DELIVERED: 'DELIVERED',
LATE: 'LATE',
FAILED: 'FAILED',
CANCELLED: 'CANCELLED',
});
const LOCATION_TYPE = Object.freeze({
CITY: 'CITY',
CAPITAL: 'CAPITAL',
PORT: 'PORT',
TERMINAL: 'TERMINAL',
INDUSTRY: 'INDUSTRY',
CUSTOMER: 'CUSTOMER',
HUB: 'HUB',
});
const EVENT_TYPE = Object.freeze({
TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT',
SNOW: 'SNOW',
STORM: 'STORM',
PORT_DELAY: 'PORT_DELAY',
RAIL_STRIKE: 'RAIL_STRIKE',
SPECIAL_ORDER: 'SPECIAL_ORDER',
MAINTENANCE: 'MAINTENANCE',
});
const HINT_MODE = Object.freeze({
BLINK_EXACT: 'BLINK_EXACT',
SHOW_COUNTRY: 'SHOW_COUNTRY',
SHOW_REGION: 'SHOW_REGION',
DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK',
NONE: 'NONE',
});
/* ============================================================
KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65
Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt
sie ggf. pro Level.
============================================================ */
const VEHICLE_DEFAULTS = Object.freeze({
TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5 },
TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 },
TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 },
});
const ECONOMY = Object.freeze({
standCostContainerPerHour: 10, // Container im Hafen
standCostVehicleIdlePerHour: 5,
latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts
punctualBonusPercent: 0.1, // +10%
optimalBonusPercent: 0.2, // +20% (keine Leerfahrt)
standardContractBase: 1000,
expressContractBase: 1500,
});
const EVENT_RULES = Object.freeze({
TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 },
SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] },
PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 },
});
/* ============================================================
FACTORIES
============================================================ */
/**
* Erzeugt einen neuen Spielzustand für das gegebene Level.
* Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults.
*
* @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 17)
============================================================ */
/** @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,
};
})();