Tourismusregion: Komplett-Integration (zweite neue Sim, beta)

Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal-
Playbook integriert: Sim nach App/sims/tourismusregion (game.html,
Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys),
Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung,
Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home-
Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info
(beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte
Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus-
raumentwicklung (primaer) + verkehrserziehung/systemisches-denken.
LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer-
Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten,
needsHelp, Highlights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 01:14:12 +02:00
parent 778634fab6
commit 8047778a5b
86 changed files with 5237 additions and 0 deletions
+802
View File
@@ -0,0 +1,802 @@
// Simulationskern: Spielzustand + tick(state, dt). Enthält Spawning,
// Bewegung entlang der Routen, Infrastrukturwirkung (§11: Zielgruppen-,
// Saison-, Umwelt-, Kapazitätsfaktor, Bremsen, Einwirkzeit), Ökonomie,
// Bewertung beim Verlassen, Phasen/Saisonen, Ereignisse und Endauswertung.
// Komplett DOM-frei läuft auch im Headless-Test (tests/model-test.mjs).
import {
GAME_LEN, PHASE_LEN, SEASON_LEN, SEASONS, SEASON_KEYS,
CONTACT_NORM, MAX_GAIN_FACTOR, MAX_BRAKE, START_BUDGET, START_ENV,
GROUPS, BUILDINGS, WAVES, EVENTS, FEED_TEXTS, PROFILES, GOALS, SYNERGIES, currentAvg,
} from './data.js';
import {
prepMap, mulberry32, makeRoute, cumLengths, TILE2PX, canPlace as worldCanPlace,
} from './world.js';
export { GOALS, currentAvg };
export const canPlace = worldCanPlace;
const LIFT_SPEED = 1.4; // Kacheln/s in der Liftfahrt
const REV_SCALE = 0.42; // globale Einnahmen-Skala (Balancing: Geld darf nicht trivial werden)
export function createState(opts = {}) {
const seed = opts.seed ?? 20260703;
const map = prepMap(opts.mapId || 'talschleife');
const state = {
map,
slots: map.slots,
playerName: opts.playerName || '',
classCode: opts.classCode || '',
time: 0, phase: 0, season: SEASONS[0], seasonKey: SEASON_KEYS[0],
ended: false, result: null,
budget: START_BUDGET, revenueTotal: 0, opCostTotal: 0,
env: START_ENV, traffic: 0,
fame: 1.0, festUntil: 0, natureBoost: 1,
buildings: [], nextBid: 1,
visitors: [], nextVid: 1,
unlocked: new Set(),
spawnQueue: [],
phaseExitStars: [],
feedPool: [],
pendingCards: [],
firedEvents: new Set(),
stats: {
all: { count: 0, stars: 0 },
groups: {},
buildingRevenue: {},
transports: { foot: 0, bike: 0, bus: 0, car: 0 },
exits: { E: 0, SE: 0 },
spawned: 0, skippedNoParking: 0,
},
fx: [],
heat: new Float32Array(48 * 32), heatCols: 48, heatRows: 32,
history: { t: [], budget: [], rating: [], env: [], visitors: [], satPct: [] },
lastSatPct: 0,
actionLog: [],
opAccum: 0, sampleAccum: 0,
waveStatus: 'pause', pauseLeft: 22, waveStartTime: 0,
recentStars: [],
rng: mulberry32(seed + 7),
};
prepareWave(state, 1, { silent: true });
return state;
}
// ---------- Haupt-Tick ----------
export function tick(state, dt) {
if (state.ended || dt <= 0) return;
state.time += dt;
// ---------- Wellen-Maschine (Tower-Defense-Rhythmus) ----------
// Baupause → Welle läuft → alle Gäste der Welle abgereist → nächste
// Baupause. „Nächste Welle rufen" startet früher (mit Bonus).
if (state.waveStatus === 'pause') {
state.pauseLeft -= dt;
if (state.pauseLeft <= 0) launchWave(state);
} else if (state.waveStatus === 'running') {
const active = state.spawnQueue.length > 0
|| state.visitors.some(v => v.wave === state.phase);
if (!active) {
if (state.phase >= 8) { finish(state); return; }
prepareWave(state, state.phase + 1);
state.waveStatus = 'pause';
state.pauseLeft = 16;
}
}
// ---------- Baustellen: Bau- und Ausbauzeit ----------
for (const b of state.buildings) {
if (b.construction > 0) {
b.construction -= dt;
if (b.construction <= 0) {
b.construction = 0;
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
}
} else if (b.upgrading) {
b.upgrading.left -= dt;
if (b.upgrading.left <= 0) {
b.level++;
state.env = clampEnv(state.env + (BUILDINGS[b.type].env[b.level - 1] || 0));
b.upgrading = null;
refreshInfoBoosts(state);
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
}
}
}
spawnDue(state);
moveAndAffect(state, dt);
// Betriebskosten alle 10 s (§18.7)
state.opAccum += dt;
while (state.opAccum >= 10) {
state.opAccum -= 10;
let cost = 0;
for (const b of state.buildings) {
if (b.construction > 0) continue; // Baustelle: noch keine Betriebskosten
cost += BUILDINGS[b.type].opCost[b.level - 1];
}
cost /= 6; // pro Minute → pro 10 s
state.budget -= cost;
state.opCostTotal += cost;
}
// Verkehr: Parkplätze + aktive Auto-/Busgruppen
let traffic = 0;
for (const b of state.buildings) {
if (b.type === 'parkplatz') traffic += BUILDINGS.parkplatz.traffic[b.level - 1];
}
for (const v of state.visitors) {
const tr = GROUPS[v.type].transport;
if (tr === 'car') traffic += 1.5;
else if (tr === 'bus') traffic += 2.5;
}
state.traffic = Math.round(traffic);
// Verlauf (für den Graphen) alle 5 s
state.sampleAccum += dt;
if (state.sampleAccum >= 5) {
state.sampleAccum = 0;
const h = state.history;
h.t.push(state.time);
h.budget.push(Math.round(state.budget));
h.rating.push(Number(currentAvg(state).toFixed(2)));
h.env.push(Math.round(state.env));
h.visitors.push(state.visitors.length);
// Live-Erfüllungsgrad: wie „voll" sind die Zufriedenheitsbalken gerade?
// (Ziel wie im Tower Defense: 100 % = Gast komplett glücklich)
if (state.visitors.length) {
const mean = state.visitors.reduce((a, v) =>
a + Math.max(0, Math.min(1.1, v.satisfaction / GROUPS[v.type].satTarget)), 0) / state.visitors.length;
state.lastSatPct = Math.round(mean * 100);
}
h.satPct.push(state.lastSatPct || 0);
}
if (state.time > 2400) finish(state); // Sicherheitsdeckel
}
// ---------- Wellen-Vorbereitung: Ruhm, Feed, Freischaltungen, Plan ----------
function prepareWave(state, phase, opts = {}) {
// Besucherfaktor aus den Bewertungen der Vorphase (§8.4)
if (state.phaseExitStars.length) {
const avg = state.phaseExitStars.reduce((a, b) => a + b, 0) / state.phaseExitStars.length;
state.fame = 0.7 + avg / 5;
// Social-Media-Feed der Vorphase
const picks = pickFeed(state);
if (picks.length) {
state.pendingCards.push({
kind: 'feed', icon: '📱', phase: state.phase,
title: `Bewertungen aus Phase ${state.phase}`,
items: picks, avg, fame: state.fame,
});
}
}
state.phaseExitStars = [];
state.feedPool = [];
state.phase = phase;
// Saison hängt an der Welle (2 Wellen je Jahreszeit)
const si = Math.min(3, Math.floor((phase - 1) / 2));
if (SEASON_KEYS[si] !== state.seasonKey) {
state.seasonKey = SEASON_KEYS[si];
state.season = SEASONS[si];
state.pendingCards.push({
kind: 'season', icon: ['🌸', '☀️', '🍂', '❄️'][si],
title: `Saisonwechsel: ${state.season}`,
text: {
summer: 'Der Sommer ist da! Badesee, Eis, Rad und Wandern sind jetzt besonders gefragt.',
autumn: 'Herbst: Kulinarik, Kultur und Wandern haben Hochsaison Badespaß ist vorbei.',
winter: 'Winter! Jetzt zählen Lift, Almhütte, Gasthaus und Aussicht. Der See ist zugefroren.',
}[state.seasonKey] || '',
});
}
// Ankündigungen/Entscheidungen dieser Welle
for (const ev of EVENTS) {
if (ev.wave === phase && !state.firedEvents.has(ev.id)) {
state.firedEvents.add(ev.id);
state.pendingCards.push({ kind: ev.type, ...ev });
}
}
// Freischaltungen
const fresh = Object.entries(BUILDINGS)
.filter(([id, d]) => d.unlockPhase === phase && !state.unlocked.has(id));
for (const [id] of fresh) state.unlocked.add(id);
// Welle planen
const wave = WAVES[phase] || {};
const parkPlus = sumBonus(state, 'parkplatz', 'visitorPlus');
const busPlus = sumBonus(state, 'bushaltestelle', 'busPlus');
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
state.spawnQueue = [];
const waveCounts = [];
let slot = 0;
const t0 = 0; // relativ launchWave addiert die echte Startzeit
for (const [type, base] of Object.entries(wave)) {
const g = GROUPS[type];
let mult = state.fame;
if (g.transport === 'car') mult *= 1 + parkPlus;
if (g.transport === 'bus') mult *= 1 + busPlus + parkPlus * 0.5;
let n = Math.max(1, Math.round(base * mult));
if (g.needsParking && !hasParking) {
const skipped = Math.floor(n / 2);
n -= skipped;
state.stats.skippedNoParking += skipped;
if (skipped > 0) state.feedPool.push({ stars: 1, emoji: g.emoji, text: FEED_TEXTS.noParking, prio: 3 });
}
waveCounts.push(`${n}× ${GROUPS[type].name}`);
// Konvoi statt Streuung: Die Welle einer Gruppe kommt gebündelt kurz
// hintereinander (alle 46 s) und nimmt dieselbe Route so ist sie
// als zusammengehörende Welle erkennbar.
// Konvois innerhalb der Welle: Gruppe für Gruppe, eng beieinander
const clusterStart = t0 + 1 + slot * (n * 1.2 + 9);
const routePick = state.rng();
state.spawnQueue.push({ t: Math.max(t0 + 0.5, clusterStart - 1.2), banner: { type, n } });
for (let i = 0; i < n; i++) {
state.spawnQueue.push({ t: clusterStart + i * (2.4 + state.rng() * 0.8), type, routePick });
}
slot++;
}
state.spawnQueue.sort((a, b) => a.t - b.t);
// Wellen-Ankündigung: Wer kommt? (Porträts neuer Gruppen) + neue Bauoptionen
const newcomers = Object.keys(wave).filter(t => GROUPS[t].fromPhase === phase);
let text = `Erwartete Gäste: ${waveCounts.join(' · ')}.`;
for (const t of newcomers) {
const g = GROUPS[t];
text += `\n\n${g.emoji} ${g.name}: ${g.who}`;
}
if (fresh.length) {
text += `\n\n🔓 Neu baubar: ${fresh.map(([, d]) => `${d.emoji} ${d.name}`).join(' · ')}`;
}
state.pendingCards.push({
kind: 'phase', icon: '👥',
title: phase === 1 ? 'Phase 1: Die ersten Gäste kommen' : `Phase ${phase}: Neue Welle im Anmarsch`,
text,
});
}
// Welle wirklich starten: geplante Spawns auf die aktuelle Zeit schieben
function launchWave(state) {
for (const e of state.spawnQueue) e.t += state.time;
state.waveStatus = 'running';
state.waveStartTime = state.time;
state.pauseLeft = 0;
}
function sumBonus(state, type, key) {
let sum = 0;
for (const b of state.buildings) {
if (b.type === type) sum += BUILDINGS[type][key][b.level - 1];
}
return sum;
}
// ---------- Spawning ----------
function spawnDue(state) {
if (state.waveStatus !== 'running') return;
while (state.spawnQueue.length && state.spawnQueue[0].t <= state.time) {
const { type, routePick, banner } = state.spawnQueue.shift();
if (banner) {
state.pendingCards.push({ kind: 'banner', type: banner.type, n: banner.n });
continue;
}
if (state.visitors.length >= 120) continue; // Performance-Deckel
const g = GROUPS[type];
const route = makeRoute(state, g, state.rng, routePick);
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
// Busgruppen fahren im Bus bis zur Haltestelle und steigen dort aus
let travel = 'walk', busStopS = 0;
if (g.transport === 'bus') {
const stop = state.buildings.find(b => b.type === 'bushaltestelle');
if (stop) {
let bestD = Infinity;
for (let i = 0; i < route.pts.length; i++) {
const d = Math.hypot(route.pts[i].x - stop.x, route.pts[i].y - stop.y);
if (d < bestD && route.cum[i] > 40) { bestD = d; busStopS = route.cum[i]; }
}
if (bestD < 260 && busStopS < route.cum[route.cum.length - 1] - 60) travel = 'bus';
}
}
state.visitors.push({
wave: state.phase,
travel, busStopS, busWait: 0,
id: state.nextVid++,
type,
pts: route.pts,
cum: route.cum,
exitLabel: route.exit,
s: 0, seg: 0,
x: route.pts[0].x, y: route.pts[0].y,
satisfaction: g.needsParking && !hasParking ? -10 : 0,
revFx: {},
spent: 0,
gained: {},
hadService: false,
fxNext: {},
brake: 0,
timeIn: 0,
mode: 'walk',
wobble: state.rng() * 7,
});
state.stats.spawned++;
state.stats.transports[g.transport]++;
}
}
// ---------- Bewegung + Infrastrukturwirkung ----------
// Tower-Defense-Kapazität (§11.7, verschärft): Jedes Gebäude bedient nur so
// viele Gäste, wie es Kapazität hat die nächstgelegenen zuerst. Wer keinen
// Platz bekommt, geht leer aus (sichtbar: Auslastungsbogen + Bedien-Bögen).
function moveAndAffect(state, dt) {
const bs = state.buildings;
const servedBy = new Array(bs.length);
for (let i = 0; i < bs.length; i++) {
const b = bs[i];
b.arcs = [];
if (b.construction > 0 || b.upgrading) {
// Baustelle: bedient niemanden
b.inRange = 0; b.util = 0; b.serving = [];
servedBy[i] = new Set();
continue;
}
const R = range(b);
const near = [];
for (const v of state.visitors) {
if (v.travel === 'bus') continue; // im Bus wird niemand bedient
const d = dist(v, b);
if (d <= R) near.push([d, v]);
}
near.sort((a, b2) => a[0] - b2[0]);
const cap = BUILDINGS[b.type].capacity[b.level - 1];
b.inRange = near.length;
b.util = cap >= 999 ? 0 : near.length / cap;
const taken = near.slice(0, cap);
servedBy[i] = new Set(taken.map(x => x[1].id));
b.serving = taken.slice(0, 10).map(x => x[1].id);
}
const envFBase = 0.6 + state.env / 100; // §11.9
const fest = state.time < state.festUntil ? 1.15 : 1;
for (const v of state.visitors) {
const g = GROUPS[v.type];
v.timeIn += dt;
// Wirkung aller Gebäude, die diesen Gast gerade bedienen
let brakeProduct = 1;
for (let i = 0; i < bs.length; i++) {
if (!servedBy[i].has(v.id)) continue;
const b = bs[i];
const def = BUILDINGS[b.type];
const lvl = b.level - 1;
const tf = targetFactor(def, b.level, v.type);
if (def.service) v.hadService = true;
if (def.slow[lvl] > 0 && tf >= 0.3 && v.mode !== 'lift') {
brakeProduct *= 1 - def.slow[lvl];
}
if (tf <= 0.05) continue;
const sf = def.season[state.seasonKey] ?? 1;
const envF = 1 + g.envSens * (envFBase - 1);
const natF = def.natureBoosted ? state.natureBoost : 1;
const elevB = def.elevBonus && b.slotZone === 'berg' ? 1.3 : 1;
const eff = def.sat[lvl] * tf * sf * envF * natF * (b.infoBoost || 1) * (b.synergy || 1) * elevB * fest;
const maxGain = def.sat[lvl] * tf * sf * MAX_GAIN_FACTOR;
const got = v.gained[b.id] ?? 0;
const add = Math.min((eff / CONTACT_NORM) * dt, Math.max(0, maxGain - got));
if (add > 0) {
v.satisfaction += add;
v.gained[b.id] = got + add;
// Bogen nur, solange die Bedienung wirklich etwas bringt
if (b.arcs.length < 10 && !b.arcs.includes(v.id)) b.arcs.push(v.id);
}
// sichtbare Interaktion: Objekt/Erlebnis schwebt vom Gebäude zum Gast
// beim ersten Kontakt und dann alle paar Sekunden wieder
// Objekt-Flug nur für wirklich passende Zielgruppen (tf ≥ 0.5),
// Produkt je Zielgruppe (itemsBy), Geräusch je Dienstleistung
if (tf >= 0.5 && (v.gained[b.id] ?? 0) >= 2 && state.time >= (v.fxNext[b.id] ?? 0)) {
v.fxNext[b.id] = state.time + 2.2 + state.rng() * 1.2;
const emoji = def.itemsBy?.[v.type] || def.item || def.fx;
if (emoji && state.fx.length < 90) {
const amount = Math.round(v.revFx[b.id] || 0);
if (amount > 0) v.revFx[b.id] = 0;
state.fx.push({ emoji, fx: b.x, fy: b.y, vid: v.id, amount, sound: def.sound || 'pling' });
}
}
// Einnahmen als Folge von Zufriedenheit (§11.8, §25)
const baseRev = def.revenue[lvl];
if (baseRev > 0 && tf > 0.3) {
const satF = Math.min(1.5, 0.5 + Math.max(0, v.satisfaction) / g.satTarget);
const rev = Math.min((baseRev * REV_SCALE * tf * satF / CONTACT_NORM) * dt, g.budget - v.spent);
if (rev > 0) {
state.budget += rev;
state.revenueTotal += rev;
v.spent += rev;
v.revFx[b.id] = (v.revFx[b.id] || 0) + rev;
state.stats.buildingRevenue[b.type] = (state.stats.buildingRevenue[b.type] || 0) + rev;
}
}
}
v.brake = Math.min(MAX_BRAKE, 1 - brakeProduct);
// Bewegung entlang der Route (Bogenlänge in Bild-Pixeln)
const liftSeg = v.pts[v.seg + 1]?.lift;
v.mode = liftSeg ? 'lift' : 'walk';
// starke Bremse ⇒ sichtbares Verweilen: Stop-and-Go statt Zeitlupe
v.stopped = false;
if (!liftSeg && v.travel === 'walk' && v.brake > 0.28) {
const cycle = (state.time + v.id * 1.7) % 7;
if (cycle < 2.8) v.stopped = true;
}
let speed = (liftSeg ? LIFT_SPEED : g.speed * (1 - v.brake * 0.55)) * TILE2PX;
if (v.stopped) speed = 0;
if (v.travel === 'bus') {
speed = 2.6 * TILE2PX; // Busfahrt bis zur Haltestelle
if (v.s >= v.busStopS) { v.travel = 'busstop'; v.busWait = 2.6; }
} else if (v.travel === 'busstop') {
speed = 0; // Halt: Fahrgäste steigen aus
v.busWait -= dt;
if (v.busWait <= 0) v.travel = 'walk';
}
v.s += speed * dt;
const total = v.cum[v.cum.length - 1];
if (v.s >= total) {
exitVisitor(state, v);
} else {
while (v.seg < v.pts.length - 2 && v.cum[v.seg + 1] <= v.s) v.seg++;
const a = v.pts[v.seg], b2 = v.pts[v.seg + 1];
const len = v.cum[v.seg + 1] - v.cum[v.seg] || 1;
const f = (v.s - v.cum[v.seg]) / len;
v.x = a.x + (b2.x - a.x) * f;
v.y = a.y + (b2.y - a.y) * f;
v.headRight = b2.x >= a.x;
}
}
// Besucherstrom-Dichte fürs Heatmap-Raster akkumulieren (§15.7)
const HC = state.heatCols, HR = state.heatRows;
const mw = state.map.w, mh = state.map.h;
for (const v of state.visitors) {
if (v.exited || v.travel === 'bus') continue;
const c = Math.max(0, Math.min(HC - 1, Math.floor(v.x / mw * HC)));
const r = Math.max(0, Math.min(HR - 1, Math.floor(v.y / mh * HR)));
state.heat[r * HC + c] += dt;
}
state.visitors = state.visitors.filter(v => !v.exited);
}
function dist(v, b) {
return Math.hypot(v.x - b.x, v.y - b.y);
}
function range(b) {
return BUILDINGS[b.type].range[b.level - 1] * TILE2PX;
}
function targetFactor(def, level, groupId) {
const byLevel = def.targetByLevel?.[groupId];
if (byLevel) return byLevel[level - 1];
return def.target[groupId] ?? 0.4;
}
// ---------- Abreise + Bewertung (§8, §18.5) ----------
function exitVisitor(state, v) {
v.exited = true;
const g = GROUPS[v.type];
let sat = v.satisfaction;
const malus = { service: false, traffic: false, env: false, time: false };
if (g.needsService && !v.hadService && v.timeIn > 60) { sat -= 10; malus.service = true; }
const tPen = state.traffic * 0.08 * g.trafficSens;
if (tPen > 4) malus.traffic = true;
sat -= tPen;
if (state.env < 45 && g.envSens >= 0.7) malus.env = true;
if (v.timeIn > g.maxTime) { sat *= 0.92; malus.time = true; }
const ratio = Math.max(0, sat) / g.satTarget;
const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5)));
// sichtbares Abschluss-Feedback am Ausgang (wie „Gegner besiegt")
if (state.fx.length < 90) {
state.fx.push({ pop: true, stars, fx: v.x, fy: v.y });
}
state.recentStars.push(stars);
if (state.recentStars.length > 12) state.recentStars.shift();
if (stars <= 2) state.lastBadExit = state.time;
const st = state.stats;
st.all.count++; st.all.stars += stars;
st.groups[v.type] = st.groups[v.type] || { count: 0, stars: 0 };
st.groups[v.type].count++;
st.groups[v.type].stars += stars;
st.groups[v.type].avg = st.groups[v.type].stars / st.groups[v.type].count;
st.exits[v.exitLabel] = (st.exits[v.exitLabel] || 0) + 1;
state.phaseExitStars.push(stars);
// Feed-Text erzeugen
let text;
if (malus.traffic && stars <= 3 && state.rng() < 0.5) text = FEED_TEXTS.traffic;
else if (malus.env && stars <= 3 && state.rng() < 0.4) text = FEED_TEXTS.env;
else {
const pool = FEED_TEXTS[stars];
text = pool[Math.floor(state.rng() * pool.length)]
.replace('{best}', bestBuildingName(state, v))
.replace('{missing}', g.wish);
}
state.feedPool.push({ stars, emoji: g.emoji, group: g.name, text, prio: stars === 5 || stars <= 2 ? 2 : 1 });
}
function bestBuildingName(state, v) {
let best = null, val = 3;
for (const [bid, gain] of Object.entries(v.gained)) {
if (gain > val) {
const b = state.buildings.find(x => x.id === Number(bid));
if (b) { best = b; val = gain; }
}
}
if (!best) return 'die Landschaft';
const def = BUILDINGS[best.type];
return (def.levelNames ? def.levelNames[best.level - 1] : def.name);
}
function pickFeed(state) {
const sorted = [...state.feedPool].sort((a, b) => (b.prio - a.prio) || (state.rng() - 0.5));
const out = [];
const seen = new Set();
for (const f of sorted) {
if (seen.has(f.text)) continue;
seen.add(f.text);
out.push(f);
if (out.length >= 3) break;
}
return out;
}
// ---------- Bauen / Ausbauen / Abreißen (auf festen Slots) ----------
export function place(state, type, slotIdx) {
const check = canPlace(state, type, slotIdx);
if (!check.ok) return check;
const def = BUILDINGS[type];
const slot = state.slots[slotIdx];
state.budget -= def.cost[0];
const b = {
id: state.nextBid++, type, level: 1,
x: slot.x, y: slot.y, slotIdx, slotZone: slot.zone, infoBoost: 1,
construction: 5 + def.cost[0] / 50, // Baustellenzeit in Sekunden
upgrading: null, synergy: 1, synergyList: [],
};
state.buildings.push(b);
slot.building = b;
state.env = clampEnv(state.env + def.env[0]);
state.actionLog.push({ t: state.time, action: 'build', type, level: 1 });
if (def.opensBerg) {
state.pendingCards.push({
kind: 'card', icon: '⛰️', title: 'Der Berg ist erschlossen!',
text: 'Die Seilbahn bringt Gäste auf den Berg oben warten neue Bauplätze '
+ '(Almhütte, Aussichtspunkt). Wanderer und junge Gruppen lieben das, '
+ 'im Winter läuft der Lift auf Hochtouren.',
});
}
if (type === 'naturschutz') {
state.natureBoost = 1.15;
// Schutzgebiet sperrt umliegende freie Bauplätze (§10: Zielkonflikt
// Wirtschaft ↔ Schutz). Reichweite in Pixeln.
const R = (def.blocksRadius || 3.2) * TILE2PX;
b.blockedSlots = [];
for (const s of state.slots) {
if (s === slot || s.building || s.zone === 'lake' || s.zone === 'road') continue;
if (Math.hypot(s.x - slot.x, s.y - slot.y) <= R) {
s.blocked = true;
b.blockedSlots.push(s.idx);
}
}
}
refreshInfoBoosts(state);
return { ok: true, building: b };
}
export function upgrade(state, b) {
const def = BUILDINGS[b.type];
if (b.construction > 0 || b.upgrading) return { ok: false, reason: 'Hier wird gerade gebaut.' };
if (b.level >= def.maxLevel) return { ok: false, reason: 'Bereits voll ausgebaut.' };
const cost = def.cost[b.level];
if (state.budget < cost) return { ok: false, reason: `Zu teuer Ausbau kostet ${cost} €.` };
state.budget -= cost;
b.upgrading = { left: 4 + cost / 60 }; // Umbauzeit; Stufe zählt erst danach
state.actionLog.push({ t: state.time, action: 'upgrade', type: b.type, level: b.level + 1 });
return { ok: true, cost, time: Math.round(b.upgrading.left) };
}
export function demolish(state, b) {
if (b.type === 'naturschutz') return { ok: false, reason: 'Ein Schutzgebiet wird nicht einfach wieder aufgehoben.' };
const def = BUILDINGS[b.type];
let invested = 0;
for (let i = 0; i < b.level; i++) invested += def.cost[i];
const refund = Math.round(invested * 0.3);
state.budget += refund;
state.buildings = state.buildings.filter(x => x !== b);
const slot = state.slots[b.slotIdx];
if (slot) slot.building = null;
// gesperrte Bauplätze eines Schutzgebiets wieder freigeben
if (b.blockedSlots) for (const i of b.blockedSlots) { if (state.slots[i]) state.slots[i].blocked = false; }
state.actionLog.push({ t: state.time, action: 'demolish', type: b.type });
if (def.opensBerg) {
// Wer gerade am Berg unterwegs ist, steigt um: Rest des Wegs zu Fuß
for (const v of state.visitors) {
if (v.pts.some(p => p.lift)) {
for (const p of v.pts) delete p.lift;
}
}
}
refreshInfoBoosts(state);
return { ok: true, refund, name: def.name };
}
// Tourismusinfo-Multiplikator (§10) + Synergien (§9.2) je Gebäude neu berechnen.
// Wird nach jedem Bau/Ausbau/Abriss aufgerufen.
function refreshInfoBoosts(state) {
const infos = state.buildings.filter(b => b.type === 'tourismusinfo');
for (const b of state.buildings) {
let boost = 1;
for (const inf of infos) {
if (inf === b) continue;
const r = BUILDINGS.tourismusinfo.range[inf.level - 1] * TILE2PX;
if (Math.hypot(inf.x - b.x, inf.y - b.y) <= r) {
boost += BUILDINGS.tourismusinfo.boost[inf.level - 1];
}
}
b.infoBoost = boost;
b.synergy = 1;
b.synergyList = [];
}
// Synergie-Paare: beide Partner bekommen den Bonus, wenn in Reichweite
for (const rule of SYNERGIES) {
for (const x of state.buildings) {
if (x.type !== rule.a) continue;
for (const y of state.buildings) {
if (y.type !== rule.b) continue;
if (Math.hypot(x.x - y.x, x.y - y.y) <= rule.radius) {
x.synergy += rule.bonus; x.synergyList.push(rule.why);
y.synergy += rule.bonus; y.synergyList.push(rule.why);
}
}
}
}
}
function clampEnv(v) {
return Math.max(5, Math.min(100, v));
}
// ---------- „Nächste Welle" (Tower-Defense-Klassiker) ----------
// Ruft die nächste Phase sofort Bonus fürs Risiko, restliche Spawns der
// laufenden Phase entfallen. In Phase 8 nicht mehr möglich.
export function callNextWave(state) {
if (state.ended) return { ok: false, reason: 'Die Partie ist vorbei.' };
if (state.waveStatus === 'pause') {
// Baupause überspringen: kleiner Bonus für die gesparte Zeit
const bonus = Math.round(Math.max(0, state.pauseLeft) * 2) + 10;
state.budget += bonus;
launchWave(state);
state.actionLog.push({ t: state.time, action: 'next_wave', bonus });
return { ok: true, bonus };
}
if (state.phase >= 8) return { ok: false, reason: 'Die letzte Welle läuft bereits.' };
// Welle früh rufen: Risiko-Bonus je noch aktivem Gast
const remaining = state.visitors.filter(v => v.wave === state.phase).length;
const bonus = 15 + remaining * 4;
state.budget += bonus;
prepareWave(state, state.phase + 1);
launchWave(state);
state.actionLog.push({ t: state.time, action: 'next_wave', bonus, early: true });
return { ok: true, bonus };
}
// ---------- Endauswertung (§12, §21) ----------
export function finish(state) {
if (state.ended) return;
state.ended = true;
const st = state.stats;
const avgStars = st.all.count ? st.all.stars / st.all.count : 0;
const nonCar = st.transports.foot + st.transports.bike + st.transports.bus;
const total = Math.max(1, st.spawned);
let lenkung = (nonCar / total) * 100;
if (state.buildings.some(b => b.type === 'bushaltestelle')) lenkung = Math.min(100, lenkung + 10);
const scores = {
rating: (avgStars / 5) * 100,
revenue: Math.min(100, (state.revenueTotal / 2500) * 100),
sustain: state.env,
lenkung,
finance: state.budget >= 0 ? 100 : Math.max(0, 100 * (1 + state.budget / 1000)),
};
const score = Math.round(
scores.rating * 0.35 + scores.revenue * 0.25 + scores.sustain * 0.20
+ scores.lenkung * 0.10 + scores.finance * 0.10,
);
state.result = {
avgStars, score, scores,
revenue: Math.round(state.revenueTotal),
env: Math.round(state.env),
budget: Math.round(state.budget),
visitors: st.spawned,
rated: st.all.count,
goals: GOALS.map(g => ({ label: g.label, done: g.check(state) })),
};
state.result.profile = PROFILES.find(p => p.check(state));
state.result.feedback = buildFeedback(state);
state.pendingCards.push({ kind: 'end' });
}
function buildFeedback(state) {
const st = state.stats;
const lines = [];
// stärkste und schwächste Gruppe
let best = null, worst = null;
for (const [id, g] of Object.entries(st.groups)) {
if (g.count < 3) continue;
if (!best || g.avg > best.g.avg) best = { id, g };
if (!worst || g.avg < worst.g.avg) worst = { id, g };
}
if (best && best.g.avg >= 3) {
lines.push(`Besonders wohl fühlten sich ${plural(best.id)}${best.g.avg.toFixed(1)} Sterne) `
+ 'dein Angebot passte zu ihren Bedürfnissen.');
} else if (best) {
lines.push('Keine Besuchergruppe wurde richtig glücklich die Region bot zu wenig passende '
+ 'Angebote entlang der Wege.');
}
// Wirtschaft
const topRev = Object.entries(st.buildingRevenue).sort((a, b) => b[1] - a[1])[0];
if (topRev) {
lines.push(`Wirtschaftlich trug ${BUILDINGS[topRev[0]].name} am meisten zur regionalen `
+ `Wertschöpfung bei (${Math.round(topRev[1])} € von ${Math.round(state.revenueTotal)} €).`);
}
if (state.budget < 0) {
lines.push('Das Budget rutschte ins Minus Betriebskosten laufen weiter, auch wenn keine Gäste da sind. '
+ 'Teure Infrastruktur braucht Zeit, bis sie sich rechnet (Amortisation).');
}
// Umwelt
if (state.env >= 65) {
lines.push('Der Umweltwert blieb hoch. Das zahlt sich doppelt aus: Naturgruppen bewerten besser, '
+ 'und die Landschaft bleibt als wichtigster Standortfaktor erhalten.');
} else if (state.env < 45) {
lines.push('Der Umweltwert ist stark gesunken Parkflächen, Lift und Ausbau haben Spuren hinterlassen. '
+ 'Naturnahe Gäste haben das in ihren Bewertungen deutlich gemacht.');
}
// Verkehr / Mobilität
if (state.stats.skippedNoParking > 2) {
lines.push(`${state.stats.skippedNoParking} Busgruppen fanden keinen Parkplatz und fuhren weiter `
+ 'Erreichbarkeit entscheidet, wer überhaupt Gast werden kann.');
}
if (state.traffic > 30) {
lines.push('Der Verkehr im Ort wurde zur Belastung empfindliche Gäste (Senioren, Familien) '
+ 'bewerteten schlechter. Bushaltestelle und Besucherlenkung könnten helfen.');
}
// Verbesserungstipp
if (worst && worst.g.avg < 3) {
lines.push(`Am schlechtesten schnitten ${plural(worst.id)} ab (Ø ${worst.g.avg.toFixed(1)} Sterne). `
+ `Ihnen fehlte: ${GROUPS[worst.id].wish}.`);
}
return lines;
}
function plural(id) {
return {
spaziergaenger: 'Spaziergänger', wanderer: 'Wanderer', familie: 'Familien',
radfahrer: 'Radfahrer', senioren: 'Seniorengruppen', schulklasse: 'Schulklassen',
busgruppe: 'Busreisegruppen', junge_gruppe: 'junge Reisegruppen', luxuspaar: 'wohlhabende Paare',
}[id] || id;
}