Files
geograsim/App/sims/logistik/headless-runner.js
T
Adminator 79ac9dad84 Atlas: Deploy-Buendel vor Staustufen-Sprint
- Logistik: Musik-Player Playlist-Modus (5 Gruppen, 20 Tracks)
- Heli: End-Screen auf .ggs-endscreen, Mission-Bilder, Voice-Lines, Briefing-Audio
- Logistik: Layout-Tausch Auftraege+Fahrzeuge links, Karte rechts
- Logistik: Tier-1-Staedte ausgebaut, Auto-Timescale-Badge
- Logistik: Roadnet/Railnet Dijkstra-Routing, Balance-Updates
- Atlas: 5 Atlas-Inbox-Nachrichten (Cards-Pattern, DALL-E-Key, Musik-Playlists)
- Status-Updates Heli + Logistik

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:05:13 +02:00

475 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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` — FIFO-Auftrag, erstes Fahrzeug.
* - `greedy` — Höchster Auftragswert zuerst, größtes passendes Fahrzeug.
* - `optimal` — Best-Net-Heuristik: reward×1.15 fare erwartete
* Verspätungsstrafe. Dominiert greedy in allen Leveln.
*
* 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 (aktiv): nimmt den ersten OPEN-Auftrag, weist ihn dem
* ersten IDLE-Fahrzeug zu, lässt dann ticken bis Limit oder
* alle Aufträge fertig.
*/
naive: {
description: 'Erstes Fahrzeug, nächster Auftrag, FIFO.',
run(ctx) {
const { game, engine, log, seeds } = ctx;
const cfg = game._config || {};
const limitHours = cfg.timeLimitHours || 24;
const E = engine;
// Seeds + Lifecycle
E.loadContent(game, seeds);
E.seedInitialVehicles(game);
E.seedInitialContracts(game);
E.startPlanning(game);
E.startSimulation(game);
// Phase-2-Tick-Step: 5 Min Sim-Zeit pro Iteration (entspricht
// ~5000 ms real bei 1×; im Headless egal, da deterministisch)
const stepMinutes = 5;
const stepRealMs = stepMinutes * 1000; // (1×: 1 sec real = 1 min sim → 5 min = 5000 ms)
let safety = 100000;
while (game.state === E.STATE.RUNNING && safety-- > 0) {
// Phase 8e: Auto-Accept aller OFFERED-Angebote (naive = nimmt alles)
(game.activeContracts || []).filter(c => c.state === 'OFFERED').forEach(c => {
try { E.acceptOffer && E.acceptOffer(game, c.id); } catch (e) { /* max-open erreicht, ok */ }
});
// Naive Disposition: erstes OPEN ↔ erstes IDLE.
// Intermodal-Aufträge werden in dieser Strategie noch übersprungen
// (Phase 6c: separate Strategie-Anpassung mit Mode-Match).
const open = (game.activeContracts || []).filter(c => c.state === E.CONTRACT_STATE.OPEN && !c.intermodal);
const free = (game.vehicles || []).filter(v => v.state === E.VEHICLE_STATE.IDLE);
if (open.length && free.length) {
try {
E.assignContract(game, open[0].id, free[0].id);
log.push({ simTime: game.simulationTime, action: 'assign', contract: open[0].id, vehicle: free[0].id });
} catch (e) {
log.push({ simTime: game.simulationTime, action: 'assign_failed', error: e.message });
}
}
E.tick(game, stepRealMs);
if (game.state !== E.STATE.RUNNING) break;
if (E.getSimHoursElapsed(game) >= limitHours) break;
}
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
return {
successful: profit >= minTarget,
endBalance: game.balance,
completedContracts: (game.completedContracts || []).length,
lateDeliveries: (game.analytics && game.analytics.lateDeliveries) || 0,
durationHours: E.getSimHoursElapsed(game),
hintUsages: 0,
log,
};
},
},
/**
* `greedy` — höchster Auftragswert zuerst, fähigstes verfügbares
* Fahrzeug. „Fähig" = Kapazität ≥ Container-Bedarf, sonst egal.
* Phase 3 aktiv.
*/
greedy: {
description: 'Höchster Auftragswert zuerst, größtes passendes Fahrzeug.',
run(ctx) {
const { game, engine, log, seeds } = ctx;
const cfg = game._config || {};
const limitHours = cfg.timeLimitHours || 24;
const E = engine;
E.loadContent(game, seeds);
E.seedInitialVehicles(game);
E.seedInitialContracts(game);
E.startPlanning(game);
E.startSimulation(game);
const stepRealMs = 5 * 1000; // 5 min sim
let safety = 100000;
while (game.state === E.STATE.RUNNING && safety-- > 0) {
// Phase 8e: Greedy nimmt alle OFFERED-Angebote an
(game.activeContracts || []).filter(c => c.state === 'OFFERED').forEach(c => {
try { E.acceptOffer && E.acceptOffer(game, c.id); } catch (e) { /* ignore */ }
});
// Aufträge nach Wert sortiert (descending), Fahrzeuge nach Kapazität descending.
// Intermodal-Aufträge werden hier noch übersprungen (Phase 6c).
const open = (game.activeContracts || [])
.filter(c => c.state === E.CONTRACT_STATE.OPEN && !c.intermodal)
.slice()
.sort((a, b) => (b.rewardBase || 0) - (a.rewardBase || 0));
const free = (game.vehicles || [])
.filter(v => v.state === E.VEHICLE_STATE.IDLE)
.slice()
.sort((a, b) => (b.capacityUnits || 0) - (a.capacityUnits || 0));
// Pro Auftrag das passendste Fahrzeug nehmen
for (const c of open) {
if (!free.length) break;
// Erstes free, das genug Kapazität hat (wenn keines, das größte)
let pick = free.find(v => (v.capacityUnits || 0) >= (c.quantityContainers || 1));
if (!pick) pick = free[0];
try {
E.assignContract(game, c.id, pick.id);
log.push({ simTime: game.simulationTime, action: 'assign', contract: c.id, vehicle: pick.id });
const idx = free.indexOf(pick);
if (idx >= 0) free.splice(idx, 1);
} catch (e) {
log.push({ simTime: game.simulationTime, action: 'assign_failed', error: e.message });
}
}
E.tick(game, stepRealMs);
if (game.state !== E.STATE.RUNNING) break;
if (E.getSimHoursElapsed(game) >= limitHours) break;
}
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
return {
successful: profit >= minTarget,
endBalance: game.balance,
completedContracts: (game.completedContracts || []).length,
lateDeliveries: (game.analytics && game.analytics.lateDeliveries) || 0,
durationHours: E.getSimHoursElapsed(game),
hintUsages: 0,
log,
};
},
},
/**
* `optimal` — Best-Net-Heuristik: pro Iteration wird die
* (Contract, Vehicle)-Paarung mit maximalem erwartetem Netto
* ausgewaehlt.
*
* Netto = rewardBase × 1.3 (gemittelter Bonus-Erwartungswert)
* fareCost (Pickup + Delivery aus calculateRoute)
* erwartete Verspaetungsstrafe (falls Frist knapp)
*
* Keine echte globale Optimierung (waere VRP-TW = NP-hart), aber
* berueckichtigt Leerfahrt, Container-Matching, Frist — dominiert
* greedy in allen drei Leveln.
*/
optimal: {
description: 'Best-Net-Heuristik: Auftrag×Fahrzeug nach erwartetem Netto.',
run(ctx) {
const { game, engine, log, seeds } = ctx;
const cfg = game._config || {};
const limitHours = cfg.timeLimitHours || 24;
const E = engine;
E.loadContent(game, seeds);
E.seedInitialVehicles(game);
E.seedInitialContracts(game);
E.startPlanning(game);
E.startSimulation(game);
const latePenRate = (typeof cfg.latePenaltyOverride === 'number')
? cfg.latePenaltyOverride : (E.ECONOMY.latePenaltyPercentPerHour || 0.2);
const stepRealMs = 5 * 1000;
let safety = 100000;
while (game.state === E.STATE.RUNNING && safety-- > 0) {
// Phase 8e: optimal akzeptiert alle OFFERED-Angebote (interne Bewertung
// passiert dann im Best-Net-Matching)
(game.activeContracts || []).filter(c => c.state === 'OFFERED').forEach(c => {
try { E.acceptOffer && E.acceptOffer(game, c.id); } catch (e) { /* ignore */ }
});
const open = (game.activeContracts || [])
.filter(c => c.state === E.CONTRACT_STATE.OPEN && !c.intermodal);
const free = (game.vehicles || [])
.filter(v => v.state === E.VEHICLE_STATE.IDLE);
// Alle feasible (contract, vehicle) Paarungen mit erwartetem Netto bewerten
const options = [];
for (const c of open) {
for (const v of free) {
if ((v.capacityUnits || 0) < (c.quantityContainers || 1)) continue; // Kapazitaet
let route;
try { route = E.calculateRoute(game, c.originLocationId, c.targetLocationId, v.mode); }
catch (e) { continue; }
const veh = E.VEHICLE_DEFAULTS[v.mode];
if (!veh) continue;
// Pickup (Leerfahrt falls Vehicle nicht am Origin)
let pickupKm = 0, pickupH = 0;
if (v.currentLocationId !== c.originLocationId) {
try {
const pr = E.calculateRoute(game, v.currentLocationId, c.originLocationId, v.mode);
pickupKm = pr.totalDistanceKm || 0;
pickupH = (pr.totalDurationMinutes || 0) / 60;
} catch (e) { continue; }
}
const deliveryKm = route.totalDistanceKm || 0;
const deliveryH = (route.totalDurationMinutes || 0) / 60;
const totalH = pickupH + deliveryH;
const fareCost = pickupKm * veh.costPerKm + pickupH * veh.costPerHour
+ deliveryKm * veh.costPerKm + deliveryH * veh.costPerHour;
// Erwartete Verspaetung
const nowMs = new Date(game.simulationTime).getTime();
const dueMs = new Date(c.dueTime).getTime();
const slackH = (dueMs - nowMs) / (1000 * 3600);
const lateH = Math.max(0, totalH - slackH);
const expectedPenalty = (c.rewardBase || 1000) * latePenRate * lateH;
// Erwarteter Bonus: +10% puenktlich +20% keine Leerfahrt, Daumenrechnung 1.15×
const bonusMult = 1.15;
const net = (c.rewardBase || 1000) * bonusMult - fareCost - expectedPenalty;
options.push({ c, v, net, totalH, lateH });
}
}
// Beste Paarung zuerst, dann jede Zuweisung feuern (solange neu verfuegbar)
options.sort((a, b) => b.net - a.net);
const usedV = new Set(), usedC = new Set();
for (const opt of options) {
if (opt.net <= 0) break; // unprofitable skip
if (usedV.has(opt.v.id) || usedC.has(opt.c.id)) continue;
try {
E.assignContract(game, opt.c.id, opt.v.id);
log.push({
simTime: game.simulationTime, action: 'assign',
contract: opt.c.id, vehicle: opt.v.id,
net: Math.round(opt.net), lateH: opt.lateH.toFixed(1),
});
usedV.add(opt.v.id); usedC.add(opt.c.id);
} catch (e) {
log.push({ simTime: game.simulationTime, action: 'assign_failed', error: e.message });
}
}
E.tick(game, stepRealMs);
if (game.state !== E.STATE.RUNNING) break;
if (E.getSimHoursElapsed(game) >= limitHours) break;
}
const startBudget = cfg.startBudget || 5000;
const profit = game.balance - startBudget;
const minTarget = cfg.minTargetEarnings || 0;
return {
successful: profit >= minTarget,
endBalance: game.balance,
completedContracts: (game.completedContracts || []).length,
lateDeliveries: (game.analytics && game.analytics.lateDeliveries) || 0,
durationHours: E.getSimHoursElapsed(game),
hintUsages: 0,
log,
};
},
},
};
/* ============================================================
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, { deterministic: true, seed });
const rng = makeRng(seed);
const log = [];
const ctx = {
game, rng, engine, log, level: levelNum, seed,
seeds: (opts && opts.seeds) || null, // optional override
};
const result = await strategy.run(ctx);
// Vertrag-Anreicherung — alle Felder garantieren
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
};
});