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:
@@ -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 1–7 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) => {
|
||||
// Wien–München–Hamburg: 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>
|
||||
Reference in New Issue
Block a user