// 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 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; 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; }