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>
This commit is contained in:
2026-04-19 20:43:48 +02:00
parent 3931a701e1
commit 3885c83294
54 changed files with 5717 additions and 71 deletions
+361
View File
@@ -0,0 +1,361 @@
/**
* 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,
};
})();