Files
Adminator fecd8bb9c9 Textsystem: Bis-Strich mit Leerzeichen (Zahl – Zahl) per Agent-Sweep
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (=
"bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex
/([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung
unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle
JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein
Logik-Key) -> safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:38:19 +02:00

1011 lines
49 KiB
HTML
Raw Permalink 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.
<!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>