Files
geograsim/App/sims/tourismusregion/js/model.js
T
Adminator fecd8bb9c9 Textsystem: Bis-Strich mit Leerzeichen (Zahl – Zahl) per Agent-Sweep
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (=
"bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex
/([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung
unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle
JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein
Logik-Key) -> safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:38:19 +02:00

834 lines
32 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.
// 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;
// Übertourismus (§10 Raumnutzungskonflikt): dichte Bebauung + viel Verkehr
// zehren die Umwelt langsam aus. Ab ~6 fertigen Bauwerken beginnt der Druck.
// „Alles zubauen" drückt die Umwelt so über die Partie klar unter die
// 45er-Schwelle — vorher blieb sie fast konstant. Naturschutzgebiet (+20)
// und maßvolles Bauen sind die Gegenmittel.
const builtCount = state.buildings.reduce((n, b) => n + (b.construction <= 0 ? 1 : 0), 0);
const overbuild = Math.max(0, builtCount - 6);
const envErode = overbuild * 0.06 + state.traffic * 0.012; // pro 10 s
if (envErode > 0) state.env = clampEnv(state.env - envErode);
// Einmalige Lern-Warnung, sobald die Umwelt kippt (didaktischer Hinweis).
if (state.env < 50 && !state.envWarned) {
state.envWarned = true;
state.pendingCards.push({
kind: 'card', icon: '🌲', title: 'Die Umwelt leidet!', sound: 'card',
text: 'Deine Umwelt ist unter 50 gefallen. Umweltbewusste Gäste '
+ 'Wanderer, Naturfreunde, Radgruppen werden unzufrieden, wenn sie '
+ 'unter 45 sinkt, und bleiben irgendwann ganz weg.\n\n'
+ 'Was hilft: maßvoller bauen, auf sanfte Anreise (Bus, Rad, Bahn) '
+ 'setzen oder ein 🌿 Naturschutzgebiet bauen, das die Umwelt sofort '
+ 'um +20 hebt. Naturschutz gegen Ausbau abwägen ist der Kern des Spiels.',
});
}
}
// 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 4 6 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;
// Umwelt-Malus WIRKLICH anwenden (war zuvor nur ein toter Flag ohne Wirkung):
// eine kaputte Landschaft vergrault Gäste — je umweltbewusster, desto stärker,
// und je tiefer die Umwelt fällt, desto heftiger. Ab env<60 spürbar, <40 hart.
// Das ist der Raumnutzungskonflikt: zu viel zubauen → Umwelt kippt → Rating fällt.
if (state.env < 60 && g.envSens >= 0.4) {
sat -= g.envSens * (60 - state.env) * 0.5; // env 40 → ~6, env 20 → ~12 (× envSens)
if (state.env < 45) 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;
}