fc8996f064
Platform: - OSM-DE als dritter Tile-Proxy-Provider (deutsche Beschriftungen) - logistik.php-Wrapper: Asset-Pfade auf BASE_PATH, Leaflet lokal, Tile-Proxy-Umbiegung, routesOsm in LOGISTIK_SEEDS injiziert - Music-Registry: 20 Suno-Country-Tracks fuer Logistik geclaimed - Datenschutz-Update: serverseitige Tile-Auslieferung dokumentiert Logistik (Instanz-Lieferungen, parallel): - Phase 0 bis 8 inkl. Engine, Bahn-Dijkstra, Haefen, Container-Standkosten, Intermodal-Engine, Event-Engine, Hilfestufen, Minigame An-die-Rampe-Einparken, Analytics-Panel + End-Screen, Multi-Contract-Tour, Vehicle-first-Lademodus, Cargo-Icons - lg-routes-osm.json mit 17 Hand-Polylines, lg-cargo-types mit Emojis - 32 Test-Gruppen, ~115 Cases - admin-fields.json fuer Admin-UI-Integration Inbox-Verkehr: - Phase-Reviews + Balance-Entscheidung (Reward-Formel bleibt, kontext-bewusster Contract-Pool) - Logistik-Wartepunkte wiederholt geklaert - ElevenLabs-Sound-Pipeline-Anleitung mit 22 Logistik-Prompts - Heli + Glossar Status-Pings - lg_contracts_log-Migration angelegt (Server-Prod separat) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1011 lines
49 KiB
HTML
1011 lines
49 KiB
HTML
<!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) => {
|
||
// Vollständiges Bahnnetz aus lg-railnet.json (Atlas-bestätigt 2026-04-20)
|
||
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 },
|
||
{ from:'paris', to:'rotterdam', distanceKm: 520 },
|
||
{ from:'paris', to:'muenchen', distanceKm: 820 },
|
||
],
|
||
};
|
||
// 7.1 Wien → Hamburg via München
|
||
const r1 = LogistikEngine.railShortestPath(graph, 'wien', 'hamburg');
|
||
t.truthy(r1, 'Wien→Hamburg: Pfad gefunden');
|
||
t.eq(r1.distanceKm, 1200, 'Wien → Hamburg: 1200 km');
|
||
t.eq(r1.path, ['wien', 'muenchen', 'hamburg'], 'Pfad über München');
|
||
|
||
// 7.2 Wien → Paris via München (Umweg-Validierung)
|
||
const r2 = LogistikEngine.railShortestPath(graph, 'wien', 'paris');
|
||
t.truthy(r2, 'Wien→Paris: Pfad gefunden');
|
||
t.eq(r2.distanceKm, 1220, 'Wien → Paris: 1220 km (via München)');
|
||
t.eq(r2.path, ['wien', 'muenchen', 'paris'], 'Pfad über München, nicht über Hamburg');
|
||
|
||
// 7.3 Wien → Rotterdam: kürzerer Weg via Hamburg statt via Paris
|
||
const r3 = LogistikEngine.railShortestPath(graph, 'wien', 'rotterdam');
|
||
t.truthy(r3, 'Wien→Rotterdam: Pfad gefunden');
|
||
t.eq(r3.distanceKm, 1700, 'Wien → Rotterdam: 1700 km (via München+Hamburg, kürzer als 1740 via Paris)');
|
||
});
|
||
|
||
/* =========================================================
|
||
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 greedy/optimal werfen kontrolliert (Phase 2 noch Stub)
|
||
// naive ist seit Phase 2 implementiert — Test 17 prüft naive separat.
|
||
let greedyThrew = false;
|
||
try { await LogistikRunner.runLevel(1, 1, 'greedy'); }
|
||
catch (e) { greedyThrew = /Phase 2/.test(e.message); }
|
||
t.truthy(greedyThrew, 'greedy 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');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 1 — Lifecycle, Tick, seeded reproduzierbar
|
||
========================================================= */
|
||
|
||
group('Test 11 — Phase 1: Lifecycle + seeded Tick reproduzierbar', (t) => {
|
||
const E = LogistikEngine;
|
||
|
||
// 11.1 createGame deterministic → reproduzierbare sessionId
|
||
const g1 = E.createGame(1, { deterministic: true, seed: 7 });
|
||
const g2 = E.createGame(1, { deterministic: true, seed: 7 });
|
||
t.eq(g1.sessionId, g2.sessionId, 'Seed 7 → identische sessionId');
|
||
t.eq(g1.simulationTime, g2.simulationTime, 'Seed → identische simulationStartTime');
|
||
|
||
// 11.2 State-Übergänge INIT → LOADING_CONTENT → READY
|
||
const seeds = { locations: [{id:'a', name:'A', lat:0, lon:0, type:'CAPITAL', visibleFromLevel:1}], railnet: { nodes:[], edges:[] } };
|
||
t.eq(g1.state, 'INIT', 'Start-State INIT');
|
||
E.loadContent(g1, seeds);
|
||
t.eq(g1.state, 'READY', 'Nach loadContent: READY');
|
||
t.truthy(Array.isArray(g1.worldState.locations), 'worldState.locations ist Array');
|
||
|
||
// 11.3 startPlanning + startSimulation
|
||
E.startPlanning(g1);
|
||
t.eq(g1.state, 'PLANNING', 'Nach startPlanning: PLANNING');
|
||
E.startSimulation(g1);
|
||
t.eq(g1.state, 'RUNNING', 'Nach startSimulation: RUNNING');
|
||
t.eq(g1.paused, false, 'paused=false nach Start');
|
||
|
||
// 11.4 tick erhöht Sim-Zeit (1s real bei 1× = 1 min sim)
|
||
const t0 = new Date(g1.simulationTime).getTime();
|
||
E.tick(g1, 1000);
|
||
const t1 = new Date(g1.simulationTime).getTime();
|
||
t.approx((t1 - t0) / 60000, 1.0, 0.001, 'tick(1000ms, scale=1) → +1 Sim-Min');
|
||
|
||
// 11.5 setTimeScale × 4 → 4× Sim-Zeit
|
||
E.setTimeScale(g1, 4);
|
||
const t2 = new Date(g1.simulationTime).getTime();
|
||
E.tick(g1, 1000);
|
||
const t3 = new Date(g1.simulationTime).getTime();
|
||
t.approx((t3 - t2) / 60000, 4.0, 0.001, 'tick(1000ms, scale=4) → +4 Sim-Min');
|
||
|
||
// 11.6 pause blockiert tick
|
||
E.pause(g1);
|
||
const t4 = new Date(g1.simulationTime).getTime();
|
||
E.tick(g1, 1000);
|
||
const t5 = new Date(g1.simulationTime).getTime();
|
||
t.eq(t4, t5, 'tick im pause → keine Zeit-Änderung');
|
||
E.resume(g1);
|
||
|
||
// 11.7 Reproduzierbarkeit: zwei Instanzen, identische Tick-Folge → identische Sim-Zeit
|
||
const a = E.createGame(1, { deterministic: true, seed: 99 });
|
||
const b = E.createGame(1, { deterministic: true, seed: 99 });
|
||
E.loadContent(a, seeds); E.startPlanning(a); E.startSimulation(a);
|
||
E.loadContent(b, seeds); E.startPlanning(b); E.startSimulation(b);
|
||
for (let i = 0; i < 50; i++) { E.tick(a, 100); E.tick(b, 100); }
|
||
t.eq(a.simulationTime, b.simulationTime, 'Seed 99 + 50× tick → identische simulationTime');
|
||
t.eq(a.state, b.state, 'Seed 99 → identischer state');
|
||
|
||
// 11.8 onStateChange-Listener wird gerufen
|
||
const c = E.createGame(1, { deterministic: true, seed: 1 });
|
||
const events = [];
|
||
E.onStateChange(c, (evt) => events.push(evt.from + '→' + evt.to));
|
||
E.loadContent(c, seeds);
|
||
E.startPlanning(c);
|
||
E.startSimulation(c);
|
||
t.eq(events, ['INIT→LOADING_CONTENT', 'LOADING_CONTENT→READY', 'READY→PLANNING', 'PLANNING→RUNNING'],
|
||
'onStateChange protokolliert alle Übergänge');
|
||
|
||
// 11.9 Zeitlimit erreicht → LEVEL_FAILED
|
||
const d = E.createGame(1, { deterministic: true, seed: 5 });
|
||
d._config = { timeLimitHours: 0.001 }; // ~3.6 Sek Sim-Zeit
|
||
E.loadContent(d, seeds); E.startPlanning(d); E.startSimulation(d);
|
||
E.tick(d, 5000); // 5 Sek real bei 1× = 5 Min sim → > 3.6 Sek = > 0.001 h
|
||
t.eq(d.state, 'LEVEL_FAILED', 'Zeitlimit überschritten → LEVEL_FAILED');
|
||
|
||
// 11.10 serialize/deserialize Roundtrip (Plattform 7b)
|
||
const e = E.createGame(2, { deterministic: true, seed: 42 });
|
||
E.loadContent(e, seeds); E.startPlanning(e); E.startSimulation(e);
|
||
E.tick(e, 2500);
|
||
const json = JSON.stringify(E.serialize(e));
|
||
const restored = E.deserialize(json);
|
||
t.eq(restored.sessionId, e.sessionId, 'Roundtrip sessionId');
|
||
t.eq(restored.simulationTime, e.simulationTime, 'Roundtrip simulationTime');
|
||
t.eq(restored.state, e.state, 'Roundtrip state');
|
||
t.truthy(Array.isArray(restored._stateChangeListeners), 'Listener-Array nach deserialize wieder da');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 2 — calculateRoute, assignContract, Vehicle-Bewegung,
|
||
Delivery, Wirtschaft, naive-Strategie schafft L1
|
||
========================================================= */
|
||
|
||
// Mini-Seeds für Phase-2-Tests (Wien + Salzburg, keine Bahn)
|
||
const phase2Seeds = {
|
||
locations: [
|
||
{ id: 'wien', name: 'Wien', countryId: 'AT', regionId: 'central_europe', type: 'CAPITAL', lat: 48.2082, lon: 16.3738, visibleFromLevel: 1 },
|
||
{ id: 'salzburg', name: 'Salzburg', countryId: 'AT', regionId: 'central_europe', type: 'CITY', lat: 47.8095, lon: 13.0550, visibleFromLevel: 1 },
|
||
],
|
||
railnet: { nodes: [], edges: [] },
|
||
vehicleTypes: [], cargoTypes: [],
|
||
};
|
||
|
||
function makePhase2Game(level) {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(level || 1, { deterministic: true, seed: 11 });
|
||
// L1-Default-Config aus balance-matrix
|
||
g._config = {
|
||
startBudget: 8000, maxActiveContracts: 1,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 1,
|
||
deadlineMultiplier: 1.5, defaultHintMode: 'BLINK_EXACT',
|
||
eventProbabilityMultiplier: 0, latePenaltyOverride: 0.10,
|
||
rentalCostPerDay: 0, idleCostPerHour: 0,
|
||
minTargetEarnings: 3000, timeLimitHours: 24,
|
||
initialTimeScale: 1, railEnabled: false, portsEnabled: false,
|
||
visibleLocationLevel: 1,
|
||
};
|
||
g.balance = g._config.startBudget;
|
||
E.loadContent(g, phase2Seeds);
|
||
E.seedInitialVehicles(g);
|
||
E.seedInitialContracts(g);
|
||
return g;
|
||
}
|
||
|
||
group('Test 12 — Phase 2: calculateRoute (Luftlinie × 1.3)', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase2Game(1);
|
||
const r = E.calculateRoute(g, 'wien', 'salzburg', 'TRUCK_SMALL');
|
||
t.truthy(r, 'Route berechnet');
|
||
// Wien-Salzburg airline ≈ 250.8 km, × 1.3 ≈ 326.1 km
|
||
t.approx(r.totalDistanceKm, 326, 2, 'Distanz ≈ 326 km (250.8 × 1.3)');
|
||
// 326 km / 70 km/h = 4.66 h = ca. 280 min
|
||
t.approx(r.totalDurationMinutes, 280, 5, 'Dauer ≈ 280 min @ 70 km/h');
|
||
t.eq(r.geometry.length, 2, 'Geometrie: 2 Punkte (Phase 2: Luftlinie)');
|
||
t.eq(r.mode, 'TRUCK_SMALL', 'Mode bleibt erhalten');
|
||
t.eq(r.provider, 'airline_x1.3', 'Provider markiert');
|
||
});
|
||
|
||
group('Test 13 — Phase 2: assignContract', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase2Game(1);
|
||
t.truthy(g.activeContracts.length === 1, 'Initial 1 Auftrag');
|
||
t.truthy(g.vehicles.length === 1, 'Initial 1 Fahrzeug');
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
t.eq(c.state, 'OPEN', 'Auftrag initial OPEN');
|
||
t.eq(v.state, 'IDLE', 'Fahrzeug initial IDLE');
|
||
|
||
const res = E.assignContract(g, c.id, v.id);
|
||
t.eq(c.state, 'IN_TRANSIT', 'Nach assign: IN_TRANSIT');
|
||
t.eq(v.state, 'MOVING', 'Nach assign: MOVING');
|
||
t.truthy(g.activeRoutes.length === 1, 'activeRoutes hat eine Route');
|
||
t.eq(v.assignedContractId, c.id, 'Vehicle kennt Contract');
|
||
|
||
// Doppelte Zuweisung muss fehlschlagen
|
||
let threw = false;
|
||
try { E.assignContract(g, c.id, v.id); } catch (e) { threw = true; }
|
||
t.truthy(threw, 'doppelte Zuweisung wirft (Auftrag nicht OPEN)');
|
||
});
|
||
|
||
group('Test 14 — Phase 2: tick bewegt Vehicle entlang Route', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase2Game(1);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
E.assignContract(g, c.id, v.id);
|
||
const startLat = v.lat;
|
||
// Tick 60 Min Sim → bei 4.66h Gesamtdauer = ~21% Progress
|
||
E.tick(g, 60 * 1000); // 60s real bei 1× = 60 min sim
|
||
t.truthy(v.segmentProgress > 0 && v.segmentProgress < 1, 'segmentProgress strikt zwischen 0 und 1');
|
||
t.truthy(v.lat !== startLat, 'Position hat sich geändert');
|
||
t.eq(v.state, 'MOVING', 'noch MOVING (Ziel nicht erreicht)');
|
||
});
|
||
|
||
group('Test 15 — Phase 2: Delivery + Erlös + Folge-Auftrag', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase2Game(1);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
const balanceBefore = g.balance;
|
||
E.assignContract(g, c.id, v.id);
|
||
// Tick lange genug, um anzukommen (4.66h * 60 = 280 min sim → 280s real bei 1×)
|
||
E.tick(g, 300 * 1000);
|
||
t.eq(v.state, 'IDLE', 'Vehicle nach Ankunft: IDLE');
|
||
t.eq(c.state, 'DELIVERED', 'Contract nach Ankunft: DELIVERED');
|
||
t.truthy(g.completedContracts.length === 1, '1 Auftrag in completedContracts');
|
||
t.truthy(g.balance > balanceBefore, 'Balance nach Delivery gestiegen (positiver Net)');
|
||
t.truthy((g.activeContracts || []).length === 1, 'Folge-Auftrag automatisch generiert');
|
||
// Notification
|
||
t.truthy(g.pendingNotifications.length >= 1, 'Notification CONTRACT_DELIVERED erzeugt');
|
||
t.eq(g.pendingNotifications[0].type, 'CONTRACT_DELIVERED', 'Notification-Typ stimmt');
|
||
});
|
||
|
||
group('Test 16 — Phase 2: latePenaltyOverride wird respektiert', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase2Game(1);
|
||
g._config.latePenaltyOverride = 0.10; // L1: 10%
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
// Frist künstlich auf 1 min ab jetzt setzen, damit garantiert verspätet
|
||
const due = new Date(g.simulationTime); due.setMinutes(due.getMinutes() + 1);
|
||
c.dueTime = due.toISOString();
|
||
E.assignContract(g, c.id, v.id);
|
||
E.tick(g, 300 * 1000);
|
||
t.eq(c.state, 'LATE', 'Contract als LATE markiert');
|
||
t.truthy(c.lateHours > 0, 'lateHours > 0');
|
||
t.truthy((g.analytics && g.analytics.lateDeliveries) === 1, 'analytics.lateDeliveries++');
|
||
});
|
||
|
||
group('Test 17 — Phase 2: naive-Strategie schafft L1 (Headless)', async (t) => {
|
||
const r = await LogistikRunner.runLevel(1, 1, 'naive', { seeds: phase2Seeds });
|
||
t.truthy(r, 'naive liefert Result');
|
||
t.truthy(r.completedContracts >= 4, 'mindestens 4 Aufträge in 24h geschafft');
|
||
t.truthy(r.endBalance > 8000, 'endBalance > startBudget (8000)');
|
||
// Akzeptanzkorridor balance-matrix.md §3: naive L1 ≥ +1000€ Profit
|
||
t.truthy((r.endBalance - 8000) >= 1000, 'Profit ≥ +1000 € (Akzeptanzkorridor L1 naive)');
|
||
t.eq(r.successful, true, 'success=true (Min-Ziel 3000 € erreicht)');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 3 — Polyline-Lookup, Pickup-Drive, Standkosten,
|
||
Auftrags-Variation, greedy-Strategie
|
||
========================================================= */
|
||
|
||
// Phase-3-Seeds: zusätzlich routesOsm + Mehr Locations
|
||
const phase3Seeds = {
|
||
locations: [
|
||
{ id: 'wien', name: 'Wien', countryId: 'AT', regionId: 'central_europe', type: 'CAPITAL', lat: 48.2082, lon: 16.3738, visibleFromLevel: 1 },
|
||
{ id: 'salzburg', name: 'Salzburg', countryId: 'AT', regionId: 'central_europe', type: 'CITY', lat: 47.8095, lon: 13.0550, visibleFromLevel: 1 },
|
||
{ id: 'muenchen', name: 'München', countryId: 'DE', regionId: 'central_europe', type: 'CAPITAL', lat: 48.1351, lon: 11.5820, visibleFromLevel: 1 },
|
||
{ id: 'hamburg', name: 'Hamburg', countryId: 'DE', regionId: 'central_europe', type: 'CITY', lat: 53.5511, lon: 9.9937, visibleFromLevel: 1 },
|
||
{ id: 'berlin', name: 'Berlin', countryId: 'DE', regionId: 'central_europe', type: 'CAPITAL', lat: 52.5200, lon: 13.4050, visibleFromLevel: 1 },
|
||
],
|
||
railnet: { nodes: [], edges: [] },
|
||
routesOsm: [
|
||
{ id: 'wien-salzburg', originLocationId: 'wien', targetLocationId: 'salzburg', polyline: [[48.2082,16.3738],[48.205,15.616],[48.307,14.286],[47.81,13.055]] },
|
||
{ id: 'wien-muenchen', originLocationId: 'wien', targetLocationId: 'muenchen', polyline: [[48.2082,16.3738],[48.205,15.616],[48.307,14.286],[47.81,13.055],[47.85,12.13],[48.1351,11.582]] },
|
||
{ id: 'salzburg-muenchen', originLocationId: 'salzburg', targetLocationId: 'muenchen', polyline: [[47.8095,13.055],[47.85,12.13],[48.1351,11.582]] },
|
||
],
|
||
vehicleTypes: [], cargoTypes: [],
|
||
};
|
||
|
||
function makePhase3Game(level, seed) {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(level || 1, { deterministic: true, seed: seed || 11 });
|
||
g._config = level === 2
|
||
? { startBudget: 5000, maxActiveContracts: 3, availableVehicleTypes: ['TRUCK_SMALL','TRUCK_LARGE','TRAIN'], vehicleCount: 3,
|
||
deadlineMultiplier: 1.2, defaultHintMode: 'SHOW_REGION', eventProbabilityMultiplier: 0.5,
|
||
latePenaltyOverride: 0.20, rentalCostPerDay: 200, idleCostPerHour: 5,
|
||
minTargetEarnings: 10000, timeLimitHours: 48, initialTimeScale: 1, visibleLocationLevel: 2 }
|
||
: { startBudget: 8000, maxActiveContracts: 1, availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 1,
|
||
deadlineMultiplier: 1.5, defaultHintMode: 'BLINK_EXACT', eventProbabilityMultiplier: 0,
|
||
latePenaltyOverride: 0.10, rentalCostPerDay: 0, idleCostPerHour: 0,
|
||
minTargetEarnings: 3000, timeLimitHours: 24, initialTimeScale: 1, visibleLocationLevel: 1 };
|
||
g.balance = g._config.startBudget;
|
||
E.loadContent(g, phase3Seeds);
|
||
E.seedInitialVehicles(g);
|
||
E.seedInitialContracts(g);
|
||
return g;
|
||
}
|
||
|
||
group('Test 18 — Phase 3: calculateRoute mit Polyline-Lookup', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase3Game(1);
|
||
const r = E.calculateRoute(g, 'wien', 'salzburg', 'TRUCK_SMALL');
|
||
t.eq(r.provider, 'osm_polyline', 'Provider zeigt OSM-Polyline an');
|
||
t.truthy(r.geometry.length >= 4, 'Polyline hat mehrere Stützpunkte (nicht nur Luftlinie)');
|
||
t.truthy(r.totalDistanceKm < 326, 'Polyline-Distanz < Luftlinie×1.3 (Stützpunkte näher dran)');
|
||
// Bidirektional
|
||
const r2 = E.calculateRoute(g, 'salzburg', 'wien', 'TRUCK_SMALL');
|
||
t.eq(r2.provider, 'osm_polyline_reversed', 'Reverse-Lookup als Reversed markiert');
|
||
t.approx(r2.totalDistanceKm, r.totalDistanceKm, 0.1, 'Reverse-Distanz = Forward-Distanz');
|
||
// Fallback
|
||
const r3 = E.calculateRoute(g, 'berlin', 'hamburg', 'TRUCK_SMALL');
|
||
t.eq(r3.provider, 'airline_x1.3', 'Ohne Polyline → Luftlinie-Fallback');
|
||
});
|
||
|
||
group('Test 19 — Phase 3: Pickup-Drive (Multi-Phase-Trip)', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase3Game(1);
|
||
// Vehicle künstlich nach Salzburg umsetzen, damit Pickup von Salzburg → Wien nötig wird
|
||
const v = g.vehicles[0];
|
||
const sbg = g.worldState.locations.find(l => l.id === 'salzburg');
|
||
v.currentLocationId = 'salzburg';
|
||
v.lat = sbg.lat; v.lon = sbg.lon;
|
||
const c = g.activeContracts[0]; // Wien → Salzburg
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
E.assignContract(g, c.id, v.id);
|
||
t.eq(v.tripPhase, 'pickup_drive', 'tripPhase startet als pickup_drive');
|
||
t.eq(c.state, 'PICKUP_PENDING', 'Contract: PICKUP_PENDING');
|
||
t.truthy(v.pickupRouteId, 'pickupRouteId gesetzt');
|
||
t.truthy(v.deliveryRouteId, 'deliveryRouteId gesetzt');
|
||
t.truthy(g.activeRoutes.length === 2, 'zwei Routen aktiv (pickup + delivery)');
|
||
|
||
// Lange genug ticken, bis Trip vollendet (Pickup ~3h + Loading 5min + Delivery ~3h + Unloading 5min ≈ 6.5h)
|
||
E.tick(g, 500 * 1000); // 500 sec real bei 1× = 500 min sim ≈ 8.3 h sim
|
||
t.eq(v.state, 'IDLE', 'Vehicle nach Trip wieder IDLE');
|
||
t.truthy(g.completedContracts.length >= 1, 'Mindestens 1 Auftrag abgeschlossen');
|
||
t.eq(v.tripPhase, null, 'tripPhase nach Vollendung null');
|
||
});
|
||
|
||
group('Test 20 — Phase 3: Standkosten + Miete', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = makePhase3Game(1);
|
||
// L1-Defaults haben rentalCostPerDay=0, idleCostPerHour=0 → keine Kosten erwartet
|
||
const balanceBefore = g.balance;
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
E.tick(g, 60 * 1000); // 60 min sim
|
||
t.eq(g.balance, balanceBefore, 'L1: keine Standkosten/Miete (Defaults = 0)');
|
||
|
||
// Manuell auf L2-Werte setzen
|
||
const g2 = makePhase3Game(1);
|
||
g2._config.idleCostPerHour = 5;
|
||
g2._config.rentalCostPerDay = 240; // 240/24 = 10€/h, schön rechenbar
|
||
E.startPlanning(g2); E.startSimulation(g2);
|
||
const beforeBalance = g2.balance;
|
||
E.tick(g2, 60 * 1000); // 60 min = 1 Sim-Stunde
|
||
// 1 Vehicle IDLE: Miete 240/24 = 10€ + Standkosten 5€ = 15€
|
||
const cost = beforeBalance - g2.balance;
|
||
t.approx(cost, 15, 0.5, 'Pro Sim-Std: Miete 10€ + Standkosten 5€ = 15€');
|
||
});
|
||
|
||
group('Test 21 — Phase 3: Auftrags-Variation deterministisch', (t) => {
|
||
const E = LogistikEngine;
|
||
const a = makePhase3Game(2, 42);
|
||
const b = makePhase3Game(2, 42);
|
||
const c = makePhase3Game(2, 99);
|
||
t.eq(a.activeContracts.length, 3, 'L2: 3 initiale Aufträge');
|
||
t.eq(b.activeContracts.length, 3, 'L2 mit gleichem Seed: 3 Aufträge');
|
||
// Reproduzierbar
|
||
const sigA = a.activeContracts.map(x => x.originLocationId + '→' + x.targetLocationId).join(',');
|
||
const sigB = b.activeContracts.map(x => x.originLocationId + '→' + x.targetLocationId).join(',');
|
||
const sigC = c.activeContracts.map(x => x.originLocationId + '→' + x.targetLocationId).join(',');
|
||
t.eq(sigA, sigB, 'Seed 42 → identische Auftrags-Sequenz');
|
||
t.truthy(sigA !== sigC, 'Seed 42 ≠ Seed 99');
|
||
});
|
||
|
||
group('Test 22 — Phase 3: greedy-Strategie auf L2', async (t) => {
|
||
const r = await LogistikRunner.runLevel(2, 1, 'greedy', { seeds: phase3Seeds });
|
||
t.truthy(r, 'greedy liefert Result');
|
||
t.truthy(r.completedContracts >= 1, 'mindestens 1 Auftrag in 48h geschafft');
|
||
t.truthy(r.endBalance > 0, 'Konto nicht negativ (L2-Akzeptanz: nicht pleite)');
|
||
// L2 success-Schwelle minTarget=10000 ist Tuning-Sache (Phase 4); hier reicht
|
||
// dass greedy strukturell durchläuft und Aufträge schafft.
|
||
t.truthy(typeof r.endBalance === 'number', 'endBalance numerisch');
|
||
t.truthy(r.durationHours <= 48.5, 'Dauer im Limit (48h)');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 4 — Bahn-Routing, Schiffsankünfte, Container-Standkosten
|
||
========================================================= */
|
||
|
||
const phase4Seeds = {
|
||
locations: [
|
||
{ id: 'wien', name: 'Wien', countryId: 'AT', regionId: 'central_europe', type: 'CAPITAL', lat: 48.2082, lon: 16.3738, visibleFromLevel: 1 },
|
||
{ id: 'muenchen', name: 'München', countryId: 'DE', regionId: 'central_europe', type: 'CAPITAL', lat: 48.1351, lon: 11.5820, visibleFromLevel: 1 },
|
||
{ id: 'hamburg', name: 'Hamburg', countryId: 'DE', regionId: 'central_europe', type: 'CITY', lat: 53.5511, lon: 9.9937, visibleFromLevel: 1 },
|
||
{ id: 'hamburg_hafen', name: 'Hamburg Hafen', countryId: 'DE', regionId: 'central_europe', type: 'PORT', lat: 53.5461, lon: 9.9661, visibleFromLevel: 2 },
|
||
{ id: 'rotterdam_hafen',name: 'Rotterdam Hafen',countryId: 'NL', regionId: 'benelux', type: 'PORT', lat: 51.9519, lon: 4.1378, visibleFromLevel: 2 },
|
||
{ id: 'berlin', name: 'Berlin', countryId: 'DE', regionId: 'central_europe', type: 'CAPITAL', lat: 52.5200, lon: 13.4050, visibleFromLevel: 1 },
|
||
],
|
||
railnet: {
|
||
nodes: [
|
||
{ id: 'wien', locationId: 'wien', name: 'Wien Hbf' },
|
||
{ id: 'muenchen', locationId: 'muenchen', name: 'München Hbf' },
|
||
{ id: 'hamburg', locationId: 'hamburg', name: 'Hamburg Hbf' },
|
||
],
|
||
edges: [
|
||
{ id: 'w-m', from: 'wien', to: 'muenchen', distanceKm: 400, durationMinutes: 267 },
|
||
{ id: 'm-h', from: 'muenchen', to: 'hamburg', distanceKm: 800, durationMinutes: 533 },
|
||
],
|
||
},
|
||
routesOsm: [], vehicleTypes: [], cargoTypes: [],
|
||
};
|
||
|
||
group('Test 23 — Phase 4: TRAIN-Routing via railnet-Dijkstra', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(3, { deterministic: true, seed: 4 });
|
||
g._config = { startBudget: 2500, maxActiveContracts: 6,
|
||
availableVehicleTypes: ['TRAIN'], vehicleCount: 1,
|
||
defaultHintMode: 'NONE', railEnabled: true, portsEnabled: true,
|
||
visibleLocationLevel: 3 };
|
||
E.loadContent(g, phase4Seeds);
|
||
const r = E.calculateRoute(g, 'wien', 'hamburg', 'TRAIN');
|
||
t.eq(r.provider, 'rail_dijkstra', 'Provider: rail_dijkstra');
|
||
t.eq(r.totalDistanceKm, 1200, 'Wien → Hamburg: 1200 km via München');
|
||
t.eq(r.geometry.length, 3, 'Geometrie: 3 Punkte (Wien, München, Hamburg)');
|
||
t.eq(r.segments.length, 2, '2 Bahnsegmente (RAIL)');
|
||
t.eq(r.segments[0].segmentType, 'RAIL', 'Segment 0 ist RAIL');
|
||
|
||
// Wenn TRAIN aber kein Bahnknoten verfügbar → Fallback
|
||
const r2 = E.calculateRoute(g, 'wien', 'berlin', 'TRAIN'); // berlin ist kein Bahnknoten
|
||
t.truthy(r2.provider !== 'rail_dijkstra', 'kein Bahnknoten → Fallback (nicht rail_dijkstra)');
|
||
});
|
||
|
||
group('Test 24 — Phase 4: Schiffsankunft-Generator', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(3, { deterministic: true, seed: 4 });
|
||
g._config = { startBudget: 2500, maxActiveContracts: 6,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 1,
|
||
portsEnabled: true, railEnabled: true, defaultHintMode: 'NONE',
|
||
timeLimitHours: 72, visibleLocationLevel: 3 };
|
||
g.balance = 2500;
|
||
E.loadContent(g, phase4Seeds);
|
||
E.seedInitialVehicles(g);
|
||
E.seedInitialContracts(g);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
const initialActive = g.activeContracts.length;
|
||
// Tick 7 Sim-Stunden (Trigger nach 6h)
|
||
E.tick(g, 7 * 3600 * 1000 / 60); // 7h in Sim-Min, dann × 60s/min × 1000ms/s, geteilt durch 60? Falsch — nochmal:
|
||
// Eigentlich: tick(deltaMs); deltaMs ist Echtzeit-ms. Bei 1× = 1 Sim-Min/Sec
|
||
// 7h Sim = 7*60 Sim-Min. 1 Sim-Min = 1 Sec real = 1000 ms real. 420 Sim-Min = 420000 ms real.
|
||
// Reset und korrekt:
|
||
while (E.getSimHoursElapsed(g) < 7) E.tick(g, 60000); // 1 min real = 1 sim-min, repeat
|
||
t.truthy(g.activeContracts.length > initialActive, 'Schiff hat zusätzlichen Auftrag generiert');
|
||
const ship = g.activeContracts.find(c => c.code && c.code.startsWith('SHIP-'));
|
||
t.truthy(ship, 'Auftrag mit SHIP-Code gefunden');
|
||
t.truthy(ship && /Hafen/.test(ship.narrativeText || ''), 'narrativeText erwähnt Hafen');
|
||
const notif = (g.pendingNotifications || []).find(n => n.type === 'SHIP_ARRIVED');
|
||
t.truthy(notif, 'SHIP_ARRIVED-Notification existiert');
|
||
});
|
||
|
||
group('Test 25 — Phase 4: Container-Standkosten an Häfen', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(3, { deterministic: true, seed: 1 });
|
||
g._config = { startBudget: 10000, maxActiveContracts: 6,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 0, // keine Vehicles!
|
||
portsEnabled: true, railEnabled: false, defaultHintMode: 'NONE',
|
||
containerStandCostPerHour: 10, idleCostPerHour: 0, rentalCostPerDay: 0,
|
||
timeLimitHours: 72, visibleLocationLevel: 3 };
|
||
g.balance = 10000;
|
||
E.loadContent(g, phase4Seeds);
|
||
// Manueller Hafen-Auftrag
|
||
g.activeContracts = [{
|
||
id: 'manual-port',
|
||
code: 'TEST-001',
|
||
originLocationId: 'hamburg_hafen',
|
||
targetLocationId: 'wien',
|
||
quantityContainers: 3,
|
||
state: E.CONTRACT_STATE.OPEN,
|
||
issueTime: g.simulationTime,
|
||
dueTime: new Date(new Date(g.simulationTime).getTime() + 100 * 3600000).toISOString(),
|
||
rewardBase: 1000,
|
||
}];
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
const before = g.balance;
|
||
// Tick 1 Sim-Stunde (= 60 sec real bei 1×)
|
||
E.tick(g, 60000);
|
||
const cost = before - g.balance;
|
||
// Container 3 × 10€/h × 1h = 30€
|
||
t.approx(cost, 30, 0.5, 'Container-Standkosten: 3 × 10€/h × 1h = 30€');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 4b — Intermodale Aufträge (Multi-Leg)
|
||
========================================================= */
|
||
|
||
group('Test 26 — Phase 4b: Intermodal-Schema (legs[])', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(3, { deterministic: true, seed: 1 });
|
||
g._config = { startBudget: 10000, maxActiveContracts: 6,
|
||
availableVehicleTypes: ['TRUCK_SMALL','TRAIN'], vehicleCount: 2,
|
||
portsEnabled: true, railEnabled: true, defaultHintMode: 'NONE',
|
||
visibleLocationLevel: 3 };
|
||
E.loadContent(g, phase4Seeds);
|
||
// Manueller intermodaler Auftrag (Engine-private, hier per direkter
|
||
// Engine-Funktion via window.__test-Hook simuliert: wir bauen das
|
||
// Contract-Objekt von außen und prüfen das Schema)
|
||
const c = {
|
||
id: 'contract_IM-001', code: 'IM-001', intermodal: true,
|
||
legs: [
|
||
{ legNum: 0, mode: 'TRUCK_SMALL', originLocationId: 'rotterdam_hafen', targetLocationId: 'hamburg', state: 'OPEN', assignedVehicleId: null, routeId: null },
|
||
{ legNum: 1, mode: 'TRAIN', originLocationId: 'hamburg', targetLocationId: 'muenchen', state: 'WAITING', assignedVehicleId: null, routeId: null },
|
||
],
|
||
currentLegNum: 0, currentLocationId: 'rotterdam_hafen',
|
||
originLocationId: 'rotterdam_hafen', targetLocationId: 'muenchen',
|
||
quantityContainers: 2, state: 'OPEN', issueTime: g.simulationTime,
|
||
dueTime: new Date(new Date(g.simulationTime).getTime() + 100 * 3600000).toISOString(),
|
||
rewardBase: 1500,
|
||
};
|
||
t.eq(c.intermodal, true, 'intermodal-Flag gesetzt');
|
||
t.eq(c.legs.length, 2, '2 Legs');
|
||
t.eq(c.legs[0].state, 'OPEN', 'Leg 0 OPEN');
|
||
t.eq(c.legs[1].state, 'WAITING', 'Leg 1 WAITING');
|
||
});
|
||
|
||
group('Test 27 — Phase 4b: assignContract mit legNum + Leg-Übergabe', async (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(3, { deterministic: true, seed: 1 });
|
||
g._config = { startBudget: 50000, maxActiveContracts: 6,
|
||
availableVehicleTypes: ['TRUCK_SMALL','TRAIN'], vehicleCount: 2,
|
||
portsEnabled: true, railEnabled: true, defaultHintMode: 'NONE',
|
||
timeLimitHours: 200, idleCostPerHour: 0, rentalCostPerDay: 0,
|
||
visibleLocationLevel: 3 };
|
||
g.balance = 50000;
|
||
E.loadContent(g, phase4Seeds);
|
||
|
||
// 2 Vehicles manuell platzieren
|
||
const lkw = { id: 'lkw1', mode: 'TRUCK_SMALL', displayName: 'LKW 1', state: 'IDLE',
|
||
currentLocationId: 'rotterdam_hafen', lat: 51.9519, lon: 4.1378,
|
||
capacityUnits: 1, speedKmh: 70, tripPhase: null, segmentProgress: 0 };
|
||
const zug = { id: 'zug1', mode: 'TRAIN', displayName: 'Güterzug 1', state: 'IDLE',
|
||
currentLocationId: 'hamburg', lat: 53.5511, lon: 9.9937,
|
||
capacityUnits: 20, speedKmh: 90, tripPhase: null, segmentProgress: 0 };
|
||
g.vehicles = [lkw, zug];
|
||
|
||
// Manueller intermodaler Auftrag
|
||
const c = {
|
||
id: 'contract_IM-001', code: 'IM-001', intermodal: true,
|
||
legs: [
|
||
{ legNum: 0, mode: 'TRUCK_SMALL', originLocationId: 'rotterdam_hafen', targetLocationId: 'hamburg', state: E.CONTRACT_STATE.OPEN, assignedVehicleId: null, routeId: null },
|
||
{ legNum: 1, mode: 'TRAIN', originLocationId: 'hamburg', targetLocationId: 'muenchen', state: 'WAITING', assignedVehicleId: null, routeId: null },
|
||
],
|
||
currentLegNum: 0, currentLocationId: 'rotterdam_hafen',
|
||
originLocationId: 'rotterdam_hafen', targetLocationId: 'muenchen',
|
||
quantityContainers: 1, state: E.CONTRACT_STATE.OPEN,
|
||
issueTime: g.simulationTime,
|
||
dueTime: new Date(new Date(g.simulationTime).getTime() + 200 * 3600000).toISOString(),
|
||
rewardBase: 1500,
|
||
};
|
||
g.activeContracts = [c];
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
|
||
// 27.1 Assign Leg 0 mit LKW
|
||
E.assignContract(g, c.id, lkw.id, 0);
|
||
t.eq(c.legs[0].state, E.CONTRACT_STATE.LOADING, 'Leg 0 nach assign: LOADING (LKW war am Origin)');
|
||
t.eq(c.legs[1].state, 'WAITING', 'Leg 1 noch WAITING');
|
||
|
||
// Tick lange genug bis Leg 0 fertig (Rotterdam_Hafen → Hamburg ~480 km airline×1.3 = 624 km, /70 = 8.9h)
|
||
let safety = 1000;
|
||
while (c.legs[0].state !== E.CONTRACT_STATE.DELIVERED && safety-- > 0) {
|
||
E.tick(g, 60000); // 1 Sim-Min real-Step
|
||
}
|
||
t.eq(c.legs[0].state, E.CONTRACT_STATE.DELIVERED, 'Leg 0 nach Tick: DELIVERED');
|
||
t.eq(c.legs[1].state, E.CONTRACT_STATE.OPEN, 'Leg 1 jetzt OPEN');
|
||
t.eq(c.currentLegNum, 1, 'currentLegNum auf 1 vorgerückt');
|
||
t.eq(c.currentLocationId, 'hamburg', 'Container in Hamburg');
|
||
t.eq(lkw.state, 'IDLE', 'LKW wieder IDLE');
|
||
|
||
// 27.2 Assign Leg 1 mit Zug (Hamburg → München via railnet)
|
||
E.assignContract(g, c.id, zug.id, 1);
|
||
t.eq(c.legs[1].state, E.CONTRACT_STATE.LOADING, 'Leg 1 nach assign: LOADING');
|
||
|
||
// Tick bis Leg 1 fertig
|
||
safety = 2000;
|
||
while (c.state !== E.CONTRACT_STATE.DELIVERED && c.state !== E.CONTRACT_STATE.LATE && safety-- > 0) {
|
||
E.tick(g, 60000);
|
||
}
|
||
t.truthy(c.state === E.CONTRACT_STATE.DELIVERED || c.state === E.CONTRACT_STATE.LATE,
|
||
'Contract nach allen Legs: DELIVERED oder LATE');
|
||
t.truthy(g.completedContracts.find(x => x.id === c.id), 'Contract in completedContracts');
|
||
t.eq(c.legs[1].state, E.CONTRACT_STATE.DELIVERED, 'Leg 1 DELIVERED');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 5 — Event-Engine
|
||
========================================================= */
|
||
|
||
group('Test 28 — Phase 5: Event wird deterministisch generiert', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(3, { deterministic: true, seed: 42 });
|
||
g._config = { startBudget: 5000, maxActiveContracts: 6,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 0,
|
||
eventProbabilityMultiplier: 10, // sehr hoch für Test-Sicherheit
|
||
timeLimitHours: 100, defaultHintMode: 'NONE',
|
||
visibleLocationLevel: 3 };
|
||
g.balance = 5000;
|
||
E.loadContent(g, phase4Seeds);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
// Tick 50 Sim-Stunden — mit prob×10 sollten viele Events kommen
|
||
for (let i = 0; i < 50; i++) E.tick(g, 60000);
|
||
t.truthy(g.activeEvents !== undefined, 'activeEvents-Array existiert');
|
||
// Mit dem hohen Multiplikator UND 50h sollten Events stattgefunden haben
|
||
const totalEventsEverGenerated = (g.pendingNotifications || []).filter(n => n.type === 'EVENT_STARTED').length
|
||
+ (g.activeEvents || []).length;
|
||
t.truthy(totalEventsEverGenerated > 0, 'Mindestens 1 Event generiert');
|
||
|
||
// Reproduzierbar: gleicher Seed → gleiche Event-Sequenz
|
||
const g2 = E.createGame(3, { deterministic: true, seed: 42 });
|
||
g2._config = g._config;
|
||
g2.balance = 5000;
|
||
E.loadContent(g2, phase4Seeds);
|
||
E.startPlanning(g2); E.startSimulation(g2);
|
||
for (let i = 0; i < 50; i++) E.tick(g2, 60000);
|
||
const sig1 = (g.pendingNotifications || []).filter(n => n.type === 'EVENT_STARTED').map(n => n.messageShort).sort().join('|');
|
||
const sig2 = (g2.pendingNotifications || []).filter(n => n.type === 'EVENT_STARTED').map(n => n.messageShort).sort().join('|');
|
||
t.eq(sig1, sig2, 'Seed 42 → identische Event-Sequenz');
|
||
});
|
||
|
||
group('Test 29 — Phase 5: Event reduziert Vehicle-Speed', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(1, { deterministic: true, seed: 1 });
|
||
g._config = { startBudget: 8000, maxActiveContracts: 1,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 1,
|
||
eventProbabilityMultiplier: 0, // KEINE zufälligen Events — wir setzen manuell
|
||
defaultHintMode: 'NONE', deadlineMultiplier: 1.5,
|
||
timeLimitHours: 24, visibleLocationLevel: 1 };
|
||
g.balance = 8000;
|
||
E.loadContent(g, phase4Seeds);
|
||
E.seedInitialVehicles(g);
|
||
E.seedInitialContracts(g);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
|
||
// Manuelles Event: TRAFFIC_ACCIDENT für 60 min → Speed × 0.5
|
||
g.activeEvents = [{
|
||
id: 'manual-evt', type: 'TRAFFIC_ACCIDENT', severity: 'normal',
|
||
startTime: g.simulationTime,
|
||
endTime: new Date(new Date(g.simulationTime).getTime() + 60 * 60000).toISOString(),
|
||
durationMin: 60,
|
||
impact: { speedMult: 0.5 },
|
||
messageShort: 'manual',
|
||
}];
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
E.assignContract(g, c.id, v.id);
|
||
// Nach Loading: tick 30 min → mit speedMul=0.5 nur halber Progress
|
||
while (v.tripPhase === 'loading') E.tick(g, 60000);
|
||
const progressBefore = v.segmentProgress;
|
||
E.tick(g, 30 * 60000); // 30 min sim
|
||
const progressAfter = v.segmentProgress;
|
||
const delta = progressAfter - progressBefore;
|
||
// Ohne Event: 30/280 = 0.107. Mit speedMul 0.5: 30/(280/0.5) = 30/560 = 0.0535
|
||
t.truthy(delta > 0 && delta < 0.07, 'Progress < ohne-Event-Wert (Speed halbiert)');
|
||
});
|
||
|
||
group('Test 30 — Phase 5: Events laufen ab', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(1, { deterministic: true, seed: 1 });
|
||
g._config = { eventProbabilityMultiplier: 0, timeLimitHours: 100, visibleLocationLevel: 1 };
|
||
g.balance = 5000;
|
||
E.loadContent(g, phase4Seeds);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
g.activeEvents = [{
|
||
id: 'short-evt', type: 'SNOW',
|
||
startTime: g.simulationTime,
|
||
endTime: new Date(new Date(g.simulationTime).getTime() + 30 * 60000).toISOString(),
|
||
durationMin: 30, impact: { speedMult: 0.7 }, messageShort: 'snow',
|
||
}];
|
||
t.eq(g.activeEvents.length, 1, 'Event vor Tick aktiv');
|
||
E.tick(g, 60 * 60000); // 60 min sim
|
||
t.eq(g.activeEvents.length, 0, 'Event nach 60 min ausgelaufen');
|
||
});
|
||
|
||
/* =========================================================
|
||
PHASE 6 — Minigame-Result + applyMinigameResult-Anbindung
|
||
========================================================= */
|
||
|
||
group('Test 31 — Phase 6c: applyMinigameResult setzt loadingTimeModifier', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(1, { deterministic: true, seed: 1 });
|
||
g._config = { startBudget: 8000, maxActiveContracts: 1,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 1,
|
||
defaultHintMode: 'BLINK_EXACT', minigamesEnabled: true,
|
||
eventProbabilityMultiplier: 0, deadlineMultiplier: 1.5,
|
||
timeLimitHours: 24, visibleLocationLevel: 1 };
|
||
g.balance = 8000;
|
||
E.loadContent(g, phase4Seeds);
|
||
E.seedInitialVehicles(g);
|
||
E.seedInitialContracts(g);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
|
||
// Perfektes Minigame-Result
|
||
const r = E.applyMinigameResult(g, {
|
||
success: true, scoreModifier: 1, timeModifierMinutes: 0, costModifier: 0,
|
||
metadata: { quality: 'perfect', loadingTimeFactor: 0.8 },
|
||
});
|
||
t.eq(g._loadingTimeModifier, 0.8, 'Modifier 0.8 gespeichert');
|
||
t.eq(g.analytics.minigamesPlayed, 1, 'minigamesPlayed++');
|
||
t.eq(g.analytics.minigamesSuccessful, 1, 'minigamesSuccessful++');
|
||
|
||
// Bei nächstem assignContract wird LoadingTime × 0.8 gerechnet
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
E.assignContract(g, c.id, v.id);
|
||
// Standard loadMinutes = 5 × 1 container × 0.8 = 4 min
|
||
t.approx(v.phaseRemainingMinutes, 4, 0.1, 'LOADING-Zeit reduziert auf 4 min (5 × 0.8)');
|
||
t.eq(g._loadingTimeModifier, 1, 'Modifier nach Verbrauch zurückgesetzt');
|
||
});
|
||
|
||
group('Test 32 — Phase 6c: Minigame fail erhöht Loading-Zeit', (t) => {
|
||
const E = LogistikEngine;
|
||
const g = E.createGame(1, { deterministic: true, seed: 1 });
|
||
g._config = { startBudget: 8000, maxActiveContracts: 1,
|
||
availableVehicleTypes: ['TRUCK_SMALL'], vehicleCount: 1,
|
||
defaultHintMode: 'BLINK_EXACT', minigamesEnabled: true,
|
||
eventProbabilityMultiplier: 0, deadlineMultiplier: 1.5,
|
||
timeLimitHours: 24, visibleLocationLevel: 1 };
|
||
g.balance = 8000;
|
||
E.loadContent(g, phase4Seeds);
|
||
E.seedInitialVehicles(g);
|
||
E.seedInitialContracts(g);
|
||
E.startPlanning(g); E.startSimulation(g);
|
||
|
||
E.applyMinigameResult(g, {
|
||
success: false,
|
||
metadata: { quality: 'miss', loadingTimeFactor: 1.3 },
|
||
});
|
||
const c = g.activeContracts[0];
|
||
const v = g.vehicles[0];
|
||
E.assignContract(g, c.id, v.id);
|
||
t.approx(v.phaseRemainingMinutes, 6.5, 0.1, 'LOADING-Zeit +30 % = 6.5 min (5 × 1.3)');
|
||
});
|
||
|
||
/* =========================================================
|
||
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>
|