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:
@@ -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 13–15-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 € | 1–3 | distanceKm/60 × 1.5 |
|
||||
| Eilauftrag | 1.500 € | 1–2 | 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** (1–5) 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
|
||||
(Wien–München 400, München–Hamburg 800, Hamburg–Rotterdam 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)
|
||||
@@ -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 1–7)
|
||||
============================================================ */
|
||||
|
||||
/** @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,
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 1–7 aus Kickoff-Briefing §5.3
|
||||
========================================================= */
|
||||
|
||||
group('Test 1 — Fahrkosten (travelCost)', (t) => {
|
||||
// Kleiner LKW: 300 km × 1.2 €/km + 4.5 h × 25 €/h = 360 + 112.5 = 472.5 €
|
||||
t.approx(LogistikEngine.travelCost(300, 4.5, 'TRUCK_SMALL'), 472.5, 0.01, '300 km, 4.5 h, TRUCK_SMALL → 472.50 €');
|
||||
t.approx(LogistikEngine.travelCost(800, 12, 'TRUCK_LARGE'), 800 * 2.5 + 12 * 45, 0.01, '800 km, 12 h, TRUCK_LARGE');
|
||||
t.approx(LogistikEngine.travelCost(950, 10.5, 'TRAIN'), 950 * 8 + 10.5 * 150, 0.01, '950 km, 10.5 h, TRAIN');
|
||||
});
|
||||
|
||||
group('Test 2 — Route-Interpolation', (t) => {
|
||||
const line = [[48.20, 16.37], [53.55, 9.97]]; // Wien → Hamburg (Luftlinie)
|
||||
const mid = LogistikEngine.interpolateAlongPolyline(line, 0.5);
|
||||
t.approx(mid[0], (48.20 + 53.55) / 2, 0.5, 'Mittelpunkt lat ≈ Schnittmittel');
|
||||
t.approx(mid[1], (16.37 + 9.97) / 2, 0.5, 'Mittelpunkt lon ≈ Schnittmittel');
|
||||
const start = LogistikEngine.interpolateAlongPolyline(line, 0);
|
||||
t.eq(start, line[0], 'progress=0 → Startpunkt');
|
||||
const end = LogistikEngine.interpolateAlongPolyline(line, 1);
|
||||
t.eq(end, line[1], 'progress=1 → Endpunkt');
|
||||
});
|
||||
|
||||
group('Test 3 — Strafkosten Verspätung (latePenalty)', (t) => {
|
||||
// 1000 € Auftrag, 2 h Verspätung → 20% × 2 = 400 €
|
||||
t.approx(LogistikEngine.latePenalty(1000, 2), 400, 0.01, '1000 €, 2 h spät → 400 €');
|
||||
t.approx(LogistikEngine.latePenalty(1500, 0.5), 150, 0.01, '1500 €, 0.5 h spät → 150 €');
|
||||
t.approx(LogistikEngine.latePenalty(1000, 0), 0, 0.01, 'Keine Verspätung → 0 €');
|
||||
});
|
||||
|
||||
group('Test 4 — Bonus-Kombi (calculateBonus)', (t) => {
|
||||
// 1000 €, pünktlich (+10%) + optimal (+20%) = 300 €
|
||||
t.approx(LogistikEngine.calculateBonus(1000, { onTime: true, noEmpty: true }), 300, 0.01, 'pünktlich + optimal → +30%');
|
||||
t.approx(LogistikEngine.calculateBonus(1000, { onTime: true, noEmpty: false }), 100, 0.01, 'nur pünktlich → +10%');
|
||||
t.approx(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: true }), 200, 0.01, 'nur optimal → +20%');
|
||||
t.approx(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: false }), 0, 0.01, 'nichts → 0');
|
||||
});
|
||||
|
||||
group('Test 5 — Event-Auswirkung (Phase 5 — skip bis implementiert)', (t) => {
|
||||
// Placeholder: echte Tests kommen mit Phase-5-Implementation
|
||||
t.truthy(LogistikEngine.EVENT_RULES.TRAFFIC_ACCIDENT, 'EVENT_RULES.TRAFFIC_ACCIDENT definiert');
|
||||
t.eq(LogistikEngine.EVENT_RULES.TRAFFIC_ACCIDENT.speedMult, 0.5, 'Unfall halbiert Geschwindigkeit');
|
||||
});
|
||||
|
||||
group('Test 6 — Savegame-Roundtrip (Phase 2 — skip bis implementiert)', (t) => {
|
||||
// Placeholder: echte Tests mit serialize/deserialize in Phase 2
|
||||
const game = LogistikEngine.createGame(1);
|
||||
const json = JSON.stringify(game);
|
||||
const restored = JSON.parse(json);
|
||||
t.eq(restored.levelId, game.levelId, 'Roundtrip levelId');
|
||||
t.eq(restored.balance, game.balance, 'Roundtrip balance');
|
||||
t.eq(restored.state, game.state, 'Roundtrip state');
|
||||
});
|
||||
|
||||
group('Test 7 — Bahn-Dijkstra (railShortestPath)', (t) => {
|
||||
// Wien–München–Hamburg: 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>
|
||||
Reference in New Issue
Block a user