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
+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Logistik Europa — Test-Harness</title>
<style>
body { font-family: 'Segoe UI', system-ui, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #222; background: #f7f5ef; }
h1 { font-size: 1.4rem; margin-bottom: 8px; }
.meta { color: #666; font-size: 0.9rem; margin-bottom: 20px; }
.group { background: #fff; border: 1px solid #e2ddd1; border-radius: 8px; padding: 14px 18px; margin: 14px 0; box-shadow: 0 2px 6px rgba(0,0,0,.04); }
.group h2 { font-size: 1.05rem; margin: 0 0 10px; color: #1f4b37; }
.case { display: grid; grid-template-columns: 20px 1fr; gap: 10px; padding: 4px 0; font-size: 0.9rem; }
.pass { color: #2d6d3d; }
.fail { color: #b32d2d; }
.case code { background: #f0ece3; padding: 1px 4px; border-radius: 3px; font-size: 0.85em; }
.summary { margin-top: 20px; padding: 12px; background: #1f4b37; color: #fff; border-radius: 6px; font-weight: 600; }
.summary.fail { background: #b32d2d; }
</style>
</head>
<body>
<h1>Logistik Europa — Test-Harness</h1>
<p class="meta">Pflichttests aus dem Kickoff-Briefing (Phase 0). Läuft ohne Test-Runner-Pipeline — einfach im Browser öffnen.</p>
<div id="results"></div>
<div id="summary" class="summary">…läuft…</div>
<script src="engine.js"></script>
<script src="headless-runner.js"></script>
<script>
(async function () {
const groups = [];
const pending = [];
let pass = 0, fail = 0;
function group(title, fn) {
const g = { title, cases: [] };
groups.push(g);
const t = {
eq: (actual, expected, label) => {
const ok = _deepEq(actual, expected);
g.cases.push({ ok, label, actual, expected });
ok ? pass++ : fail++;
},
approx: (actual, expected, tol, label) => {
const ok = Math.abs(actual - expected) <= tol;
g.cases.push({ ok, label, actual, expected: `${expected} ±${tol}` });
ok ? pass++ : fail++;
},
truthy: (actual, label) => {
const ok = !!actual;
g.cases.push({ ok, label, actual, expected: 'truthy' });
ok ? pass++ : fail++;
},
};
// fn kann sync oder async sein — beides unterstützen.
const job = (async () => {
try { await fn(t); }
catch (e) {
g.cases.push({ ok: false, label: 'Exception', actual: e.message, expected: '—' });
fail++;
}
})();
pending.push(job);
}
function _deepEq(a, b) {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a && b && typeof a === 'object') {
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
return ka.every(k => _deepEq(a[k], b[k]));
}
return false;
}
/* =========================================================
PFLICHTTESTS 17 aus Kickoff-Briefing §5.3
========================================================= */
group('Test 1 — Fahrkosten (travelCost)', (t) => {
// Kleiner LKW: 300 km × 1.2 €/km + 4.5 h × 25 €/h = 360 + 112.5 = 472.5 €
t.approx(LogistikEngine.travelCost(300, 4.5, 'TRUCK_SMALL'), 472.5, 0.01, '300 km, 4.5 h, TRUCK_SMALL → 472.50 €');
t.approx(LogistikEngine.travelCost(800, 12, 'TRUCK_LARGE'), 800 * 2.5 + 12 * 45, 0.01, '800 km, 12 h, TRUCK_LARGE');
t.approx(LogistikEngine.travelCost(950, 10.5, 'TRAIN'), 950 * 8 + 10.5 * 150, 0.01, '950 km, 10.5 h, TRAIN');
});
group('Test 2 — Route-Interpolation', (t) => {
const line = [[48.20, 16.37], [53.55, 9.97]]; // Wien → Hamburg (Luftlinie)
const mid = LogistikEngine.interpolateAlongPolyline(line, 0.5);
t.approx(mid[0], (48.20 + 53.55) / 2, 0.5, 'Mittelpunkt lat ≈ Schnittmittel');
t.approx(mid[1], (16.37 + 9.97) / 2, 0.5, 'Mittelpunkt lon ≈ Schnittmittel');
const start = LogistikEngine.interpolateAlongPolyline(line, 0);
t.eq(start, line[0], 'progress=0 → Startpunkt');
const end = LogistikEngine.interpolateAlongPolyline(line, 1);
t.eq(end, line[1], 'progress=1 → Endpunkt');
});
group('Test 3 — Strafkosten Verspätung (latePenalty)', (t) => {
// 1000 € Auftrag, 2 h Verspätung → 20% × 2 = 400 €
t.approx(LogistikEngine.latePenalty(1000, 2), 400, 0.01, '1000 €, 2 h spät → 400 €');
t.approx(LogistikEngine.latePenalty(1500, 0.5), 150, 0.01, '1500 €, 0.5 h spät → 150 €');
t.approx(LogistikEngine.latePenalty(1000, 0), 0, 0.01, 'Keine Verspätung → 0 €');
});
group('Test 4 — Bonus-Kombi (calculateBonus)', (t) => {
// 1000 €, pünktlich (+10%) + optimal (+20%) = 300 €
t.approx(LogistikEngine.calculateBonus(1000, { onTime: true, noEmpty: true }), 300, 0.01, 'pünktlich + optimal → +30%');
t.approx(LogistikEngine.calculateBonus(1000, { onTime: true, noEmpty: false }), 100, 0.01, 'nur pünktlich → +10%');
t.approx(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: true }), 200, 0.01, 'nur optimal → +20%');
t.approx(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: false }), 0, 0.01, 'nichts → 0');
});
group('Test 5 — Event-Auswirkung (Phase 5 — skip bis implementiert)', (t) => {
// Placeholder: echte Tests kommen mit Phase-5-Implementation
t.truthy(LogistikEngine.EVENT_RULES.TRAFFIC_ACCIDENT, 'EVENT_RULES.TRAFFIC_ACCIDENT definiert');
t.eq(LogistikEngine.EVENT_RULES.TRAFFIC_ACCIDENT.speedMult, 0.5, 'Unfall halbiert Geschwindigkeit');
});
group('Test 6 — Savegame-Roundtrip (Phase 2 — skip bis implementiert)', (t) => {
// Placeholder: echte Tests mit serialize/deserialize in Phase 2
const game = LogistikEngine.createGame(1);
const json = JSON.stringify(game);
const restored = JSON.parse(json);
t.eq(restored.levelId, game.levelId, 'Roundtrip levelId');
t.eq(restored.balance, game.balance, 'Roundtrip balance');
t.eq(restored.state, game.state, 'Roundtrip state');
});
group('Test 7 — Bahn-Dijkstra (railShortestPath)', (t) => {
// WienMünchenHamburg: 400 + 800 = 1200 km
const graph = {
nodes: [{id:'wien'},{id:'muenchen'},{id:'hamburg'},{id:'rotterdam'},{id:'paris'}],
edges: [
{ from:'wien', to:'muenchen', distanceKm: 400 },
{ from:'muenchen', to:'hamburg', distanceKm: 800 },
{ from:'hamburg', to:'rotterdam', distanceKm: 500 },
],
};
const result = LogistikEngine.railShortestPath(graph, 'wien', 'hamburg');
t.truthy(result, 'Pfad gefunden');
t.eq(result.distanceKm, 1200, 'Wien → Hamburg: 1200 km');
t.eq(result.path, ['wien', 'muenchen', 'hamburg'], 'Pfad über München');
});
/* =========================================================
EDGE-CASES (Atlas-Auflage Phase 0)
========================================================= */
group('Test 8 — Edge-Cases', (t) => {
// 8.1 Leere Polyline → Exception
let threw = false;
try { LogistikEngine.interpolateAlongPolyline([], 0.5); }
catch (e) { threw = true; }
t.truthy(threw, 'leere Polyline → Exception');
// 8.2 Polyline mit nur einem Punkt → Exception
threw = false;
try { LogistikEngine.interpolateAlongPolyline([[0, 0]], 0.5); }
catch (e) { threw = true; }
t.truthy(threw, '1-Punkt-Polyline → Exception');
// 8.3 Dijkstra ohne Verbindung → null
const isolated = {
nodes: [{id:'a'},{id:'b'},{id:'inselA'},{id:'inselB'}],
edges: [
{ from:'a', to:'b', distanceKm: 100 },
{ from:'inselA', to:'inselB', distanceKm: 50 },
],
};
t.eq(LogistikEngine.railShortestPath(isolated, 'a', 'inselB'), null,
'getrennte Komponenten → null');
// 8.4 Dijkstra zum Selbst → distanceKm = 0
const r = LogistikEngine.railShortestPath(isolated, 'a', 'a');
t.truthy(r, 'Pfad zum Selbst gefunden');
t.eq(r.distanceKm, 0, 'Pfad zum Selbst: 0 km');
// 8.5 latePenalty mit negativen Stunden → 0 (keine negative Strafe)
t.eq(LogistikEngine.latePenalty(1000, -2), 0, 'negative Stunden → 0');
// 8.6 latePenalty mit null Wert → 0
t.eq(LogistikEngine.latePenalty(0, 5), 0, 'null Auftragswert → 0');
// 8.7 travelCost mit unbekanntem Modus → Exception
threw = false;
try { LogistikEngine.travelCost(100, 1, 'HELI'); }
catch (e) { threw = true; }
t.truthy(threw, 'unbekannter Modus → Exception');
// 8.8 calculateBonus mit beiden Flags false → 0
t.eq(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: false }), 0,
'keine Flags → kein Bonus');
});
/* =========================================================
SEEDED-RANDOM (Phase 0 — Reproduzierbarkeit)
========================================================= */
group('Test 9 — Seeded-Random (Mulberry32)', (t) => {
t.truthy(typeof LogistikRunner !== 'undefined', 'LogistikRunner geladen');
t.truthy(typeof LogistikRunner.makeRng === 'function', 'makeRng verfügbar');
// 9.1 Gleicher Seed → identische Sequenz
const a = LogistikRunner.makeRng(42);
const b = LogistikRunner.makeRng(42);
const seqA = Array.from({length: 10}, () => a());
const seqB = Array.from({length: 10}, () => b());
t.eq(seqA, seqB, 'Seed 42 → identische Sequenz auf zwei Instanzen');
// 9.2 Unterschiedliche Seeds → unterschiedliche Sequenzen
const c = LogistikRunner.makeRng(99);
const seqC = Array.from({length: 10}, () => c());
let differs = false;
for (let i = 0; i < 10; i++) if (seqA[i] !== seqC[i]) { differs = true; break; }
t.truthy(differs, 'Seed 42 ≠ Seed 99');
// 9.3 Werte im Bereich [0, 1)
t.truthy(seqA.every(v => v >= 0 && v < 1), 'alle Werte in [0, 1)');
// 9.4 Seed 0 funktioniert (wird intern auf 1 normalisiert, kein NaN)
const z = LogistikRunner.makeRng(0);
const v0 = z();
t.truthy(typeof v0 === 'number' && !isNaN(v0), 'Seed 0 liefert valide Zahl');
});
/* =========================================================
RUNNER-VERTRAG (Phase 0 — noop läuft, andere stubben)
========================================================= */
group('Test 10 — Headless-Runner-Vertrag (noop)', async (t) => {
// 10.1 noop-Strategie liefert vollständiges Result
const r = await LogistikRunner.runLevel(1, 1, 'noop');
t.truthy(r, 'runLevel liefert Ergebnis');
t.eq(r.successful, false, 'noop bei L1 → success=false');
t.eq(r.completedContracts, 0, 'noop schließt keine Aufträge ab');
t.eq(r.lateDeliveries, 0, 'noop hat keine Verspätungen');
t.truthy(r.durationHours > 0, 'durationHours > 0');
t.eq(r.strategy, 'noop', 'strategy-Feld gesetzt');
t.eq(r.level, 1, 'level-Feld gesetzt');
t.eq(r.seed, 1, 'seed-Feld gesetzt');
t.truthy(Array.isArray(r.log), 'log ist Array');
// 10.2 Reproduzierbar: gleicher Seed → identisches Ergebnis
const r2 = await LogistikRunner.runLevel(1, 1, 'noop');
t.eq(r.endBalance, r2.endBalance, 'Seed 1 reproduziert Balance');
t.eq(r.durationHours, r2.durationHours, 'Seed 1 reproduziert Dauer');
t.eq(r.log.length, r2.log.length, 'Seed 1 reproduziert log-Länge');
// 10.3 naive/greedy/optimal werfen kontrolliert (Phase 2)
let naiveThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'naive'); }
catch (e) { naiveThrew = /Phase 2/.test(e.message); }
t.truthy(naiveThrew, 'naive wirft kontrolliert "Phase 2"');
// 10.4 Unbekannte Strategie wirft
let unknownThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'fantasy'); }
catch (e) { unknownThrew = true; }
t.truthy(unknownThrew, 'unbekannte Strategie → Exception');
});
/* =========================================================
RENDER (wartet auf alle async-Tests)
========================================================= */
await Promise.all(pending);
const root = document.getElementById('results');
groups.forEach(g => {
const box = document.createElement('div');
box.className = 'group';
box.innerHTML = '<h2>' + g.title + '</h2>';
g.cases.forEach(c => {
const row = document.createElement('div');
row.className = 'case';
row.innerHTML = '<span class="' + (c.ok ? 'pass' : 'fail') + '">' + (c.ok ? '✓' : '✗') + '</span>' +
'<span>' + c.label + (c.ok ? '' : '<br><code>got: ' + JSON.stringify(c.actual) + ' / expected: ' + JSON.stringify(c.expected) + '</code>') + '</span>';
box.appendChild(row);
});
root.appendChild(box);
});
const sum = document.getElementById('summary');
sum.textContent = pass + ' bestanden, ' + fail + ' fehlgeschlagen';
sum.className = 'summary' + (fail ? ' fail' : '');
})();
</script>
</body>
</html>