79ac9dad84
- 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>
475 lines
18 KiB
JavaScript
475 lines
18 KiB
JavaScript
/**
|
||
* 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
|
||
};
|
||
});
|