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
+226
View File
@@ -0,0 +1,226 @@
{
"moduleId": "logistik",
"version": "1.0",
"description": "Feldbeschreibungen fuer game_levels.params des Logistik-Moduls. Quelle: App/sims/logistik/balance-matrix.md v0.3.",
"groups": [
{
"title": "Wirtschaft",
"fields": [
{ "key": "startBudget",
"label": "Startbudget",
"labelEasy": "Geld am Anfang",
"type": "number",
"unit": "€",
"min": 0,
"max": 100000,
"step": 100,
"default": 5000,
"help": "Kontostand, mit dem der Durchgang beginnt. L1: 8000, L2: 5000, L3: 2500." },
{ "key": "minTargetEarnings",
"label": "Ziel-Erloes (Erfolgsschwelle)",
"labelEasy": "Geld zum Gewinnen",
"type": "number",
"unit": "€",
"min": 0,
"max": 100000,
"step": 500,
"default": 10000,
"help": "Mindest-Kontostand am Ende, damit das Level als geschafft gilt. L1: 3000, L2: 10000, L3: 20000." },
{ "key": "rentalCostPerDay",
"label": "Fahrzeugmiete pro Tag",
"labelEasy": "Miete fuer Autos/Zuege pro Tag",
"type": "number",
"unit": "€/Tag",
"min": 0,
"max": 5000,
"step": 50,
"default": 0,
"help": "Tagesmiete pro Fahrzeug. 0 auf L1, 200 auf L2, 500 auf L3." },
{ "key": "idleCostPerHour",
"label": "Standkosten Fahrzeug",
"labelEasy": "Kosten wenn Auto steht",
"type": "number",
"unit": "€/h",
"min": 0,
"max": 100,
"step": 1,
"default": 5,
"help": "Kosten pro Stunde, in der ein Fahrzeug ungenutzt steht. 0 auf L1, ab L2: 5." },
{ "key": "containerStandCostPerHour",
"label": "Standkosten Container im Hafen",
"labelEasy": "Kosten fuer Container im Hafen pro Stunde",
"type": "number",
"unit": "€/h",
"min": 0,
"max": 100,
"step": 1,
"default": 0,
"help": "Demurrage: Gebuehr pro Stunde, die ein Container nach Ankunft noch im Hafen liegt. Erst ab L3 relevant." },
{ "key": "latePenaltyOverride",
"label": "Verspaetungsstrafe (Anteil pro Stunde)",
"labelEasy": "Strafe pro Stunde Verspaetung",
"type": "number",
"min": 0,
"max": 1,
"step": 0.05,
"default": 0.20,
"help": "Anteil des Auftragswertes, der pro Stunde Verspaetung abgezogen wird. 0.10 = 10%. L1: 0.10, L2: 0.20, L3: 0.30." }
]
},
{
"title": "Auftragslage",
"fields": [
{ "key": "maxActiveContracts",
"label": "Auftraege parallel (max)",
"labelEasy": "Auftraege gleichzeitig",
"type": "number",
"min": 1,
"max": 12,
"step": 1,
"default": 3,
"help": "Wie viele Auftraege maximal gleichzeitig offen sein duerfen. L1: 1, L2: 3, L3: 6." },
{ "key": "deadlineMultiplier",
"label": "Fristlaengen-Multiplikator",
"labelEasy": "Zeit-Verlaengerung fuer Abgabe",
"type": "number",
"unit": "×",
"min": 0.5,
"max": 3.0,
"step": 0.1,
"default": 1.2,
"help": "Multipliziert die berechnete Standardfrist (distanceKm/60 Std). Werte > 1 sind grosszuegiger. L1: 1.5, L2: 1.2, L3: 1.0." },
{ "key": "timeLimitHours",
"label": "Zeitlimit Durchgang",
"labelEasy": "Zeit bis Durchgang-Ende (Stunden)",
"type": "number",
"unit": "Sim-h",
"min": 1,
"max": 240,
"step": 1,
"default": 48,
"help": "Maximale Simulations-Stunden pro Durchgang. L1: 24, L2: 48, L3: 72." },
{ "key": "initialTimeScale",
"label": "Zeit-Geschwindigkeit Start",
"labelEasy": "Tempo am Anfang",
"type": "enum",
"options": [1, 4, 8],
"default": 1,
"help": "Wie schnell die Simulationszeit relativ zur Echtzeit laeuft. 1 = realistisch langsam. Bearbeiter:innen koennen live waehrend des Durchgangs wechseln." }
]
},
{
"title": "Fahrzeugpool",
"fields": [
{ "key": "vehicleCount",
"label": "Fahrzeuge gesamt",
"labelEasy": "Anzahl Fahrzeuge",
"type": "number",
"min": 1,
"max": 10,
"step": 1,
"default": 3,
"help": "Anzahl Fahrzeuge im Pool. L1: 1, L2: 3, L3: 5. Typen werden ueber availableVehicleTypes gesteuert." },
{ "key": "availableVehicleTypes",
"label": "Fahrzeugtypen",
"labelEasy": "Welche Fahrzeuge verfuegbar",
"type": "multienum",
"options": ["TRUCK_SMALL", "TRUCK_LARGE", "TRAIN"],
"default": ["TRUCK_SMALL", "TRUCK_LARGE", "TRAIN"],
"help": "Welche Fahrzeugtypen im Pool auftauchen. L1: nur TRUCK_SMALL. Ab L2: alle drei. TRAIN nur sinnvoll wenn railEnabled=true." }
]
},
{
"title": "Didaktik & Hilfen",
"fields": [
{ "key": "defaultHintMode",
"label": "Hilfestufe (Standard)",
"labelEasy": "Wie viel Hilfe beim Suchen",
"type": "enum",
"options": ["BLINK_EXACT", "SHOW_COUNTRY", "SHOW_REGION", "DISTANCE_FEEDBACK", "NONE"],
"default": "SHOW_REGION",
"help": "Wie viel Hilfe Bearbeiter:innen beim Ortssuchen auf der Karte bekommen. BLINK_EXACT = volle Markierung, NONE = ohne Hinweis. L1: BLINK_EXACT, L2: SHOW_REGION, L3: NONE." },
{ "key": "visibleLocationLevel",
"label": "Sichtbare Orte (Level)",
"labelEasy": "Wie viele Orte auf der Karte",
"type": "enum",
"options": [1, 2, 3],
"default": 2,
"help": "Filter fuer die Kartenanzeige: 1 = nur Hauptstaedte, 2 = + Grossstaedte, 3 = alle Orte. Reduziert kognitive Last auf niedrigen Leveln." },
{ "key": "minigamesEnabled",
"label": "Mini-Aufgaben aktivieren",
"labelEasy": "Kleine Spiele an/aus",
"type": "boolean",
"default": false,
"help": "Schaltet die Rampen-Einpark-Aufgabe beim Entladen frei. L1: aus, ab L2: optional." }
]
},
{
"title": "Ereignisse",
"fields": [
{ "key": "eventProbabilityMultiplier",
"label": "Ereignis-Staerke",
"labelEasy": "Wie oft Zwischenfaelle",
"type": "number",
"min": 0,
"max": 2,
"step": 0.1,
"default": 0.5,
"help": "0 = keine Ereignisse (Unfall, Schnee, Hafenstau), 1 = Standard-Haeufigkeit, 2 = doppelt. L1: 0 (Tutorial ohne Pech), L2: 0.5, L3: 1.0." }
]
},
{
"title": "Verkehrstraeger",
"fields": [
{ "key": "railEnabled",
"label": "Bahnnetz aktiv",
"labelEasy": "Zuege erlaubt",
"type": "boolean",
"default": false,
"help": "Wenn aktiv, koennen Aufträge ueber das Bahnnetz (5 Knoten: Wien, Muenchen, Hamburg, Rotterdam, Paris) gefahren werden. Erst ab L3." },
{ "key": "portsEnabled",
"label": "Haefen aktiv",
"labelEasy": "Schiffe erlaubt",
"type": "boolean",
"default": false,
"help": "Wenn aktiv, erscheinen Seehafen-Auftraege (ankommende Schiffe als neue Containerquelle). Erst ab L3." },
{ "key": "intermodalEnabled",
"label": "Intermodale Ketten",
"labelEasy": "Auftraege mit mehreren Fahrzeugen",
"type": "boolean",
"default": false,
"help": "Wenn aktiv, entstehen Auftraege mit erzwungenem Verkehrstraeger-Wechsel (z.B. LKW -> Zug -> LKW). Setzt railEnabled + portsEnabled voraus. Erst ab L3." }
]
},
{
"title": "Sonstiges (Leichte Sprache)",
"fields": [
{ "key": "levelNameEasy",
"label": "Level-Name (Leichte Sprache)",
"labelEasy": "Einfacher Name fuers Level",
"type": "text",
"maxLength": 64,
"default": "",
"help": "Kurzer, einfacher Titel als Alternative zum Haupt-Level-Namen. Wird angezeigt, wenn Bearbeiter:in Leichte Sprache aktiviert hat." }
]
}
]
}
+28 -33
View File
@@ -258,20 +258,26 @@ Wenn nach Phase 2/3 die Akzeptanz-Korridore nicht erreicht werden:
## 6. INSERT-SQL für `game_levels`
> **Vorbehalt:** Schema-Annahmen unten basieren auf dem Atlas-Briefing
> (`game_levels.params` als JSON). Falls Spaltennamen abweichen, bitte
> in der Atlas-Review korrigieren — ich schiebe dann nach.
**Schema (von Atlas bestätigt 01:45):** `id, game_id, level_name,
scenario, params, sort_order, created_at, updated_at`. Keine eigene
Spalte `level_name_easy` — die Leichte-Sprache-Variante wandert ins
`params`-JSON als `levelNameEasy` (UI liest beide Felder, `pickText()` wählt).
> **Hinweis:** Atlas hat die drei Logistik-Level am 2026-04-20 01:45
> bereits eingespielt. Diese SQLs dienen als Referenz / für Reset.
```sql
-- Logistik Europa — 3 Level-Konfigurationen (Phase 0)
-- Voraussetzung: module_info-Eintrag 'logistik' existiert (Atlas hat angelegt)
-- Voraussetzung: module_info-Eintrag 'logistik' existiert
-- created_at / updated_at werden von MySQL-Default gesetzt
INSERT INTO `game_levels`
(`game_id`, `sort_order`, `level_name`, `level_name_easy`, `params`, `created_at`)
(`game_id`, `sort_order`, `level_name`, `scenario`, `params`)
VALUES
(
'logistik', 1, 'Lernen', 'Erster Auftrag',
'logistik', 1, 'Lernen', 'logistik_level_1',
JSON_OBJECT(
'levelNameEasy', 'Erster Auftrag',
'startBudget', 8000,
'maxActiveContracts', 1,
'availableVehicleTypes', JSON_ARRAY('TRUCK_SMALL'),
@@ -291,12 +297,12 @@ VALUES
'intermodalEnabled', FALSE,
'minigamesEnabled', FALSE,
'visibleLocationLevel', 1
),
NOW()
)
),
(
'logistik', 2, 'Übung', 'Drei Aufträge',
'logistik', 2, 'Übung', 'logistik_level_2',
JSON_OBJECT(
'levelNameEasy', 'Drei Aufträge',
'startBudget', 5000,
'maxActiveContracts', 3,
'availableVehicleTypes', JSON_ARRAY('TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'),
@@ -316,12 +322,12 @@ VALUES
'intermodalEnabled', FALSE,
'minigamesEnabled', TRUE,
'visibleLocationLevel', 2
),
NOW()
)
),
(
'logistik', 3, 'Profi', 'Profi-Disposition',
'logistik', 3, 'Profi', 'logistik_level_3',
JSON_OBJECT(
'levelNameEasy', 'Profi-Disposition',
'startBudget', 2500,
'maxActiveContracts', 6,
'availableVehicleTypes', JSON_ARRAY('TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'),
@@ -341,38 +347,27 @@ VALUES
'intermodalEnabled', TRUE,
'minigamesEnabled', TRUE,
'visibleLocationLevel', 3
),
NOW()
)
);
```
> Wenn das Schema andere Spalten hat (z.B. `module_id` statt `game_id`,
> oder zusätzliche `description`/`is_active`-Spalten), bitte mit
> Korrektur-Snippet melden — ich übernehme das hier.
---
## 7. Konsistenz-Checks für Atlas
## 7. Konsistenz-Checks (durch Atlas 01:45 abgeschlossen)
Bitte beim Review verifizieren:
1. **Bahnnetz-Distanzen:** `lg-railnet.json` enthält 5 Knoten und (laut
Gerüst-Mail) 5 Kanten. Im Briefing waren nur 3 Kanten genannt
(WienMünchen 400, MünchenHamburg 800, HamburgRotterdam 500).
Welche Distanzen haben Paris↔Rotterdam und Paris↔München in deiner
Seed-Datei? Trag in §2 ein und in `headless-runner.js` ggf. die
Test-Erwartungen anpassen.
2. **Event-Wahrsch. L1=0:** Bewusste Abweichung von Atlas-Vorschlag
(PH 65.5: Unfall 5 %, Schnee 10 %, Hafen 8 % → Atlas-Briefing halbiert
für L1 → ich setze auf 0). Bitte freigeben oder Gegenvorschlag.
3. **`game_levels`-Schema:** Spaltennamen aus §6 INSERT prüfen.
4. **`level_name_easy`:** Existiert eine Spalte für Leichte-Sprache-
Variante des Level-Namens? Falls nicht, fliegt sie aus dem INSERT.
1.**Bahnnetz-Distanzen:** Paris↔Rotterdam=520, Paris↔München=820 (bestätigt, in §2 eingearbeitet)
2.**Event-Wahrsch. L1=0:** Genehmigt
3.**`game_levels`-Schema:** Spalten geklärt, INSERT in §6 angepasst
4.**`level_name_easy`:** Existiert nicht, wandert ins params-JSON
---
## 8. Änderungshistorie
- **2026-04-20 01:50** v0.3 — Atlas-Doku-Korrekturen: Bahnnetz-Distanzen
Paris↔Rotterdam=520/Paris↔München=820 ergänzt, INSERT-Schema
angepasst (`scenario` statt `level_name_easy`, `levelNameEasy` ins
params-JSON), Konsistenz-Checks abgehakt
- **2026-04-20** v0.2 — Rationale-Spalte je Parameter, INSERT-SQLs,
Konsistenz-Checks an Atlas, `eventProbabilityMultiplier` als
Multiplikator umstrukturiert, Container-Standkosten ergänzt, `noop`-
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118 -14
View File
@@ -129,29 +129,130 @@
/**
* `naive` — erstes Fahrzeug, nächster Auftrag, FIFO-Reihenfolge.
* Phase 2: aktiviert sobald engine.tick / assignContract /
* calculateRoute existieren. Bis dahin kontrollierter Stub.
* Phase 2 (aktiv): nimmt den ersten OPEN-Auftrag, weist ihn dem
* ersten IDLE-Fahrzeug zu, lässt dann ticken bis Limit oder
* alle Aufträge fertig.
*/
naive: {
description: 'Erstes Fahrzeug, nächster Auftrag, FIFO.',
run(ctx) {
throw new Error(
'Strategy "naive" benötigt engine.tick / assignContract / ' +
'calculateRoute (Phase 2 — noch nicht implementiert).'
);
const { game, engine, log, seeds } = ctx;
const cfg = game._config || {};
const limitHours = cfg.timeLimitHours || 24;
const E = engine;
// Seeds + Lifecycle
E.loadContent(game, seeds);
E.seedInitialVehicles(game);
E.seedInitialContracts(game);
E.startPlanning(game);
E.startSimulation(game);
// Phase-2-Tick-Step: 5 Min Sim-Zeit pro Iteration (entspricht
// ~5000 ms real bei 1×; im Headless egal, da deterministisch)
const stepMinutes = 5;
const stepRealMs = stepMinutes * 1000; // (1×: 1 sec real = 1 min sim → 5 min = 5000 ms)
let safety = 100000;
while (game.state === E.STATE.RUNNING && safety-- > 0) {
// Naive Disposition: erstes OPEN ↔ erstes IDLE.
// Intermodal-Aufträge werden in dieser Strategie noch übersprungen
// (Phase 6c: separate Strategie-Anpassung mit Mode-Match).
const open = (game.activeContracts || []).filter(c => c.state === E.CONTRACT_STATE.OPEN && !c.intermodal);
const free = (game.vehicles || []).filter(v => v.state === E.VEHICLE_STATE.IDLE);
if (open.length && free.length) {
try {
E.assignContract(game, open[0].id, free[0].id);
log.push({ simTime: game.simulationTime, action: 'assign', contract: open[0].id, vehicle: free[0].id });
} catch (e) {
log.push({ simTime: game.simulationTime, action: 'assign_failed', error: e.message });
}
}
E.tick(game, stepRealMs);
if (game.state !== E.STATE.RUNNING) break;
if (E.getSimHoursElapsed(game) >= limitHours) break;
}
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
return {
successful: profit >= minTarget,
endBalance: game.balance,
completedContracts: (game.completedContracts || []).length,
lateDeliveries: (game.analytics && game.analytics.lateDeliveries) || 0,
durationHours: E.getSimHoursElapsed(game),
hintUsages: 0,
log,
};
},
},
/**
* `greedy` — höchster Auftragswert zuerst, größtes Fahrzeug.
* `greedy` — höchster Auftragswert zuerst, fähigstes verfügbares
* Fahrzeug. „Fähig" = Kapazität ≥ Container-Bedarf, sonst egal.
* Phase 3 aktiv.
*/
greedy: {
description: 'Höchster Auftragswert zuerst, größtes Fahrzeug.',
description: 'Höchster Auftragswert zuerst, größtes passendes Fahrzeug.',
run(ctx) {
throw new Error(
'Strategy "greedy" benötigt engine.tick / assignContract / ' +
'calculateRoute (Phase 2 — noch nicht implementiert).'
);
const { game, engine, log, seeds } = ctx;
const cfg = game._config || {};
const limitHours = cfg.timeLimitHours || 24;
const E = engine;
E.loadContent(game, seeds);
E.seedInitialVehicles(game);
E.seedInitialContracts(game);
E.startPlanning(game);
E.startSimulation(game);
const stepRealMs = 5 * 1000; // 5 min sim
let safety = 100000;
while (game.state === E.STATE.RUNNING && safety-- > 0) {
// Aufträge nach Wert sortiert (descending), Fahrzeuge nach Kapazität descending.
// Intermodal-Aufträge werden hier noch übersprungen (Phase 6c).
const open = (game.activeContracts || [])
.filter(c => c.state === E.CONTRACT_STATE.OPEN && !c.intermodal)
.slice()
.sort((a, b) => (b.rewardBase || 0) - (a.rewardBase || 0));
const free = (game.vehicles || [])
.filter(v => v.state === E.VEHICLE_STATE.IDLE)
.slice()
.sort((a, b) => (b.capacityUnits || 0) - (a.capacityUnits || 0));
// Pro Auftrag das passendste Fahrzeug nehmen
for (const c of open) {
if (!free.length) break;
// Erstes free, das genug Kapazität hat (wenn keines, das größte)
let pick = free.find(v => (v.capacityUnits || 0) >= (c.quantityContainers || 1));
if (!pick) pick = free[0];
try {
E.assignContract(game, c.id, pick.id);
log.push({ simTime: game.simulationTime, action: 'assign', contract: c.id, vehicle: pick.id });
const idx = free.indexOf(pick);
if (idx >= 0) free.splice(idx, 1);
} catch (e) {
log.push({ simTime: game.simulationTime, action: 'assign_failed', error: e.message });
}
}
E.tick(game, stepRealMs);
if (game.state !== E.STATE.RUNNING) break;
if (E.getSimHoursElapsed(game) >= limitHours) break;
}
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
return {
successful: profit >= minTarget,
endBalance: game.balance,
completedContracts: (game.completedContracts || []).length,
lateDeliveries: (game.analytics && game.analytics.lateDeliveries) || 0,
durationHours: E.getSimHoursElapsed(game),
hintUsages: 0,
log,
};
},
},
@@ -192,11 +293,14 @@
' (verfügbar: ' + Object.keys(STRATEGIES).join(', ') + ')');
}
const game = engine.createGame(levelNum);
const game = engine.createGame(levelNum, { deterministic: true, seed });
const rng = makeRng(seed);
const log = [];
const ctx = { game, rng, engine, log, level: levelNum, seed };
const ctx = {
game, rng, engine, log, level: levelNum, seed,
seeds: (opts && opts.seeds) || null, // optional override
};
const result = await strategy.run(ctx);
// Vertrag-Anreicherung — alle Felder garantieren
Binary file not shown.
Binary file not shown.
+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)
========================================================= */