Atlas: Logistik-Gerüst + Heli-Fix + Waypoints-Regen + Level-Picker

- Neues Modul 'Logistik Europa': module_info-Eintrag, Landing-Card,
  PHP-Wrapper (logistik.php, modul-logistik.php), Engine-Skelett mit
  Enums + Helpern (travelCost, latePenalty, Bonus, Dijkstra,
  Polyline-Interpolation), Test-Harness, Kompetenzen-Draft,
  5 Seed-Dateien (locations, vehicle-types, cargo-types, railnet,
  contract-templates), 3 Level-Einträge in game_levels, Phase 0 durch
  die Logistik-Instanz geliefert und Atlas-Review bestanden
- Heli-Fix: heli-game.php mit BASE_PATH (production-sicher) +
  Asset-Pfad-Injection, geo_waypoints-Tabelle per Seed-Script
  auffindbar gemacht
- Waypoints-Regen-Tool: regen-waypoints-sql.php synchronisiert
  waypoints.sql aus DB (70 Einträge)
- Design-System: .ggs-level-grid / .ggs-level-card als Standard
  gepromotet (Klimas Muster, iPad-hover-safe)
- Inbox-Nachrichten: Kickoff-Briefings für Heli und Logistik,
  Asset-Map für Heli, DALL-E-Pipeline-Anleitung, Koordinations-
  nachrichten an Fluss, Klima, Glossar, Lehrplan

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 20:43:48 +02:00
parent 3931a701e1
commit 3885c83294
54 changed files with 5717 additions and 71 deletions
+380
View File
@@ -0,0 +1,380 @@
# Logistik Europa — Balance-Matrix v0.2
**Stand:** 2026-04-20 (Phase 0 — Atlas-Review angefordert)
**Quellen:** Pflichtenheft Kap 65 + Atlas-Kickoff-Briefing §5.1 + eigene Schätzungen (markiert)
> Diese Matrix ist der **verbindliche Startpunkt** für alle Level-Konfigurationen.
> Werte werden in `game_levels.params` (JSON) persistiert (siehe §6 INSERT-SQL),
> zur Laufzeit erreichbar über `window.LOGISTIK_LEVELS`. Einzeln änderbar von
> Lehrkräften über das Admin-Tool (Phase 7).
>
> **Akzeptanzkorridor (Atlas-Vorgabe, hartes Kriterium):**
> - Level 1: ≥ 90 % der 1315-jährigen schaffen Level beim **ersten** Versuch
> - Level 3: ~50 % schaffen es beim ersten / ~90 % beim dritten Versuch
>
> Messung über Headless-Runner (`headless-runner.js`) mit drei Strategien
> (`naive`, `greedy`, `optimal`) plus `noop` als Lebenszeichen-Strategie.
---
## 1. Level-Parameter mit Rationale
Tabelle pro Parameter. **Quelle = A** (Atlas-Briefing §5.1), **PH** (Pflichtenheft Kap),
**S** (Schätzung Logistik-Instanz). **„=A"** in Rationale heißt: Atlas-Wert übernommen,
keine Abweichung. Abweichungen sind ausführlich begründet.
| Parameter | L1 | L2 | L3 | Quelle | Rationale |
|-----------------------------------|------------------|-----------------|-----------------|--------|-----------|
| **Startbudget (€)** | 8.000 | 5.000 | 2.500 | A | =A. Werte erlauben bei TRUCK_SMALL Wien→Salzburg (~472 € Fahrkosten) genau 1 Standardauftrag (1.000 €) als Tutorialgewinn auf L1; auf L3 ist jede Fehlentscheidung sichtbar. |
| **Aufträge parallel (max)** | 1 | 3 | 6 | A / PH 13.3 | =A. PH 13.3 nennt nur „mehrere", Atlas konkretisiert. 1/3/6 ist saubere Verdoppelung+Steigerung, didaktisch sortierbar. |
| **Fahrzeuge verfügbar** | 1 | 3 | 5 | A / PH 13.3 | =A bzgl. Anzahl. Typen-Mix konkretisiert: L1=`TRUCK_SMALL`; L2=`TRUCK_SMALL+TRUCK_LARGE+TRAIN` (alle Typen, je 1); L3=variabel (2× SMALL, 2× LARGE, 1× TRAIN). |
| **Fristlänge-Multiplikator** | × 1.5 | × 1.2 | × 1.0 | A / PH 65.6 | =A. PH 65.6 definiert nur Standardauftrag-Frist als `Distanz/60 × 1.5`. Atlas reduziert pro Level — gut, bleibt. |
| **Hilfestufe (default)** | `BLINK_EXACT` | `SHOW_REGION` | `NONE` | A / PH 10.3 | =A. PH 10.3 nennt 5 Stufen, Atlas wählt die didaktisch sinnvollste Spreizung (volle Hilfe → mittlere → keine). `SHOW_COUNTRY` zwischen L1 und L2 als künftige Lehrkraft-Option. |
| **Event-Wahrscheinlichkeit (Multiplikator)** | **0** (deaktiviert) | 0.5 | 1.0 | **S abweichend** | **Abweichung von A** (war 2 %/5 %/10 %): Setze L1 auf **0** statt halbiert. Begründung: PH 4.2 verlangt „motivierend, nicht frustrierend". Bei einem einzigen Auftrag im Tutorial ist ein Unfall-Event statistisch zwar selten, aber wenn er trifft, ist der Effekt katastrophal (Speed×0.5 bei einer einzigen 4.5h-Fahrt = +2.25h Verspätung = 450 € Strafe = halber Erlös weg). Bei 0 % wird das Modell durchsichtig. Events kommen ab L2. |
| **Verspätungsstrafe (% Wert/h)** | 10 % | 20 % | 30 % | A / PH 65.2 | A weicht selbst von PH ab: PH 65.2 nennt fix „20 %", Atlas staffelt 10/20/30. Übernehme Atlas, weil pädagogisch sinnvoller (Schonung auf L1, Schmerz auf L3). Im JSON-Export als `latePenaltyOverride`, default bleibt `ECONOMY.latePenaltyPercentPerHour = 0.2`. |
| **Miete Fahrzeug (€/Tag)** | 0 | 200 | 500 | A | =A. PH erwähnt Miete nur lose (Kap 10.7 „Miete ab bestimmten Levels"). 0/200/500 macht Fahrzeugauswahl strategisch ab L2 (großer LKW = teurer, aber 3× Kapazität → lohnt nur bei Großauftrag). |
| **Standkosten Fahrzeug (€/h)** | 0 | 5 | 5 | PH 65.2 | =PH (5 €/h). L1 ausgesetzt, weil Tutorial — sonst wirkt jede Pause wie eine Strafe. Ab L2 normal aktiv. |
| **Standkosten Container (€/h Hafen)** | n/a | n/a | 10 | PH 65.2 | =PH (10 €/h). Erst L3 relevant, weil Häfen erst L3 aktiv. |
| **Min Ziel-Erlös (€)** | 3.000 | 10.000 | 20.000 | A / S | =A. Mein Sanity-Check: L3 mit 6 parallelen Aufträgen × ~1.500 € durchschnittlich × 2 Auftragsrunden in 72h = 18.000 € Brutto. 20.000 € Min-Ziel ist also „muss Bonus mitnehmen, sonst knapp". Passt zu „Profi". |
| **Zeitlimit (Sim-Stunden)** | 24 | 48 | 72 | A / S | =A. 24h L1 ist genug für 1 Auftrag (4.5h Wien→Salzburg + Puffer). 48h L2 für 3 Aufträge mit Disposition. 72h L3 für 6+intermodal+Events. |
| **Initial timeScale** | 1× | 1× | 1× | PH 65.4 | Standard. Bearbeiter:in wechselt in der UI auf 4× oder 8× nach Bedarf. Kein Auto-Skip auf höhere Levels — bewusste Entscheidung sollen sie selbst treffen. |
| **Bahn aktiv?** | nein | nein | ja | A / PH 13.3 | =A. PH 13.3 sagt L3 „volle Grundlogik" inkl. Bahn. L1+L2 ohne Bahn vereinfacht Routing-Entscheidung erheblich (kein Mode-Choice). |
| **Häfen aktiv?** | nein | nein | ja | A / PH 13.3 | =A. Begründung wie Bahn — Schiffsankünfte als Auftragsquelle braucht didaktischen Boden, der erst auf L3 da ist. |
| **Intermodale Aufträge?** | nein | nein | ja | A / PH 13.3 | =A. Voraussetzung: Bahn + Häfen aktiv. |
| **Minigames?** | nein | optional | optional | PH 11.4 | =PH (optional ab L2). L1 nichts, weil schon mit Hauptmechanik beschäftigt. „Optional" heißt: per Lehrkraft-Config schaltbar (Phase 7). |
| **Sichtbare Locations** | nur `visible_from_level=1` (Hauptstädte) | bis Level 2 (+ Großstädte) | alle | PH 9.1 + Seed | Seed-Daten haben pro Location ein `visibleFromLevel`-Feld. Filter rendert nur sichtbare Marker — reduziert kognitive Last auf L1. Ab L3 alles sichtbar. |
### Begründungen je Level (Erzählebene)
**Level 1 — „Erster Auftrag":**
Tutorial. Ein einziger Auftrag (Wien → Salzburg, 300 km), ein einziger
TRUCK_SMALL. `BLINK_EXACT` zeigt Start- und Zielort blinkend auf der Karte.
Events deaktiviert (Abweichung von Atlas — siehe Tabelle, Zeile Event-Wahrsch.).
Kontingent: 8.000 € + 1.000 € Auftrag ~472 € Fahrkosten = klarer
positiver Abschluss bei Pünktlichkeit. Bonus „pünktlich" (+10 %) und
„keine Leerfahrt" (+20 %) spürbar, aber nicht überlebenswichtig.
**Ziel:** Vertrauen aufbauen, Mechanik verstehen, „ich kann das"-Erlebnis.
**Level 2 — „Drei Aufträge gleichzeitig":**
Erste echte Disposition. 3 parallele Aufträge in Mitteleuropa, alle 3
Fahrzeugtypen verfügbar. `SHOW_REGION` zeigt nur die Region des Zielorts —
Bearbeiter:in muss überlegen, welcher Ort innerhalb dieser Region passt.
Events bei 50 % der PH-Werte (Unfall 2.5 %/h, Schnee 5 %/h, Hafen 4 %/h).
Knappe 5.000 € Startbudget zwingt zu Effizienz. Zwei dumme Leerfahrten
machen die Bilanz schon negativ. **Ziel:** Strategie-Bewusstsein,
Fahrzeug-zu-Auftrag-Match lernen.
**Level 3 — „Profi-Disposition Europa":**
Volles Modell. 6 parallele Aufträge, intermodale Ketten (LKW → Zug → LKW),
Häfen mit Schiffsankünften (= zusätzliche Container, neue Aufträge),
Bahn-Dijkstra über 5 Knoten. `NONE` — alle Orte selbst suchen.
Events bei 100 % der PH-Werte. 2.500 € Startbudget verzeiht keinen
Patzer. **Ziel:** Wer hier mit positivem Kontostand abschließt, hat
das Modell verstanden — auch wirtschaftlich.
---
## 2. Verbindliche Konstanten (aus Kap 65 — gleich für alle Level)
> Über `LogistikEngine.VEHICLE_DEFAULTS`, `.ECONOMY`, `.EVENT_RULES`
> bereits implementiert. **Nicht überschreiben pro Level**, nur via
> Tuning-Iteration mit Atlas-Approval.
### Fahrzeuge
| Typ | Speed | Kapazität | €/km | €/h | Lade min | Entlade min |
|--------------|---------|-----------|------|------|----------|-------------|
| TRUCK_SMALL | 70 km/h | 1 Cont. | 1.20 | 25 | 5 | 5 |
| TRUCK_LARGE | 60 km/h | 3 Cont. | 2.50 | 45 | 8 | 8 |
| TRAIN | 90 km/h | 20 Cont. | 8.00 | 150 | 20/Block | 20 |
### Aufträge
| Typ | Basiswert | Container | Frist (h) |
|------------------|-----------|-----------|------------------------|
| Standardauftrag | 1.000 € | 13 | distanceKm/60 × 1.5 |
| Eilauftrag | 1.500 € | 12 | distanceKm/70 |
### Bonus / Malus
- Pünktlich: **+10 %**
- Optimal (keine Leerfahrt): **+20 %**
- Strafe pro Stunde Verspätung: **20 % Auftragswert** (per Level via `latePenaltyOverride` anpassbar)
### Events (pro Stunde, Basis-Werte)
| Event | Basis-Wahrsch./h | Wirkung |
|-----------------|---------------------|-----------------------|
| TRAFFIC_ACCIDENT| 5 % | Speed × 0.5 für Event-Dauer |
| SNOW (Alpen) | 10 % | Speed × 0.7 |
| PORT_DELAY | 8 % | Ladezeit × 1.5 |
Pro Level multipliziert mit `eventProbabilityMultiplier` (0/0.5/1.0).
### Zeit
- 1 Tick = 250 ms Echtzeit
- 1× = 1 Sim-Min/Echt-Sek
- 4× = 4 Sim-Min/Echt-Sek
- 8× = 8 Sim-Min/Echt-Sek (je nach Stabilität)
### Bahnnetz (aus `lg-railnet.json`, von Atlas bestätigt 01:45)
Knoten: Wien, München, Hamburg, Rotterdam, Paris (5)
Kanten:
- Wien ↔ München: 400 km
- München ↔ Hamburg: 800 km
- Hamburg ↔ Rotterdam: 500 km
- Paris ↔ Rotterdam: 520 km (durationMinutes: 347)
- Paris ↔ München: 820 km (durationMinutes: 547)
Test-Pfade für Dijkstra:
- Wien → Hamburg via München = 400 + 800 = **1.200 km**
- Wien → Paris via München = 400 + 820 = **1.220 km**
- Wien → Rotterdam (kürzer): via München+Hamburg = 400+800+500 = **1.700 km** (vs. via Paris = 400+820+520 = 1.740 km)
---
## 3. Strategie-Profile (für Headless-Runner)
| Strategie | Verhalten | Erwartung L1 | Erwartung L2 | Erwartung L3 |
|-----------|-----------|--------------|--------------|--------------|
| `noop` | Keine Aktion. Zeit läuft ab, kein Erlös. | fail | fail | fail |
| `naive` | Erstes Fahrzeug, nächster Auftrag, FIFO. | **muss** schaffen (≥ 90 %) | knapp / fail | fail (sicher) |
| `greedy` | Höchster Auftragswert zuerst, größtes Fahrzeug. | schafft locker | schafft (~ 70 %) | fail / knapp |
| `optimal` | Orakel — perfekte Routenplanung, keine Leerfahrten. | schafft perfekt | schafft mit Reserve | **muss** schaffen (~ 90 %) |
`noop` ist Test-Strategie für Phase 0: Beweist, dass Runner-Vertrag,
Seed-RNG, Ergebnisstruktur und Loop-Steuerung funktionieren — ohne
dass `engine.tick` implementiert sein muss.
### Akzeptanzkorridor (formal)
```
runLevel(1, *, 'naive') → success: true, endBalance ≥ +1.000 €
runLevel(1, *, 'greedy') → success: true, endBalance ≥ +2.000 €
runLevel(1, *, 'optimal') → success: true, endBalance ≥ +3.000 €
runLevel(2, *, 'naive') → success: false (oder negativ)
runLevel(2, *, 'greedy') → success: true, endBalance ≥ 0
runLevel(2, *, 'optimal') → success: true, endBalance ≥ +5.000 €
runLevel(3, *, 'naive') → success: false (sicher)
runLevel(3, *, 'greedy') → success: false oder knapp negativ
runLevel(3, *, 'optimal') → success: true, endBalance ≥ 0
```
Pro Level werden **5 Seeds** (15) gespielt. Kriterium gilt für **Median**,
nicht Worst-Case (sonst kein Spielraum für Pech). Ergebnisse landen in
`headless-runner.html`-Tabelle.
---
## 4. Tuning-Protokoll
Wenn nach Phase 2/3 die Akzeptanz-Korridore nicht erreicht werden:
1. **Erst** prüfen, ob die Strategie wirklich „naive"/„optimal" ist —
schlechte Strategien lassen Level-Werte gut aussehen.
2. **Dann** an *einem* Parameter pro Iteration drehen. Nie mehrere
gleichzeitig — sonst nicht messbar.
3. Jede Tuning-Änderung mit kurzem Eintrag im §8 Änderungslog dokumentieren.
4. Bei Strukturveränderungen (Hilfestufe oder Fahrzeuge) Atlas-Review
anfragen, weil dann Lehrplan-Anker möglicherweise nicht mehr passen.
---
## 5. JSON-Repräsentation für `game_levels.params`
```json
{
"level_1": {
"startBudget": 8000,
"maxActiveContracts": 1,
"availableVehicleTypes": ["TRUCK_SMALL"],
"vehicleCount": 1,
"deadlineMultiplier": 1.5,
"defaultHintMode": "BLINK_EXACT",
"eventProbabilityMultiplier": 0,
"latePenaltyOverride": 0.10,
"rentalCostPerDay": 0,
"idleCostPerHour": 0,
"containerStandCostPerHour": 0,
"minTargetEarnings": 3000,
"timeLimitHours": 24,
"initialTimeScale": 1,
"railEnabled": false,
"portsEnabled": false,
"intermodalEnabled": false,
"minigamesEnabled": false,
"visibleLocationLevel": 1
},
"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,
"containerStandCostPerHour": 0,
"minTargetEarnings": 10000,
"timeLimitHours": 48,
"initialTimeScale": 1,
"railEnabled": false,
"portsEnabled": false,
"intermodalEnabled": false,
"minigamesEnabled": true,
"visibleLocationLevel": 2
},
"level_3": {
"startBudget": 2500,
"maxActiveContracts": 6,
"availableVehicleTypes": ["TRUCK_SMALL", "TRUCK_LARGE", "TRAIN"],
"vehicleCount": 5,
"deadlineMultiplier": 1.0,
"defaultHintMode": "NONE",
"eventProbabilityMultiplier": 1.0,
"latePenaltyOverride": 0.30,
"rentalCostPerDay": 500,
"idleCostPerHour": 5,
"containerStandCostPerHour": 10,
"minTargetEarnings": 20000,
"timeLimitHours": 72,
"initialTimeScale": 1,
"railEnabled": true,
"portsEnabled": true,
"intermodalEnabled": true,
"minigamesEnabled": true,
"visibleLocationLevel": 3
}
}
```
---
## 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.
```sql
-- Logistik Europa — 3 Level-Konfigurationen (Phase 0)
-- Voraussetzung: module_info-Eintrag 'logistik' existiert (Atlas hat angelegt)
INSERT INTO `game_levels`
(`game_id`, `sort_order`, `level_name`, `level_name_easy`, `params`, `created_at`)
VALUES
(
'logistik', 1, 'Lernen', 'Erster Auftrag',
JSON_OBJECT(
'startBudget', 8000,
'maxActiveContracts', 1,
'availableVehicleTypes', JSON_ARRAY('TRUCK_SMALL'),
'vehicleCount', 1,
'deadlineMultiplier', 1.5,
'defaultHintMode', 'BLINK_EXACT',
'eventProbabilityMultiplier', 0,
'latePenaltyOverride', 0.10,
'rentalCostPerDay', 0,
'idleCostPerHour', 0,
'containerStandCostPerHour', 0,
'minTargetEarnings', 3000,
'timeLimitHours', 24,
'initialTimeScale', 1,
'railEnabled', FALSE,
'portsEnabled', FALSE,
'intermodalEnabled', FALSE,
'minigamesEnabled', FALSE,
'visibleLocationLevel', 1
),
NOW()
),
(
'logistik', 2, 'Übung', 'Drei Aufträge',
JSON_OBJECT(
'startBudget', 5000,
'maxActiveContracts', 3,
'availableVehicleTypes', JSON_ARRAY('TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'),
'vehicleCount', 3,
'deadlineMultiplier', 1.2,
'defaultHintMode', 'SHOW_REGION',
'eventProbabilityMultiplier', 0.5,
'latePenaltyOverride', 0.20,
'rentalCostPerDay', 200,
'idleCostPerHour', 5,
'containerStandCostPerHour', 0,
'minTargetEarnings', 10000,
'timeLimitHours', 48,
'initialTimeScale', 1,
'railEnabled', FALSE,
'portsEnabled', FALSE,
'intermodalEnabled', FALSE,
'minigamesEnabled', TRUE,
'visibleLocationLevel', 2
),
NOW()
),
(
'logistik', 3, 'Profi', 'Profi-Disposition',
JSON_OBJECT(
'startBudget', 2500,
'maxActiveContracts', 6,
'availableVehicleTypes', JSON_ARRAY('TRUCK_SMALL', 'TRUCK_LARGE', 'TRAIN'),
'vehicleCount', 5,
'deadlineMultiplier', 1.0,
'defaultHintMode', 'NONE',
'eventProbabilityMultiplier', 1.0,
'latePenaltyOverride', 0.30,
'rentalCostPerDay', 500,
'idleCostPerHour', 5,
'containerStandCostPerHour', 10,
'minTargetEarnings', 20000,
'timeLimitHours', 72,
'initialTimeScale', 1,
'railEnabled', TRUE,
'portsEnabled', TRUE,
'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
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.
---
## 8. Änderungshistorie
- **2026-04-20** v0.2 — Rationale-Spalte je Parameter, INSERT-SQLs,
Konsistenz-Checks an Atlas, `eventProbabilityMultiplier` als
Multiplikator umstrukturiert, Container-Standkosten ergänzt, `noop`-
Strategie zur Strategieliste
- **2026-04-20** v0.1 — Erste Fassung (Logistik)
+361
View File
@@ -0,0 +1,361 @@
/**
* Logistik Europa — Engine (Skelett)
* ------------------------------------------------------------------
* Headless Simulationslogik ohne DOM, ohne Rendering. Die UI
* (game.html, test.html) ruft Methoden auf und verarbeitet
* zurückgegebene Events selbst.
*
* Exportiert als globales `window.LogistikEngine` (kein ES-Module,
* kein Bundler).
*
* Dieses Skelett enthält:
* - State-Machine-Enum (aus Pflichtenheft Kap 44)
* - Enum-Konstanten für Fahrzeugmodi, Vertrags-/Fahrzeug-Zustände,
* Ereignistypen, Hilfestufen (Kap 42)
* - Platzhalter-Funktionen für createGame, tick, assignContract,
* calculateRoute, useHint, applyMinigameResult
* - Test-Hooks für die Regressionstests aus dem Kickoff-Briefing
*
* Phase 0 (Balance-Matrix + Test-Harness) soll dieses Skelett nutzen,
* aber die Implementierung bleibt bewusst Stub, damit die Tests
* zuerst das Contract testen (TDD-ähnlich).
*/
(function () {
'use strict';
/* ============================================================
ENUMS (aus Pflichtenheft Kap 42, 44)
============================================================ */
const STATE = Object.freeze({
INIT: 'INIT',
LOADING_CONTENT: 'LOADING_CONTENT',
READY: 'READY',
PLANNING: 'PLANNING',
RUNNING: 'RUNNING',
PAUSED_BY_EVENT: 'PAUSED_BY_EVENT',
PAUSED_BY_ARRIVAL: 'PAUSED_BY_ARRIVAL',
MINIGAME: 'MINIGAME',
LEVEL_SUCCESS: 'LEVEL_SUCCESS',
LEVEL_FAILED: 'LEVEL_FAILED',
SAVING: 'SAVING',
ERROR: 'ERROR',
});
const VEHICLE_MODE = Object.freeze({
TRUCK_SMALL: 'TRUCK_SMALL',
TRUCK_LARGE: 'TRUCK_LARGE',
TRAIN: 'TRAIN',
});
const VEHICLE_STATE = Object.freeze({
IDLE: 'IDLE',
RESERVED: 'RESERVED',
MOVING: 'MOVING',
LOADING: 'LOADING',
UNLOADING: 'UNLOADING',
WAITING: 'WAITING',
BLOCKED: 'BLOCKED',
BROKEN: 'BROKEN',
});
const CONTRACT_STATE = Object.freeze({
OPEN: 'OPEN',
RESERVED: 'RESERVED',
ASSIGNED: 'ASSIGNED',
PICKUP_PENDING: 'PICKUP_PENDING',
LOADING: 'LOADING',
IN_TRANSIT: 'IN_TRANSIT',
AT_TRANSFER: 'AT_TRANSFER',
DELIVERED: 'DELIVERED',
LATE: 'LATE',
FAILED: 'FAILED',
CANCELLED: 'CANCELLED',
});
const LOCATION_TYPE = Object.freeze({
CITY: 'CITY',
CAPITAL: 'CAPITAL',
PORT: 'PORT',
TERMINAL: 'TERMINAL',
INDUSTRY: 'INDUSTRY',
CUSTOMER: 'CUSTOMER',
HUB: 'HUB',
});
const EVENT_TYPE = Object.freeze({
TRAFFIC_ACCIDENT: 'TRAFFIC_ACCIDENT',
SNOW: 'SNOW',
STORM: 'STORM',
PORT_DELAY: 'PORT_DELAY',
RAIL_STRIKE: 'RAIL_STRIKE',
SPECIAL_ORDER: 'SPECIAL_ORDER',
MAINTENANCE: 'MAINTENANCE',
});
const HINT_MODE = Object.freeze({
BLINK_EXACT: 'BLINK_EXACT',
SHOW_COUNTRY: 'SHOW_COUNTRY',
SHOW_REGION: 'SHOW_REGION',
DISTANCE_FEEDBACK: 'DISTANCE_FEEDBACK',
NONE: 'NONE',
});
/* ============================================================
KONSTANTEN — Pflichtwerte aus Pflichtenheft Kap 65
Diese sind Startwerte. Balance-Matrix in Phase 0 überschreibt
sie ggf. pro Level.
============================================================ */
const VEHICLE_DEFAULTS = Object.freeze({
TRUCK_SMALL: { speedKmh: 70, capacity: 1, costPerKm: 1.2, costPerHour: 25, loadMinutes: 5, unloadMinutes: 5 },
TRUCK_LARGE: { speedKmh: 60, capacity: 3, costPerKm: 2.5, costPerHour: 45, loadMinutes: 8, unloadMinutes: 8 },
TRAIN: { speedKmh: 90, capacity: 20, costPerKm: 8, costPerHour: 150, loadMinutes: 20, unloadMinutes: 20 },
});
const ECONOMY = Object.freeze({
standCostContainerPerHour: 10, // Container im Hafen
standCostVehicleIdlePerHour: 5,
latePenaltyPercentPerHour: 0.2, // 20% des Auftragswerts
punctualBonusPercent: 0.1, // +10%
optimalBonusPercent: 0.2, // +20% (keine Leerfahrt)
standardContractBase: 1000,
expressContractBase: 1500,
});
const EVENT_RULES = Object.freeze({
TRAFFIC_ACCIDENT: { probabilityPerHour: 0.05, speedMult: 0.5 },
SNOW: { probabilityPerHour: 0.10, speedMult: 0.7, regions: ['alps'] },
PORT_DELAY: { probabilityPerHour: 0.08, loadMult: 1.5 },
});
/* ============================================================
FACTORIES
============================================================ */
/**
* Erzeugt einen neuen Spielzustand für das gegebene Level.
* Merged Level-Params aus `window.LOGISTIK_LEVELS` mit Defaults.
*
* @param {number} levelNum
* @returns {Object} gameState
*/
function createGame(levelNum) {
const levelCfg = (window.LOGISTIK_LEVELS || []).find(l => l.level === levelNum)?.params || {};
return {
sessionId: _uuid(),
levelId: 'level_' + levelNum,
state: STATE.INIT,
simulationTime: new Date().toISOString(),
timeScale: levelCfg.initialTimeScale ?? 1,
paused: true,
balance: levelCfg.startBudget ?? 5000,
score: 0,
activeContracts: [],
completedContracts: [],
vehicles: [],
activeRoutes: [],
activeEvents: [],
pendingNotifications: [],
usedHints: [],
analytics: {
totalKm: 0,
emptyKm: 0,
lateDeliveries: 0,
hintUsages: 0,
misclicks: 0,
},
worldState: {},
version: '0.1.0',
_pendingEvents: [],
_config: levelCfg,
};
}
/* ============================================================
PLACEHOLDER / STUB FUNKTIONEN
(Logistik-Instanz implementiert diese in Phase 17)
============================================================ */
/** @throws nicht implementiert — Phase 2 */
function tick(game, deltaMs) {
throw new Error('LogistikEngine.tick: nicht implementiert (Phase 2)');
}
/** @throws nicht implementiert — Phase 2 */
function assignContract(game, contractId, vehicleId) {
throw new Error('LogistikEngine.assignContract: nicht implementiert (Phase 2)');
}
/** @throws nicht implementiert — Phase 2/3 */
function calculateRoute(game, originId, targetId, mode) {
throw new Error('LogistikEngine.calculateRoute: nicht implementiert (Phase 2)');
}
/** @throws nicht implementiert — Phase 5 */
function useHint(game, contractId, hintMode) {
throw new Error('LogistikEngine.useHint: nicht implementiert (Phase 5)');
}
/** @throws nicht implementiert — Phase 6 */
function applyMinigameResult(game, result) {
throw new Error('LogistikEngine.applyMinigameResult: nicht implementiert (Phase 6)');
}
/* ============================================================
REINE HELPER (können / sollen Phase 0 schon getestet werden)
============================================================ */
/**
* Fahrkosten für eine Strecke. Pflichttest #1 aus Kickoff-Briefing.
* @param {number} distanceKm
* @param {number} durationHours
* @param {'TRUCK_SMALL'|'TRUCK_LARGE'|'TRAIN'} mode
* @returns {number} Kosten in €
*/
function travelCost(distanceKm, durationHours, mode) {
const v = VEHICLE_DEFAULTS[mode];
if (!v) throw new Error('Unbekannter Fahrzeugmodus: ' + mode);
return distanceKm * v.costPerKm + durationHours * v.costPerHour;
}
/**
* Strafkosten bei Verspätung. Pflichttest #3.
* @param {number} contractValue
* @param {number} hoursLate
* @returns {number} Strafe in €
*/
function latePenalty(contractValue, hoursLate) {
return Math.max(0, contractValue * ECONOMY.latePenaltyPercentPerHour * hoursLate);
}
/**
* Bonus-Kombi: pünktlich + optimal. Pflichttest #4.
* @param {number} contractValue
* @param {{ onTime: boolean, noEmpty: boolean }} flags
* @returns {number} Bonus in €
*/
function calculateBonus(contractValue, flags) {
let bonus = 0;
if (flags.onTime) bonus += contractValue * ECONOMY.punctualBonusPercent;
if (flags.noEmpty) bonus += contractValue * ECONOMY.optimalBonusPercent;
return bonus;
}
/**
* Interpolation entlang einer Polyline (lat/lon-Paare).
* Pflichttest #2.
* @param {[number,number][]} polyline
* @param {number} progress 0..1
* @returns {[number,number]} [lat, lon]
*/
function interpolateAlongPolyline(polyline, progress) {
if (!polyline || polyline.length < 2) throw new Error('Polyline zu kurz');
const p = Math.max(0, Math.min(1, progress));
if (p === 0) return polyline[0];
if (p === 1) return polyline[polyline.length - 1];
const total = _polylineLength(polyline);
const target = p * total;
let acc = 0;
for (let i = 0; i < polyline.length - 1; i++) {
const d = _segmentLength(polyline[i], polyline[i + 1]);
if (acc + d >= target) {
const local = (target - acc) / d;
const [lat1, lon1] = polyline[i];
const [lat2, lon2] = polyline[i + 1];
return [lat1 + (lat2 - lat1) * local, lon1 + (lon2 - lon1) * local];
}
acc += d;
}
return polyline[polyline.length - 1];
}
/**
* Dijkstra über Bahnnetz. Pflichttest #7.
* Graph: { nodes:[{id}], edges:[{from,to,distanceKm}] } — ungerichtet.
* @param {{nodes:Array, edges:Array}} graph
* @param {string} startId
* @param {string} endId
* @returns {{path:string[], distanceKm:number}|null}
*/
function railShortestPath(graph, startId, endId) {
const adj = new Map();
graph.nodes.forEach(n => adj.set(n.id, []));
graph.edges.forEach(e => {
if (!adj.has(e.from) || !adj.has(e.to)) return;
adj.get(e.from).push({ to: e.to, d: e.distanceKm });
adj.get(e.to).push({ to: e.from, d: e.distanceKm });
});
const dist = new Map();
const prev = new Map();
const queue = new Set(graph.nodes.map(n => n.id));
graph.nodes.forEach(n => dist.set(n.id, Infinity));
dist.set(startId, 0);
while (queue.size) {
let u = null, best = Infinity;
for (const id of queue) if (dist.get(id) < best) { best = dist.get(id); u = id; }
if (u === null || u === endId) break;
queue.delete(u);
for (const { to, d } of adj.get(u) || []) {
if (!queue.has(to)) continue;
const alt = dist.get(u) + d;
if (alt < dist.get(to)) {
dist.set(to, alt);
prev.set(to, u);
}
}
}
if (!isFinite(dist.get(endId))) return null;
const path = [];
let cur = endId;
while (cur !== undefined) { path.unshift(cur); cur = prev.get(cur); }
return { path, distanceKm: dist.get(endId) };
}
/* ============================================================
PRIVATE
============================================================ */
function _uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function _segmentLength(a, b) {
const [lat1, lon1] = a;
const [lat2, lon2] = b;
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function _polylineLength(poly) {
let sum = 0;
for (let i = 0; i < poly.length - 1; i++) sum += _segmentLength(poly[i], poly[i + 1]);
return sum;
}
/* ============================================================
EXPORT
============================================================ */
window.LogistikEngine = {
// Enums
STATE, VEHICLE_MODE, VEHICLE_STATE, CONTRACT_STATE,
LOCATION_TYPE, EVENT_TYPE, HINT_MODE,
// Konstanten
VEHICLE_DEFAULTS, ECONOMY, EVENT_RULES,
// Factories
createGame,
// Stubs (Phase 1+)
tick, assignContract, calculateRoute, useHint, applyMinigameResult,
// Helper (Phase 0 testbar)
travelCost, latePenalty, calculateBonus,
interpolateAlongPolyline, railShortestPath,
};
})();
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logistik Europa — Simulation</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="../../assets/fonts/inter.css">
<link rel="stylesheet" href="../../assets/css/design-system.css">
<style>
/* ===== Modul-spezifische Styles (minimal) =====
Regel: Alles, was auch andere Module brauchen könnten, gehört ins
Design-System (mit Atlas absprechen). Hier nur Logistik-Spezifisches. */
html, body { height: 100%; margin: 0; padding: 0; font-family: 'Inter', system-ui, sans-serif; background: var(--ggs-bg); color: var(--ggs-text); }
/* Layout-Raster: Header + Hauptbereich (3 Spalten) + Status-Leiste + Didaktik */
.lg-layout { display: grid; grid-template-rows: auto 1fr auto auto; height: 100vh; }
.lg-main { display: grid; grid-template-columns: 240px 1fr 240px; gap: 8px; padding: 8px; min-height: 0; }
.lg-panel { background: var(--ggs-white); border-radius: var(--ggs-radius-md); padding: 12px; overflow-y: auto; box-shadow: var(--ggs-shadow-sm); }
.lg-map { background: var(--ggs-white); border-radius: var(--ggs-radius-md); overflow: hidden; box-shadow: var(--ggs-shadow-sm); position: relative; }
#map { width: 100%; height: 100%; }
.lg-status { display: flex; gap: 12px; align-items: center; padding: 8px 16px; background: var(--ggs-white); border-top: 1px solid var(--ggs-border); font-size: 0.85rem; }
.lg-didaktik { max-height: 120px; overflow-y: auto; padding: 8px 16px; background: var(--ggs-bg); border-top: 1px solid var(--ggs-border); font-size: 0.82rem; }
/* iPad Landscape (1180×820) */
@media (min-width: 900px) and (max-width: 1199px) {
.lg-main { grid-template-columns: 215px 1fr 215px; }
}
/* Phone / schmale Tablets */
@media (max-width: 899px) {
.lg-main { grid-template-columns: 1fr; grid-template-rows: auto auto 300px; }
}
/* Platzhalter-Skelett — wird von Logistik-Instanz in Phase 1+ befüllt */
.lg-placeholder { padding: 40px 20px; text-align: center; color: var(--ggs-text-muted); }
.lg-placeholder h2 { font-size: 1.2rem; margin-bottom: 10px; color: var(--ggs-text); }
.lg-placeholder code { display: inline-block; padding: 2px 6px; background: var(--ggs-bg); border-radius: 4px; font-size: 0.8rem; }
</style>
</head>
<body>
<!-- Injection-Point für logistik.php-Wrapper (BASE_PATH, Seeds, Levels, Sim-Name) -->
<script>
/*__LOGISTIK_INJECTION__*/
// Fallback für direkten HTML-Aufruf (Dev-Modus ohne PHP-Wrapper)
if (!window.LOGISTIK_BASE) {
window.LOGISTIK_BASE = '';
window.LOGISTIK_SIM_NAME = 'Logistik Europa (Dev)';
window.LOGISTIK_SEEDS = { locations: [], vehicleTypes: [], cargoTypes: [], railnet: { nodes: [], edges: [] }, contractTemplates: [] };
window.LOGISTIK_LEVELS = [];
}
</script>
<div class="lg-layout">
<!-- ============= Header ============= -->
<header class="ggs-header">
<div class="ggs-header-left">
<img src="../../assets/img/bildLogo.png" alt="" class="ggs-logo-img">
<img src="../../assets/img/textlogo_geograsim.svg" alt="GeoGraSim" class="ggs-logo-text">
<span class="ggs-sim-badge" id="simName">Logistik Europa</span>
</div>
<div class="ggs-header-right">
<div class="ggs-speed-ctrl" id="speedCtrl">
<button class="ggs-btn-icon" data-speed="1">1×</button>
<button class="ggs-btn-icon" data-speed="2">2×</button>
<button class="ggs-btn-icon" data-speed="4">4×</button>
<button class="ggs-btn-icon" data-speed="8">8×</button>
</div>
<button class="ggs-btn-icon" title="Speichern" id="btnSave">💾</button>
<button class="ggs-btn-icon" title="Laden" id="btnLoad">📂</button>
<a class="ggs-btn-icon" title="Cockpit" href="../../sim">🏠</a>
</div>
</header>
<!-- ============= Hauptbereich ============= -->
<main class="lg-main">
<!-- Links: Aufträge -->
<aside class="lg-panel" id="contractsPanel">
<div class="lg-placeholder">
<h2>Aufträge</h2>
<p>Panel wird von Logistik-Instanz in Phase 2 befüllt.</p>
<p><code>#contractsPanel</code></p>
</div>
</aside>
<!-- Mitte: Karte -->
<section class="lg-map">
<div id="map"></div>
</section>
<!-- Rechts: Fahrzeuge -->
<aside class="lg-panel" id="vehiclesPanel">
<div class="lg-placeholder">
<h2>Fahrzeuge</h2>
<p>Panel wird von Logistik-Instanz in Phase 2/3 befüllt.</p>
<p><code>#vehiclesPanel</code></p>
</div>
</aside>
</main>
<!-- ============= Status-Leiste ============= -->
<div class="lg-status">
<span>Zeit: <b id="statusTime"></b></span>
<span>Konto: <b id="statusBalance"></b></span>
<span>Score: <b id="statusScore"></b></span>
<span style="flex: 1"></span>
<span id="statusState" class="ggs-badge">INIT</span>
</div>
<!-- ============= Didaktikfenster (einklappbar) ============= -->
<div class="lg-didaktik" id="didaktik">
<em>Willkommen in der Simulation „Logistik Europa". Diese Seite ist ein leeres Gerüst — die Logistik-Instanz baut hier die Kern-Simulation in mehreren Phasen.</em>
</div>
</div>
<!-- ============= Scripts ============= -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="engine.js"></script>
<script>
// === Gerüst-Initialisierung — Logistik-Instanz erweitert in Phase 1+ ===
document.getElementById('simName').textContent = window.LOGISTIK_SIM_NAME || 'Logistik Europa';
// Leaflet-Karte mit Carto Positron (heller, didaktisch ruhiger)
const map = L.map('map', {
center: [50.5, 10.5], // Zentral-Europa
zoom: 4,
minZoom: 4,
maxZoom: 10,
zoomControl: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(map);
// Skelett: Engine-Game erzeugen (Level 1 per Default)
const game = LogistikEngine.createGame(1);
document.getElementById('statusState').textContent = game.state;
document.getElementById('statusBalance').textContent = game.balance.toLocaleString('de-DE') + ' €';
document.getElementById('statusScore').textContent = game.score;
document.getElementById('statusTime').textContent = new Date(game.simulationTime).toLocaleString('de-DE');
// === TODO Logistik-Instanz — Phase 1+ ===
// - Seed-Daten auf Karte darstellen (Marker pro Location)
// - Contracts-Panel befüllen
// - Vehicle-Panel befüllen
// - Tick-Loop starten
// - Speed-Buttons verdrahten
// - Save/Load an API binden (App/php/api/logistik-*.php)
console.info('[Logistik-Gerüst] Engine bereit, ' + (window.LOGISTIK_SEEDS.locations.length || 0) + ' Locations geladen.');
</script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Logistik Europa — Headless-Runner</title>
<style>
body { font-family: 'Segoe UI', system-ui, sans-serif; max-width: 1100px; margin: 24px auto; padding: 0 16px; color: #222; background: #f7f5ef; }
h1 { font-size: 1.4rem; margin-bottom: 8px; }
h2 { font-size: 1.05rem; color: #1f4b37; margin-top: 24px; }
.meta { color: #666; font-size: 0.9rem; margin-bottom: 20px; }
.panel { background: #fff; border: 1px solid #e2ddd1; border-radius: 8px; padding: 14px 18px; margin: 14px 0; }
table { border-collapse: collapse; width: 100%; font-size: 0.88rem; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #eee; }
th { background: #f0ece3; font-weight: 600; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.ok { color: #2d6d3d; font-weight: 600; }
.fail { color: #b32d2d; font-weight: 600; }
.skip { color: #8a7a4f; font-style: italic; }
.err { color: #b32d2d; font-style: italic; }
button { background: #1f4b37; color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; font-size: 0.95rem; margin-right: 8px; }
button:hover { background: #2d6d4d; }
pre { background: #2b2b2b; color: #e6e6e6; padding: 12px; border-radius: 6px; font-size: 0.85rem; overflow-x: auto; }
.legend { font-size: 0.82rem; color: #666; margin-top: 6px; }
details > summary { cursor: pointer; color: #1f4b37; font-weight: 600; }
</style>
</head>
<body>
<h1>Logistik Europa — Headless-Runner</h1>
<p class="meta">
Deterministische Level-Durchläufe für die Balance-Verifikation aus
<code>balance-matrix.md</code> §3. Logik liegt komplett in
<code>headless-runner.js</code> — diese Seite ist nur die UI-Hülle.
</p>
<div class="panel">
<p>
<button id="run-noop">Nur <code>noop</code> (3 Level × 5 Seeds, läuft)</button>
<button id="run-all">Alle Strategien (Stubs werfen)</button>
</p>
<div id="results"><p class="legend">Klick auf einen Button.</p></div>
</div>
<h2>Akzeptanzkorridor (aus balance-matrix.md §3)</h2>
<table>
<thead><tr><th>Level</th><th>noop</th><th>naive</th><th>greedy</th><th>optimal</th></tr></thead>
<tbody>
<tr><td>1</td><td>fail</td><td>success ≥+1.000 €</td><td>success ≥+2.000 €</td><td>success ≥+3.000 €</td></tr>
<tr><td>2</td><td>fail</td><td>fail / negativ</td><td>success ≥0 €</td><td>success ≥+5.000 €</td></tr>
<tr><td>3</td><td>fail</td><td>fail (sicher)</td><td>fail / knapp</td><td>success ≥0 €</td></tr>
</tbody>
</table>
<h2>Strategie-Definitionen</h2>
<ul id="strat-list"><!-- gefüllt aus LogistikRunner.STRATEGIES --></ul>
<h2>API</h2>
<pre>const r = await LogistikRunner.runLevel(1, 1, 'noop');
// { successful, endBalance, completedContracts, lateDeliveries,
// durationHours, hintUsages, log, strategy, level, seed }
const matrix = await LogistikRunner.runMatrix({
levels: [1,2,3], strategies: ['noop','naive','greedy','optimal'],
seeds: [1,2,3,4,5], tolerateErrors: true
});</pre>
<script src="engine.js"></script>
<script src="headless-runner.js"></script>
<script>
(function () {
'use strict';
// Strategie-Liste rendern
const stratList = document.getElementById('strat-list');
Object.entries(LogistikRunner.STRATEGIES).forEach(([name, def]) => {
const li = document.createElement('li');
li.innerHTML = '<strong>' + name + '</strong> — ' + def.description;
stratList.appendChild(li);
});
function renderTable(rows) {
let html = '<table><thead><tr>'
+ '<th>Lvl</th><th>Strategie</th><th>Seed</th>'
+ '<th>Status</th><th class="num">Balance</th>'
+ '<th class="num">Aufträge</th><th class="num">Verspätet</th>'
+ '<th class="num">Dauer (h)</th><th>Notiz</th></tr></thead><tbody>';
rows.forEach(r => {
let cls, label, note = '';
if (r._error) {
cls = 'err'; label = 'ERROR'; note = r._error;
} else if (r.successful === null) {
cls = 'skip'; label = 'SKIP';
} else if (r.successful) {
cls = 'ok'; label = 'success';
} else {
cls = 'fail'; label = 'fail';
}
html += '<tr>'
+ '<td>' + r.level + '</td>'
+ '<td><code>' + r.strategy + '</code></td>'
+ '<td>' + r.seed + '</td>'
+ '<td class="' + cls + '">' + label + '</td>'
+ '<td class="num">' + (r.endBalance ?? '—') + '</td>'
+ '<td class="num">' + (r.completedContracts ?? 0) + '</td>'
+ '<td class="num">' + (r.lateDeliveries ?? 0) + '</td>'
+ '<td class="num">' + (r.durationHours ?? 0) + '</td>'
+ '<td>' + note + '</td>'
+ '</tr>';
});
html += '</tbody></table>';
return html;
}
async function runNoop() {
const root = document.getElementById('results');
root.innerHTML = '<p class="legend">…läuft…</p>';
const rows = await LogistikRunner.runMatrix({
strategies: ['noop'], tolerateErrors: true,
});
root.innerHTML = renderTable(rows)
+ '<p class="legend">' + rows.length + ' Durchläufe — '
+ '<code>noop</code> ist die Phase-0-Demo-Strategie. '
+ 'Alle Level → success=false ist erwartet.</p>';
}
async function runAll() {
const root = document.getElementById('results');
root.innerHTML = '<p class="legend">…läuft…</p>';
const rows = await LogistikRunner.runMatrix({ tolerateErrors: true });
root.innerHTML = renderTable(rows)
+ '<p class="legend">' + rows.length + ' Durchläufe — '
+ '<code>naive/greedy/optimal</code> werfen kontrolliert „Phase 2", '
+ '<code>noop</code> läuft sauber durch.</p>';
}
document.getElementById('run-noop').addEventListener('click', runNoop);
document.getElementById('run-all').addEventListener('click', runAll);
})();
</script>
</body>
</html>
+257
View File
@@ -0,0 +1,257 @@
/**
* Logistik Europa — Headless-Runner (Phase 0)
* ----------------------------------------------------------------
* Deterministische Level-Durchläufe für die Balance-Verifikation
* gemäß `balance-matrix.md` §3.
*
* Vertrag (aus Kickoff-Briefing §5.4):
* runLevel(levelNum, seed, strategy) → Promise<{
* successful: boolean,
* endBalance: number,
* completedContracts: number,
* lateDeliveries: number,
* durationHours: number,
* hintUsages: number,
* log: Array,
* strategy: string,
* level: number,
* seed: number
* }>
*
* Verfügbare Strategien:
* - `noop` — funktionierende Demo: keine Aktion, Zeit läuft ab,
* success=false. Beweist Runner-Vertrag in Phase 0.
* - `naive` — Skelett, wirft `Phase 2` (FIFO-Auftrag, erstes Fahrzeug).
* - `greedy` — Skelett, wirft `Phase 2` (höchster Wert zuerst).
* - `optimal` — Skelett, wirft `Phase 2` (Orakel mit Bonus-Maximierung).
*
* Browser-Nutzung:
* <script src="engine.js"></script>
* <script src="headless-runner.js"></script>
* <script>
* const r = await LogistikRunner.runLevel(1, 1, 'noop');
* console.log(r);
* </script>
*
* Node-Nutzung (sobald engine.js Node-Export bekommt):
* const Runner = require('./headless-runner');
* const Engine = require('./engine');
* const r = await Runner.runLevel(1, 1, 'noop', { engine: Engine });
*/
(function (root, factory) {
'use strict';
const api = factory();
if (typeof module !== 'undefined' && module.exports) {
module.exports = api; // Node / CommonJS
}
if (typeof root !== 'undefined') {
root.LogistikRunner = api; // Browser global
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
'use strict';
/* ============================================================
SEED-RNG (Mulberry32 — deterministisch, ausreichend für Tests)
Gleicher Seed → identische Sequenz auf jeder Plattform.
============================================================ */
function makeRng(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* ============================================================
ENGINE-RESOLVE (Browser via window, Node via opts.engine)
============================================================ */
function resolveEngine(opts) {
if (opts && opts.engine) return opts.engine;
if (typeof window !== 'undefined' && window.LogistikEngine) {
return window.LogistikEngine;
}
throw new Error(
'LogistikEngine nicht verfügbar. Im Browser: <script src="engine.js"> ' +
'vor diesem Skript einbinden. In Node: opts.engine übergeben.'
);
}
/* ============================================================
STRATEGIEN
Jede Strategie ist ein Objekt mit { description, run(ctx) }.
`ctx` enthält game, rng, engine, log, level, seed.
run() gibt das Result-Objekt zurück (gleicher Vertrag wie runLevel).
============================================================ */
const STRATEGIES = {
/**
* `noop` — funktionierende Demo-Strategie für Phase 0.
* Beweist, dass Runner-Vertrag, Seed-RNG, Loop-Steuerung und
* Ergebnisstruktur korrekt sind, OHNE engine.tick zu callen.
*
* Verhalten: keine Aktion, Sim-Zeit läuft bis Limit, kein Erlös,
* Bilanz bleibt = Startbudget, success=false (Min-Ziel-Erlös
* nicht erreicht).
*/
noop: {
description: 'Keine Aktion — Zeit läuft ab, kein Erlös.',
run(ctx) {
const { game, log } = ctx;
const cfg = game._config || {};
const limitHours = cfg.timeLimitHours || 24;
const stepHours = 1;
let simHours = 0;
let safety = 100000;
while (simHours < limitHours && safety-- > 0) {
simHours += stepHours;
log.push({ simHours, action: 'idle', balance: game.balance });
}
// noop schließt definitionsgemäß KEINE Aufträge ab → immer fail.
// (Auch wenn Min-Ziel-Erlös 0 wäre — die Strategie ist explizit
// dafür gedacht, „kein Lebenszeichen" zu beweisen.)
return {
successful: false,
endBalance: game.balance,
completedContracts: 0,
lateDeliveries: 0,
durationHours: simHours,
hintUsages: 0,
log,
};
},
},
/**
* `naive` — erstes Fahrzeug, nächster Auftrag, FIFO-Reihenfolge.
* Phase 2: aktiviert sobald engine.tick / assignContract /
* calculateRoute existieren. Bis dahin kontrollierter Stub.
*/
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).'
);
},
},
/**
* `greedy` — höchster Auftragswert zuerst, größtes Fahrzeug.
*/
greedy: {
description: 'Höchster Auftragswert zuerst, größtes Fahrzeug.',
run(ctx) {
throw new Error(
'Strategy "greedy" benötigt engine.tick / assignContract / ' +
'calculateRoute (Phase 2 — noch nicht implementiert).'
);
},
},
/**
* `optimal` — Orakel mit perfekter Routenplanung,
* keine Leerfahrten, Bonus-Maximierung.
*/
optimal: {
description: 'Orakel mit perfekter Routenplanung, keine Leerfahrten.',
run(ctx) {
throw new Error(
'Strategy "optimal" benötigt vollständige Engine inkl. Phase-3-' +
'Bahnrouting und Phase-5-Events (noch nicht implementiert).'
);
},
},
};
/* ============================================================
CORE — runLevel
============================================================ */
/**
* Spielt einen Level deterministisch durch und liefert Kennzahlen.
*
* @param {number} levelNum
* @param {number} seed Mulberry32-Seed
* @param {'noop'|'naive'|'greedy'|'optimal'} strategyName
* @param {Object} [opts]
* @param {Object} [opts.engine] LogistikEngine-Override (für Node)
* @returns {Promise<Object>}
*/
async function runLevel(levelNum, seed, strategyName, opts) {
const engine = resolveEngine(opts);
const strategy = STRATEGIES[strategyName];
if (!strategy) {
throw new Error('Unbekannte Strategie: ' + strategyName +
' (verfügbar: ' + Object.keys(STRATEGIES).join(', ') + ')');
}
const game = engine.createGame(levelNum);
const rng = makeRng(seed);
const log = [];
const ctx = { game, rng, engine, log, level: levelNum, seed };
const result = await strategy.run(ctx);
// Vertrag-Anreicherung — alle Felder garantieren
return Object.assign({
successful: false,
endBalance: 0,
completedContracts: 0,
lateDeliveries: 0,
durationHours: 0,
hintUsages: 0,
log: [],
}, result, {
strategy: strategyName,
level: levelNum,
seed,
});
}
/**
* Bequemer Wrapper: alle Kombinationen Level × Strategie × Seeds.
* @returns {Promise<Array>}
*/
async function runMatrix(opts) {
const o = opts || {};
const levels = o.levels || [1, 2, 3];
const strategies = o.strategies || ['noop', 'naive', 'greedy', 'optimal'];
const seeds = o.seeds || [1, 2, 3, 4, 5];
const tolerateErrors = o.tolerateErrors !== false;
const out = [];
for (const lv of levels) {
for (const st of strategies) {
for (const sd of seeds) {
try {
out.push(await runLevel(lv, sd, st, o));
} catch (e) {
if (!tolerateErrors) throw e;
out.push({
level: lv, strategy: st, seed: sd,
successful: null, _error: e.message,
endBalance: null, completedContracts: 0,
lateDeliveries: 0, durationHours: 0, hintUsages: 0,
log: [],
});
}
}
}
}
return out;
}
return {
runLevel,
runMatrix,
STRATEGIES,
makeRng,
_resolveEngine: resolveEngine, // exposed für Tests
};
});
+34
View File
@@ -0,0 +1,34 @@
{
"moduleId": "logistik",
"version": "0.1-draft",
"_comment": "Vorab-Skizze gemäß Pflichtenheft Kap 3.4 (28 Kernziele). Wird von Lehrplan-Instanz validiert und zu `active` hochgestuft, sobald Phase 2 zeigt, was die Simulation tatsächlich abdeckt. Lehrplan-Codes sind Platzhalter — müssen gegen `App/php/api/lehrplan.php` abgeglichen werden.",
"anchors": [
{ "id": "lg-orient-01", "kompetenz": "Räumliche Orientierung in Europa", "kernziel": "Länder erkennen", "phases": [1,2,3,4,5,6,7], "status": "proposed", "lehrplan_anker_draft": ["AT-GW-5-Raum-01"] },
{ "id": "lg-orient-02", "kompetenz": "Räumliche Orientierung in Europa", "kernziel": "Hauptstädte und wichtige Städte zuordnen", "phases": [1,2,3], "status": "proposed" },
{ "id": "lg-orient-03", "kompetenz": "Räumliche Orientierung in Europa", "kernziel": "Regionen Europas unterscheiden", "phases": [2,3,4], "status": "proposed" },
{ "id": "lg-orient-04", "kompetenz": "Räumliche Orientierung in Europa", "kernziel": "Häfen, Terminals und große Verkehrsachsen verorten", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-karte-01", "kompetenz": "Kartenkompetenz", "kernziel": "Karten lesen", "phases": [1,2,3,4,5,6,7], "status": "proposed" },
{ "id": "lg-karte-02", "kompetenz": "Kartenkompetenz", "kernziel": "Lagebeziehungen erkennen", "phases": [2,3,4], "status": "proposed" },
{ "id": "lg-karte-03", "kompetenz": "Kartenkompetenz", "kernziel": "Distanzen und Verkehrsnetze deuten", "phases": [2,3,4], "status": "proposed" },
{ "id": "lg-karte-04", "kompetenz": "Kartenkompetenz", "kernziel": "Suchstrategien auf Karten entwickeln", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-wirt-01", "kompetenz": "Wirtschafts- und Logistikverständnis", "kernziel": "Güterströme verstehen", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-wirt-02", "kompetenz": "Wirtschafts- und Logistikverständnis", "kernziel": "kombinierte Verkehre nachvollziehen", "phases": [4,5], "status": "proposed" },
{ "id": "lg-wirt-03", "kompetenz": "Wirtschafts- und Logistikverständnis", "kernziel": "Rolle von Häfen, Bahn und LKW erkennen", "phases": [3,4], "status": "proposed" },
{ "id": "lg-wirt-04", "kompetenz": "Wirtschafts- und Logistikverständnis", "kernziel": "Zeit-Kosten-Zusammenhänge verstehen", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-plan-01", "kompetenz": "Planungs- und Entscheidungskompetenz", "kernziel": "Reihenfolge von Aufträgen optimieren", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-plan-02", "kompetenz": "Planungs- und Entscheidungskompetenz", "kernziel": "Fahrzeuge passend einsetzen", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-plan-03", "kompetenz": "Planungs- und Entscheidungskompetenz", "kernziel": "Leerfahrten vermeiden", "phases": [3,4,5], "status": "proposed" },
{ "id": "lg-plan-04", "kompetenz": "Planungs- und Entscheidungskompetenz", "kernziel": "knappe Ressourcen disponieren", "phases": [4,5,6,7], "status": "proposed" },
{ "id": "lg-verk-01", "kompetenz": "Verkehrserziehung und Sicherheitsbewusstsein", "kernziel": "Einfluss von Wetter, Sperren, Störungen erkennen", "phases": [5,6], "status": "proposed" },
{ "id": "lg-verk-02", "kompetenz": "Verkehrserziehung und Sicherheitsbewusstsein", "kernziel": "sichere und vernünftige Routenentscheidungen reflektieren", "phases": [5,6], "status": "proposed" },
{ "id": "lg-verk-03", "kompetenz": "Verkehrserziehung und Sicherheitsbewusstsein", "kernziel": "Verkehr nicht nur als Bewegung, sondern als komplexes System verstehen", "phases": [4,5,6,7], "status": "proposed" },
{ "id": "lg-sys-01", "kompetenz": "Systemisches Denken", "kernziel": "Einzelentscheidungen wirken auf Gesamtsystem", "phases": [3,4,5,6,7], "status": "proposed" },
{ "id": "lg-sys-02", "kompetenz": "Systemisches Denken", "kernziel": "Zeit, Fahrzeuge, Infrastruktur und Aufträge beeinflussen einander", "phases": [3,4,5,6,7], "status": "proposed" },
{ "id": "lg-sys-03", "kompetenz": "Systemisches Denken", "kernziel": "globale Lieferketten werden auf Europa-Ebene heruntergebrochen erfahrbar", "phases": [4,5,6], "status": "proposed" }
]
}
+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Logistik Europa — Test-Harness</title>
<style>
body { font-family: 'Segoe UI', system-ui, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #222; background: #f7f5ef; }
h1 { font-size: 1.4rem; margin-bottom: 8px; }
.meta { color: #666; font-size: 0.9rem; margin-bottom: 20px; }
.group { background: #fff; border: 1px solid #e2ddd1; border-radius: 8px; padding: 14px 18px; margin: 14px 0; box-shadow: 0 2px 6px rgba(0,0,0,.04); }
.group h2 { font-size: 1.05rem; margin: 0 0 10px; color: #1f4b37; }
.case { display: grid; grid-template-columns: 20px 1fr; gap: 10px; padding: 4px 0; font-size: 0.9rem; }
.pass { color: #2d6d3d; }
.fail { color: #b32d2d; }
.case code { background: #f0ece3; padding: 1px 4px; border-radius: 3px; font-size: 0.85em; }
.summary { margin-top: 20px; padding: 12px; background: #1f4b37; color: #fff; border-radius: 6px; font-weight: 600; }
.summary.fail { background: #b32d2d; }
</style>
</head>
<body>
<h1>Logistik Europa — Test-Harness</h1>
<p class="meta">Pflichttests aus dem Kickoff-Briefing (Phase 0). Läuft ohne Test-Runner-Pipeline — einfach im Browser öffnen.</p>
<div id="results"></div>
<div id="summary" class="summary">…läuft…</div>
<script src="engine.js"></script>
<script src="headless-runner.js"></script>
<script>
(async function () {
const groups = [];
const pending = [];
let pass = 0, fail = 0;
function group(title, fn) {
const g = { title, cases: [] };
groups.push(g);
const t = {
eq: (actual, expected, label) => {
const ok = _deepEq(actual, expected);
g.cases.push({ ok, label, actual, expected });
ok ? pass++ : fail++;
},
approx: (actual, expected, tol, label) => {
const ok = Math.abs(actual - expected) <= tol;
g.cases.push({ ok, label, actual, expected: `${expected} ±${tol}` });
ok ? pass++ : fail++;
},
truthy: (actual, label) => {
const ok = !!actual;
g.cases.push({ ok, label, actual, expected: 'truthy' });
ok ? pass++ : fail++;
},
};
// fn kann sync oder async sein — beides unterstützen.
const job = (async () => {
try { await fn(t); }
catch (e) {
g.cases.push({ ok: false, label: 'Exception', actual: e.message, expected: '—' });
fail++;
}
})();
pending.push(job);
}
function _deepEq(a, b) {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a && b && typeof a === 'object') {
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
return ka.every(k => _deepEq(a[k], b[k]));
}
return false;
}
/* =========================================================
PFLICHTTESTS 17 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) => {
// WienMünchenHamburg: 400 + 800 = 1200 km
const graph = {
nodes: [{id:'wien'},{id:'muenchen'},{id:'hamburg'},{id:'rotterdam'},{id:'paris'}],
edges: [
{ from:'wien', to:'muenchen', distanceKm: 400 },
{ from:'muenchen', to:'hamburg', distanceKm: 800 },
{ from:'hamburg', to:'rotterdam', distanceKm: 500 },
],
};
const result = LogistikEngine.railShortestPath(graph, 'wien', 'hamburg');
t.truthy(result, 'Pfad gefunden');
t.eq(result.distanceKm, 1200, 'Wien → Hamburg: 1200 km');
t.eq(result.path, ['wien', 'muenchen', 'hamburg'], 'Pfad über München');
});
/* =========================================================
EDGE-CASES (Atlas-Auflage Phase 0)
========================================================= */
group('Test 8 — Edge-Cases', (t) => {
// 8.1 Leere Polyline → Exception
let threw = false;
try { LogistikEngine.interpolateAlongPolyline([], 0.5); }
catch (e) { threw = true; }
t.truthy(threw, 'leere Polyline → Exception');
// 8.2 Polyline mit nur einem Punkt → Exception
threw = false;
try { LogistikEngine.interpolateAlongPolyline([[0, 0]], 0.5); }
catch (e) { threw = true; }
t.truthy(threw, '1-Punkt-Polyline → Exception');
// 8.3 Dijkstra ohne Verbindung → null
const isolated = {
nodes: [{id:'a'},{id:'b'},{id:'inselA'},{id:'inselB'}],
edges: [
{ from:'a', to:'b', distanceKm: 100 },
{ from:'inselA', to:'inselB', distanceKm: 50 },
],
};
t.eq(LogistikEngine.railShortestPath(isolated, 'a', 'inselB'), null,
'getrennte Komponenten → null');
// 8.4 Dijkstra zum Selbst → distanceKm = 0
const r = LogistikEngine.railShortestPath(isolated, 'a', 'a');
t.truthy(r, 'Pfad zum Selbst gefunden');
t.eq(r.distanceKm, 0, 'Pfad zum Selbst: 0 km');
// 8.5 latePenalty mit negativen Stunden → 0 (keine negative Strafe)
t.eq(LogistikEngine.latePenalty(1000, -2), 0, 'negative Stunden → 0');
// 8.6 latePenalty mit null Wert → 0
t.eq(LogistikEngine.latePenalty(0, 5), 0, 'null Auftragswert → 0');
// 8.7 travelCost mit unbekanntem Modus → Exception
threw = false;
try { LogistikEngine.travelCost(100, 1, 'HELI'); }
catch (e) { threw = true; }
t.truthy(threw, 'unbekannter Modus → Exception');
// 8.8 calculateBonus mit beiden Flags false → 0
t.eq(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: false }), 0,
'keine Flags → kein Bonus');
});
/* =========================================================
SEEDED-RANDOM (Phase 0 — Reproduzierbarkeit)
========================================================= */
group('Test 9 — Seeded-Random (Mulberry32)', (t) => {
t.truthy(typeof LogistikRunner !== 'undefined', 'LogistikRunner geladen');
t.truthy(typeof LogistikRunner.makeRng === 'function', 'makeRng verfügbar');
// 9.1 Gleicher Seed → identische Sequenz
const a = LogistikRunner.makeRng(42);
const b = LogistikRunner.makeRng(42);
const seqA = Array.from({length: 10}, () => a());
const seqB = Array.from({length: 10}, () => b());
t.eq(seqA, seqB, 'Seed 42 → identische Sequenz auf zwei Instanzen');
// 9.2 Unterschiedliche Seeds → unterschiedliche Sequenzen
const c = LogistikRunner.makeRng(99);
const seqC = Array.from({length: 10}, () => c());
let differs = false;
for (let i = 0; i < 10; i++) if (seqA[i] !== seqC[i]) { differs = true; break; }
t.truthy(differs, 'Seed 42 ≠ Seed 99');
// 9.3 Werte im Bereich [0, 1)
t.truthy(seqA.every(v => v >= 0 && v < 1), 'alle Werte in [0, 1)');
// 9.4 Seed 0 funktioniert (wird intern auf 1 normalisiert, kein NaN)
const z = LogistikRunner.makeRng(0);
const v0 = z();
t.truthy(typeof v0 === 'number' && !isNaN(v0), 'Seed 0 liefert valide Zahl');
});
/* =========================================================
RUNNER-VERTRAG (Phase 0 — noop läuft, andere stubben)
========================================================= */
group('Test 10 — Headless-Runner-Vertrag (noop)', async (t) => {
// 10.1 noop-Strategie liefert vollständiges Result
const r = await LogistikRunner.runLevel(1, 1, 'noop');
t.truthy(r, 'runLevel liefert Ergebnis');
t.eq(r.successful, false, 'noop bei L1 → success=false');
t.eq(r.completedContracts, 0, 'noop schließt keine Aufträge ab');
t.eq(r.lateDeliveries, 0, 'noop hat keine Verspätungen');
t.truthy(r.durationHours > 0, 'durationHours > 0');
t.eq(r.strategy, 'noop', 'strategy-Feld gesetzt');
t.eq(r.level, 1, 'level-Feld gesetzt');
t.eq(r.seed, 1, 'seed-Feld gesetzt');
t.truthy(Array.isArray(r.log), 'log ist Array');
// 10.2 Reproduzierbar: gleicher Seed → identisches Ergebnis
const r2 = await LogistikRunner.runLevel(1, 1, 'noop');
t.eq(r.endBalance, r2.endBalance, 'Seed 1 reproduziert Balance');
t.eq(r.durationHours, r2.durationHours, 'Seed 1 reproduziert Dauer');
t.eq(r.log.length, r2.log.length, 'Seed 1 reproduziert log-Länge');
// 10.3 naive/greedy/optimal werfen kontrolliert (Phase 2)
let naiveThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'naive'); }
catch (e) { naiveThrew = /Phase 2/.test(e.message); }
t.truthy(naiveThrew, 'naive wirft kontrolliert "Phase 2"');
// 10.4 Unbekannte Strategie wirft
let unknownThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'fantasy'); }
catch (e) { unknownThrew = true; }
t.truthy(unknownThrew, 'unbekannte Strategie → Exception');
});
/* =========================================================
RENDER (wartet auf alle async-Tests)
========================================================= */
await Promise.all(pending);
const root = document.getElementById('results');
groups.forEach(g => {
const box = document.createElement('div');
box.className = 'group';
box.innerHTML = '<h2>' + g.title + '</h2>';
g.cases.forEach(c => {
const row = document.createElement('div');
row.className = 'case';
row.innerHTML = '<span class="' + (c.ok ? 'pass' : 'fail') + '">' + (c.ok ? '✓' : '✗') + '</span>' +
'<span>' + c.label + (c.ok ? '' : '<br><code>got: ' + JSON.stringify(c.actual) + ' / expected: ' + JSON.stringify(c.expected) + '</code>') + '</span>';
box.appendChild(row);
});
root.appendChild(box);
});
const sum = document.getElementById('summary');
sum.textContent = pass + ' bestanden, ' + fail + ' fehlgeschlagen';
sum.className = 'summary' + (fail ? ' fail' : '');
})();
</script>
</body>
</html>