Files
geograsim/App/sims/tourismusregion/js/model.js
T
Adminator 8047778a5b 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>
2026-07-12 01:14:12 +02:00

803 lines
30 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;
}
// 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;
}