Atlas: DSGVO-Tile-Proxy + Logistik-Phase-bis-8 + Sounds-Anleitung

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>
This commit is contained in:
2026-04-23 08:15:00 +02:00
parent eac686ee1a
commit fc8996f064
47 changed files with 9995 additions and 230 deletions
+729 -10
View File
@@ -129,19 +129,33 @@
});
group('Test 7 — Bahn-Dijkstra (railShortestPath)', (t) => {
// WienMünchenHamburg: 400 + 800 = 1200 km
// 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 },
],
};
const result = LogistikEngine.railShortestPath(graph, 'wien', 'hamburg');
t.truthy(result, 'Pfad gefunden');
t.eq(result.distanceKm, 1200, 'WienHamburg: 1200 km');
t.eq(result.path, ['wien', 'muenchen', 'hamburg'], 'Pfad über München');
// 7.1 Wien → Hamburg via München
const r1 = LogistikEngine.railShortestPath(graph, 'wien', 'hamburg');
t.truthy(r1, 'WienHamburg: 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)');
});
/* =========================================================
@@ -248,11 +262,12 @@
t.eq(r.durationHours, r2.durationHours, 'Seed 1 reproduziert Dauer');
t.eq(r.log.length, r2.log.length, 'Seed 1 reproduziert log-Länge');
// 10.3 naive/greedy/optimal werfen kontrolliert (Phase 2)
let naiveThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'naive'); }
catch (e) { naiveThrew = /Phase 2/.test(e.message); }
t.truthy(naiveThrew, 'naive wirft kontrolliert "Phase 2"');
// 10.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;
@@ -261,6 +276,710 @@
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)
========================================================= */