// Spielzustand und Simulationsmodell. Bewusst klein und nachvollziehbar // gehalten: jede Tagesrechnung wird in state.report als Klartext abgelegt, // damit Schüler/innen das Modell verstehen können ("Tagesbericht"). // Gebäude werden Schritt für Schritt freigeschaltet (Meilensteine). import { BUILDINGS, SCENARIOS, SEASONS, DAYS_PER_SEASON, KNOWLEDGE } from './data.js'; // Bebaubare Kernzone (das eigentliche Tal) plus ein breiter, unbebaubarer // Bergrahmen ringsum. Dadurch füllt die Iso-Landschaft immer das ganze // rechteckige Fenster (keine leeren Ecken) – man bleibt in der Rautensicht. const MARGIN = 14; const CORE_COLS = 22; const CORE_ROWS = 26; export const MAP_COLS = CORE_COLS + MARGIN * 2; // 38 export const MAP_ROWS = CORE_ROWS + MARGIN * 2; // 42 export const CORE = { c0: MARGIN, r0: MARGIN, c1: MARGIN + CORE_COLS - 1, r1: MARGIN + CORE_ROWS - 1, }; export const ROAD_ROW = MARGIN + 23; // Talstraße; alles darüber ist Berg export const PISTE_LEN = 5; // Standard-Pistenlänge (Sessellift) // Liegt (c,r) in der bebaubaren Kernzone? export function inCore(c, r) { return c >= CORE.c0 && c <= CORE.c1 && r >= CORE.r0 && r <= CORE.r1; } // Deterministischer Zufall, damit die Karte für alle gleich aussieht // und Playwright-Tests reproduzierbar bleiben. function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export function genMap() { const rnd = mulberry32(1975); // 1) Höhenfeld. Kernzone: Tal unten, Berg oben. Rahmen: hohe Berge, die // nach außen ansteigen – so ist das rechteckige Fenster stets gefüllt. let elev = []; for (let r = 0; r < MAP_ROWS; r++) { elev.push([]); for (let c = 0; c < MAP_COLS; c++) { const cr = r - CORE.r0, cc = c - CORE.c0; // core-relative let h; if (inCore(c, r)) { const t = (CORE_ROWS - 1 - cr) / (CORE_ROWS - 1); h = Math.pow(Math.max(0, t - 0.18) / 0.82, 1.25) * 9.5; } else { // Rahmenberge steigen nach außen an – aber NUR nach hinten/zur Seite. // Zur Kamera hin (vorne/unten, großes c+r) öffnet sich das Tal flach, // sonst verdecken Vordergrund-Berge nur das Spielfeld. const dx = Math.max(0, CORE.c0 - c, c - CORE.c1); const dy = Math.max(0, CORE.r0 - r, r - CORE.r1); const d = Math.max(dx, dy); const backEdge = CORE.c0 + CORE.r0; // hinterste Tal-Ecke (klein) const frontEdge = CORE.c1 + CORE.r1; // vorderste Tal-Ecke (groß) // 1 hinten → 0 vorne: blendet die Rahmenhöhe zur Kamera hin aus const taper = Math.max(0, Math.min(1, (frontEdge - (c + r)) / (frontEdge - backEdge))); h = (8 + d * 0.9) * taper; // hinten Gebirge, vorne Talboden } elev[r].push(h + (rnd() - 0.5) * (h > 0.5 ? 1.6 : 0.6)); } } for (let pass = 0; pass < 2; pass++) { const sm = elev.map(row => row.slice()); for (let r = 0; r < MAP_ROWS; r++) { for (let c = 0; c < MAP_COLS; c++) { const n = [elev[r - 1]?.[c], elev[r + 1]?.[c], elev[r][c - 1], elev[r][c + 1]] .filter(v => v !== undefined); sm[r][c] = (elev[r][c] * 2 + n.reduce((a, b) => a + b, 0)) / (2 + n.length); } } elev = sm; } // 2) Kacheln mit Terrain je Höhe const tiles = []; for (let r = 0; r < MAP_ROWS; r++) { const row = []; for (let c = 0; c < MAP_COLS; c++) { const e = Math.max(0, Math.round(elev[r][c])); let terrain; if (e >= 8) terrain = 'rock'; else if (e >= 6) terrain = rnd() < 0.32 ? 'rock' : 'alm'; // viel Alm oben else if (e >= 3) terrain = rnd() < 0.5 ? 'forest' : 'alm'; else if (e >= 1) terrain = rnd() < 0.22 ? 'forest' : 'meadow'; else terrain = rnd() < 0.3 ? 'field' : 'meadow'; row.push({ c, r, elev: e, terrain, building: null, deco: null, border: !inCore(c, r), // Rahmen = nicht bebaubar tint: Math.round((rnd() - 0.5) * 10), }); } tiles.push(row); } // Fluss: mäandert durch die Kernzone von Nord nach Süd let riverC = CORE.c0 + 12; for (let r = CORE.r0; r <= CORE.r1; r++) { riverC = Math.max(CORE.c0 + 3, Math.min(CORE.c1 - 3, riverC + Math.round((rnd() - 0.45) * 2))); const t = tiles[r][riverC]; t.terrain = 'water'; t.deco = null; if (r > CORE.r0) t.elev = Math.min(t.elev, tiles[r - 1][riverC].elev); } // Fluss läuft im flachen Vordergrund weiter, driftet nach rechts und mündet // in einen Bergsee in der vorderen rechten Ecke. Reines Sperr-/Kulissenland. const lakeC = MAP_COLS - 6, lakeR = MAP_ROWS - 6; // vom Kartenrand eingerückt let frc = riverC; for (let r = CORE.r1 + 1; r < MAP_ROWS - 1; r++) { frc = Math.min(lakeC, frc + 2); // steuert Richtung Bergsee const wide = r - CORE.r1 > 3 ? 1 : 0; // wird zur Mündung breiter for (let w = 0; w <= wide; w++) { const t = tiles[r]?.[frc + w]; if (t) { t.terrain = 'water'; t.deco = null; t.building = null; t.elev = 0; } } } for (let r = 0; r < MAP_ROWS - 1; r++) { // elliptischer Bergsee for (let c = 0; c < MAP_COLS - 1; c++) { if (((c - lakeC) / 4) ** 2 + ((r - lakeR) / 3) ** 2 <= 1) { const t = tiles[r][c]; t.terrain = 'water'; t.deco = null; t.building = null; t.elev = 0; } } } // Talstraße quert die ganze Karte von West nach Ost (läuft an beiden Enden // ins Gelände hinaus, endet nicht mehr im flachen Vordergrund), mit Brücke // über den Fluss. Straßentiles werden auf Talniveau eingeschnitten. for (let c = 0; c < MAP_COLS; c++) { const t = tiles[ROAD_ROW][c]; t.road = true; t.bridge = t.terrain === 'water'; if (!t.bridge) t.elev = Math.min(t.elev, 1); } // Bestehendes Dorf im Tal, beidseits der Straße (Kulisse), core-relativ [[22, 6, 'church'], [22, 8, 'house'], [24, 7, 'house'], [24, 9, 'house'], [21, 7, 'house']] .forEach(([cr, cc, deco]) => { const t = tiles[CORE.r0 + cr]?.[CORE.c0 + cc]; if (!t || t.terrain === 'water' || t.road) return; t.terrain = 'village'; t.deco = deco; }); return tiles; } export function createState(sceneId = 'pionierzeit') { const S = SCENARIOS[sceneId] || SCENARIOS.pionierzeit; // Freischaltung: Szene 1 startet klein, spätere Szenen mit allem const unlocked = S.startUnlocked === 'all' ? new Set(Object.keys(BUILDINGS).filter(k => !BUILDINGS[k].scenes || BUILDINGS[k].scenes.includes(S.id))) : new Set(S.startUnlocked); const state = { scenario: S, map: genMap(), buildings: [], // {type, c, r} pistes: [], // {c, r, owner:{c,r}} – Abfahrten oberhalb der Lifte trails: [], // {c, r, owner:{c,r}} – Bike-Trails unterhalb des Bikeparks unlocked, day: 0, year: S.startYear, season: SEASONS[0], money: S.startMoney, price: 25, // Skipass-Preis (Regler) snow: 0, // Schneehöhe in cm (Start im Sommer) weather: { warmWinter: false }, guestsToday: 0, awareness: S.startAwareness, satisfaction: 70, nature: S.startNature, staffNeeded: 0, staffAvailable: S.baseStaffHousing, stats: { maxGuests: 0, maxSummerGuests: 0 }, demandToday: 0, bottleneck: null, // warum kamen weniger Gäste als wollten? bottleneckStreak: 0, lossStreak: 0, // Tage in Folge mit Verlust advisorLast: {}, // Cooldowns der Berater-Hinweise overnightToday: 0, questIndex: 0, goalsDone: false, deadlineMissed: false, valleyName: 'Tourismustal', stageRow: S.stageRow ? CORE.r0 + S.stageRow : 0, // Hochlagen-Sperre (core-relativ) race: null, // sichtbares Skirennen {until} fest: null, // sichtbares Sommerfest {until} history: { money: [], guests: [] }, report: ['Noch kein Tag simuliert.'], firedEvents: new Set(), lastInterruptDay: -999, // letzter Tag mit Ereignis-/Entscheidungskarte (Pacing) rng: mulberry32(4242), // Wetter-Zufall, deterministisch }; // Bestehende Infrastruktur späterer Szenen (Boom/Klimawandel) – // möglichst nah am Dorf platziert, damit sie im Startbild sichtbar ist const anchor = { c: CORE.c0 + 8, r: CORE.r0 + 22 }; const candidates = []; for (let r = 0; r < MAP_ROWS; r++) { for (let c = 0; c < MAP_COLS; c++) { candidates.push({ c, r, d: (c - anchor.c) ** 2 + (r - anchor.r) ** 2 }); } } candidates.sort((a, b) => a.d - b.d); for (const type of S.prebuilt) { const spot = candidates.find(t => canPlace(state, type, t.c, t.r).ok); if (spot) place(state, type, spot.c, spot.r); } state.money = S.startMoney; // Vorbauten kosten nichts … state.nature = S.startNature; // … ihre Spuren stecken schon im Startwert return state; } export function zoneOf(tile) { if (tile.elev <= 1) return 'valley'; if (tile.elev <= 5) return 'mid'; return 'high'; } // Großzügige, überlappende Bauzonen: Höhe 2 zählt als Tal UND Hang, // Höhe 6 als Hang UND Hochlage – so gibt es überall genug Platz. const ZONE_RANGE = { valley: [0, 2], mid: [2, 6], high: [6, 99] }; function nearWater(state, c, r, dist) { for (let rr = Math.max(0, r - dist); rr <= Math.min(MAP_ROWS - 1, r + dist); rr++) { for (let cc = Math.max(0, c - dist); cc <= Math.min(MAP_COLS - 1, c + dist); cc++) { if (state.map[rr][cc].terrain === 'water') return true; } } return false; } // Prüft Platzierung; gibt {ok, reason} zurück, damit die UI (und die // Telemetrie) den Ablehnungsgrund kennt – didaktisch wertvoll. export function canPlace(state, type, c, r) { const def = BUILDINGS[type]; const tile = state.map[r]?.[c]; if (!def || !tile) return { ok: false, reason: 'Außerhalb der Karte.' }; if (!state.unlocked.has(type)) return { ok: false, reason: 'Noch nicht freigeschaltet.' }; if (state.money < def.cost) return { ok: false, reason: 'Nicht genug Geld.' }; // Footprint prüfen: 1×1 Standard, große Gebäude (size 2 = 2×2) brauchen // jede Teilfläche frei. Anker ist die Kachel links oben (c, r). const size = def.size || 1; for (let dr = 0; dr < size; dr++) { for (let dc = 0; dc < size; dc++) { const t = state.map[r + dr]?.[c + dc]; const platz = size > 1 ? ` (${def.name} braucht ${size}×${size} freie Felder)` : ''; if (!t) return { ok: false, reason: 'Außerhalb der Karte.' + platz }; if (!inCore(c + dc, r + dr)) return { ok: false, reason: 'Hier ist unwegsames Hochgebirge – im Tal bauen.' + platz }; if (t.building) return { ok: false, reason: 'Hier steht schon ein Gebäude.' + platz }; if (t.terrain === 'rock') return { ok: false, reason: 'Auf Fels kann nicht gebaut werden.' + platz }; if (t.terrain === 'water') return { ok: false, reason: 'Im Fluss kann nicht gebaut werden.' + platz }; if (t.terrain === 'village') return { ok: false, reason: 'Hier steht das alte Dorf.' + platz }; if (t.road) return { ok: false, reason: 'Auf der Straße kann nicht gebaut werden.' + platz }; if (r + dr < state.stageRow) return { ok: false, reason: 'Die Hochlagen sind noch nicht erschlossen.' }; } } if (def.zone !== 'any') { const [lo, hi] = ZONE_RANGE[def.zone]; // Lift/Gondel wirken wie eine eigene Basis: in ihrem Umkreis (4 Felder) // darf auch am Gebirge alles gebaut werden (außer Bus & Bad). Der // Bikepark hängt ohnehin an der Bahn – Bahn-Nähe ersetzt die Zonen-Regel. const nearLift = ['lift', 'gondola', 'restaurant', 'hotel', 'staffhouse', 'bikepark'].includes(type) && state.buildings.some(b => (b.type === 'lift' || b.type === 'gondola') && dist(b, { c, r }) <= 4); if (!nearLift && (tile.elev < lo || tile.elev > hi)) { const where = def.zone === 'valley' ? 'im Tal (unten)' : 'am Hang (weiter oben)'; return { ok: false, reason: `${def.name} kann nur ${where} gebaut werden – oder im Umkreis eines Lifts.` }; } } if (type === 'snow' && !nearWater(state, c, r, 3)) { return { ok: false, reason: 'Beschneiung braucht Wasser – näher am Fluss bauen!' }; } if (type === 'badesee' && !nearWater(state, c, r, 2)) { return { ok: false, reason: 'Der Badesee braucht Wasser – näher am Fluss bauen!' }; } if ((type === 'bikepark' || type === 'platform') && !state.buildings.some(b => (b.type === 'lift' || b.type === 'gondola') && dist(b, { c, r }) <= 4)) { return { ok: false, reason: `${def.name} braucht eine Bahn in der Nähe (max. 4 Felder) – sie bringt die Gäste hinauf.` }; } if (type === 'access') { let nearRoad = false; for (let rr = r - 1; rr <= r + 1 && !nearRoad; rr++) { for (let cc = c - 1; cc <= c + 1; cc++) { if (state.map[rr]?.[cc]?.road) { nearRoad = true; break; } } } if (!nearRoad) return { ok: false, reason: 'Die Bushaltestelle braucht eine Straße – direkt neben der Talstraße bauen!' }; } return { ok: true }; } export function place(state, type, c, r) { const def = BUILDINGS[type]; const tile = state.map[r][c]; const wasForest = tile.terrain === 'forest'; const size = def.size || 1; state.buildings.push({ type, c, r, alt: tile.elev, size }); state.money -= def.cost; // Alle Teilflächen belegen; Nicht-Anker-Kacheln zeigen auf den Anker // (buildingRef), damit Klick/Abriss überall auf dem Areal funktioniert. for (let dr = 0; dr < size; dr++) { for (let dc = 0; dc < size; dc++) { const t = state.map[r + dr]?.[c + dc]; if (!t) continue; t.building = type; if (dr || dc) t.buildingRef = { c, r }; // Bauen kostet Naturwert – Rodung von Wald deutlich mehr state.nature = Math.max(15, state.nature - (t.terrain === 'forest' ? 5 : 2)); } } if (def.pisteLen) { // Piste entsteht hangaufwärts über der Talstation (Lift & Gondel) for (let i = 1; i <= def.pisteLen; i++) { const t = state.map[r - i]?.[c]; if (t && t.terrain !== 'water') { state.pistes.push({ c, r: r - i, owner: { c, r } }); } } } if (def.trailLen) { // Bike-Trails schlängeln sich talwärts unter der Basisstation (Bikepark). // Sichtbar groß, aber nur 1 Feld Gebäude – und sie kosten extra Natur. let cc = c; for (let i = 1; i <= def.trailLen; i++) { cc += (i % 2 ? 1 : -1); const t = state.map[r + i]?.[Math.max(1, Math.min(MAP_COLS - 2, cc))]; if (t && t.terrain !== 'water' && !t.building) { state.trails.push({ c: t.c, r: t.r, owner: { c, r } }); } } state.nature = Math.max(15, state.nature - 2); } return { wasForest }; } export function demolish(state, c, r) { let tile = state.map[r][c]; if (!tile.building) return { ok: false, reason: 'Hier steht kein Gebäude.' }; // Klick auf eine Teilfläche eines großen Gebäudes → auf den Anker umlenken if (tile.buildingRef) { ({ c, r } = tile.buildingRef); tile = state.map[r][c]; } const def = BUILDINGS[tile.building]; const size = def.size || 1; const refund = Math.round(def.cost * 0.3); state.money += refund; state.nature = Math.min(100, state.nature + 1); state.buildings = state.buildings.filter(b => !(b.c === c && b.r === r)); state.pistes = state.pistes.filter(p => !(p.owner.c === c && p.owner.r === r)); state.trails = state.trails.filter(t => !(t.owner.c === c && t.owner.r === r)); const name = def.name; for (let dr = 0; dr < size; dr++) { for (let dc = 0; dc < size; dc++) { const t = state.map[r + dr]?.[c + dc]; if (!t) continue; t.building = null; t.level = null; delete t.buildingRef; } } return { ok: true, refund, name }; } // Jede Ausbaustufe verstärkt die Kennwerte um +50 % (Stufe I/II/III) export const MAX_LEVEL = 4; export const UPGRADABLE = Object.keys(BUILDINGS); function effVal(b, key) { const v = BUILDINGS[b.type][key] || 0; return Math.round(v * (1 + 0.5 * ((b.level || 1) - 1))); } function dist(a, b) { return Math.max(Math.abs(a.c - b.c), Math.abs(a.r - b.r)); } function sum(state, key) { return state.buildings.reduce((a, b) => a + effVal(b, key), 0); } // Wie stark ist ein Gebäude in dieser Saison in Betrieb? (0.4 = Sparflamme) const WINTER_ONLY = ['lift', 'gondola', 'snow']; const SUMMER_ONLY = ['badesee', 'sled', 'trail', 'bikepark', 'ropes', 'bikerental', 'platform', 'playground']; // Ist an einem Lift auf Höhe `alt` genug Schnee zum Skifahren? // Höher gelegene Lifte behalten länger Schnee (Rückzug von unten nach oben). export function liftSkiable(state, alt) { return state.snow + (((alt ?? 3) - 3) * 5) > 10; } export function seasonFactor(type, season) { const winter = season === 'Winter'; if (WINTER_ONLY.includes(type)) return winter ? 1 : 0.4; if (SUMMER_ONLY.includes(type)) return winter ? 0.4 : 1; return 1; // Ganzjahresbetriebe (Hotel, Gasthaus, Bus, Personalhaus) } // Voller Bericht zu EINEM Gebäude: Werte, Auslastung, Tageskosten, Nutzen. // Grundlage der Inspektions-/Ausbaukarte (Lernprogramm: alles sichtbar). export function buildingReport(state, c, r) { // Teilfläche eines großen Gebäudes → Anker const ref = state.map[r]?.[c]?.buildingRef; if (ref) ({ c, r } = ref); const b = state.buildings.find(x => x.c === c && x.r === r); if (!b) return null; const def = BUILDINGS[b.type]; const level = b.level || 1; const sf = seasonFactor(b.type, state.season); const dailyUpkeep = Math.round(effVal(b, 'upkeep') * sf); const dailyWage = Math.round(effVal(b, 'staff') * sf) * state.scenario.wage; const lines = []; if (def.cap) lines.push(`Förderleistung: ${effVal(b, 'cap')} Gäste/Tag`); if (def.beds) lines.push(`Betten: ${effVal(b, 'beds')}`); if (def.access) lines.push(`Anreise: +${effVal(b, 'access')} Gäste/Tag`); if (def.housing) lines.push(`Wohnraum: ${effVal(b, 'housing')} Angestellte`); if (def.summerCap) lines.push(`Sommergäste: bis ${effVal(b, 'summerCap')}/Tag`); if (def.spendBonus) lines.push(`Mehrumsatz: +${effVal(b, 'spendBonus')} €/Gast`); if (def.snowPower) lines.push(`Beschneiung: +${effVal(b, 'snowPower').toFixed(1)} cm/Tag`); if (def.boost) lines.push(`Verstärker: Bikepark & Panoramaweg im Umkreis +${Math.round(def.boost * 100)} % Gäste`); if (def.trailLen) lines.push(`Flowtrails: ${def.trailLen} Abschnitte talwärts`); // Aktuelle Probleme dieses Gebäudes (erscheinen im Klick-Dialog) const problems = []; if (b.underConstruction) problems.push('🏗️ Wird gerade umgebaut – gleich ist die neue Stufe fertig.'); if (def.staff > 0 && state.staffNeeded > state.staffAvailable) problems.push('👷 Personalmangel: läuft nur mit halber Kraft. Ein Personalhaus schafft Wohnraum.'); if ((b.type === 'lift' || b.type === 'gondola') && state.season === 'Winter' && !liftSkiable(state, b.alt ?? 3)) problems.push('❄️ Zu wenig Schnee zum Skifahren – kaum Skigäste. Höher gelegen ist schneesicherer, eine Beschneiung hilft.'); if (sf < 1) problems.push('💤 Nebensaison: ruht gerade – die laufenden Kosten laufen (reduziert) weiter.'); return { type: b.type, name: def.name, emoji: def.emoji, level, alt: b.alt ?? 3, dailyUpkeep, dailyWage, dailyCost: dailyUpkeep + dailyWage, sparflamme: sf < 1, stats: lines, problems, knowledge: KNOWLEDGE[b.type] || [], canUpgrade: level < MAX_LEVEL && !b.underConstruction, nextCost: level < MAX_LEVEL ? upgradeCost(b.type, level) : null, }; } export function upgradeCost(type, level) { // Ausbaukosten steigen je Stufe: I→II 50 %, II→III 75 %, III→IV 100 % return Math.round(BUILDINGS[type].cost * (0.25 + 0.25 * level)); } export function upgrade(state, c, r) { const ref = state.map[r]?.[c]?.buildingRef; if (ref) ({ c, r } = ref); const tile = state.map[r]?.[c]; const b = state.buildings.find(x => x.c === c && x.r === r); if (!tile?.building || !b) return { ok: false, reason: 'Hier steht kein Gebäude.' }; const def = BUILDINGS[b.type]; const level = b.level || 1; if (level >= MAX_LEVEL) return { ok: false, reason: 'Schon auf Ausbaustufe III (Maximum).' }; if (b.underConstruction) return { ok: false, reason: 'Wird bereits umgebaut – bitte warten.' }; const cost = upgradeCost(b.type, level); if (state.money < cost) return { ok: false, reason: `Ausbau kostet ${cost.toLocaleString('de-AT')} € – nicht genug Geld.` }; state.money -= cost; // Sichtbarer Umbau: der Renderer animiert ~10 s Bauzeit und schaltet dann die // neue Stufe scharf (setzt b.level/tile.level). Bis dahin gilt die alte Stufe. b.underConstruction = true; b.constructTo = level + 1; state.nature = Math.max(15, state.nature - 1); return { ok: true, cost, name: def.name, level: level + 1, building: true }; } export function count(state, type) { return state.buildings.filter(b => b.type === type).length; } export const DAYS_PER_YEAR = 120; const MONTHS = ['Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', 'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni']; // Saison je Monat: Skisaison (Winter) von Oktober bis März – bis Ostern const MONTH_SEASON = ['Sommer', 'Sommer', 'Herbst', 'Winter', 'Winter', 'Winter', 'Winter', 'Winter', 'Winter', 'Frühling', 'Sommer', 'Sommer']; const MONTH_TEMP = [22, 21, 15, 7, 2, -2, -4, -3, 1, 9, 15, 19]; // day ist hier 0-basiert (Aufrufer übergeben state.day - 1) export function dateOfDay(day) { const d = ((day % DAYS_PER_YEAR) + DAYS_PER_YEAR) % DAYS_PER_YEAR; const monthIdx = Math.floor(d / 10); return { monthIdx, month: MONTHS[monthIdx], dayInMonth: (d % 10) * 3 + 1, // 1., 4., 7., … 28. – wie ein Kalender }; } export function seasonOfDay(day) { return MONTH_SEASON[dateOfDay(day).monthIdx]; } // Wetter eines Tages – deterministisch aus der Tagesnummer berechnet, // damit die 3-Tage-Vorhersage exakt stimmt und Tests reproduzierbar sind. export function weatherOfDay(day, S) { const season = seasonOfDay(day - 1); const { monthIdx } = dateOfDay(day - 1); const yearIdx = Math.floor((day - 1) / DAYS_PER_YEAR); const warmChance = Math.min(0.9, S.warmWinterChance + (S.warmWinterGrow || 0) * yearIdx); const warmWinter = mulberry32(yearIdx * 7919 + 13)() < warmChance; const r1 = mulberry32(day * 31 + 7)(); const r2 = mulberry32(day * 17 + 3)(); const temp = Math.round(MONTH_TEMP[monthIdx] + (season === 'Winter' && warmWinter ? 6 : 0) + (S.meltExtra ? 1 : 0) + (r1 - 0.5) * 8); let kind = '☀️'; let snowfall = 0; if (temp <= 1) { if (r2 < (warmWinter ? 0.25 : 0.55)) { kind = '🌨️'; snowfall = 4 + r2 * 18; } else kind = r2 < 0.8 ? '⛅' : '☀️'; } else if (r2 < (season === 'Winter' ? 0.35 : 0.2)) kind = '🌧️'; else kind = r2 < 0.55 ? '⛅' : '☀️'; return { temp, kind, snowfall, warmWinter, season }; } export function simulateDay(state) { const S = state.scenario; state.day += 1; state.season = seasonOfDay(state.day - 1); state.year = S.startYear + Math.floor((state.day - 1) / DAYS_PER_YEAR); const date = dateOfDay(state.day - 1); state.dateStr = `${date.dayInMonth}. ${date.month} ${state.year}`; const rep = []; const winter = state.season === 'Winter'; // --- Wetter & Schnee (temperaturbasiert, mit echter Vorhersage) --- const w = weatherOfDay(state.day, S); state.temp = w.temp; state.weatherToday = w.kind; state.weather.warmWinter = w.warmWinter; state.forecast = [1, 2, 3].map(i => weatherOfDay(state.day + i, S)); state.snow += w.snowfall; if (w.temp > 2) state.snow -= (w.temp - 2) * 0.5; // Schmelze über 2 °C let cannonCost = 0; let cannonActive = 0; // Beschneiung springt an, wenn Schnee knapp wird – braucht aber Kälte! if (winter && state.snow < 45 && w.temp <= 2) { cannonActive = count(state, 'snow'); state.snow += sum(state, 'snowPower'); // Stufe II/III schneit mehr cannonCost = cannonActive * S.snowCannonRun; } state.snow = Math.max(0, Math.min(100, state.snow)); // --- Personal (Winterbetriebe brauchen im Sommer weniger Personal) --- state.staffNeeded = Math.round(state.buildings.reduce((a, b) => a + effVal(b, 'staff') * seasonFactor(b.type, state.season), 0)); // Lage zählt: Ein Personalhaus weit weg von den Betrieben (>4 Felder) // wirkt nur zu zwei Dritteln const workplaces = state.buildings.filter(b => (BUILDINGS[b.type].staff || 0) > 0); let housing = 0; let farHouses = 0; for (const b of state.buildings) { if (b.type !== 'staffhouse') continue; const near = !workplaces.length || workplaces.some(wp => dist(b, wp) <= 6); if (!near) farHouses++; housing += Math.round(effVal(b, 'housing') * (near ? 1 : 2 / 3)); } state.staffAvailable = S.baseStaffHousing + housing; const staffFactor = state.staffNeeded === 0 ? 1 : Math.min(1, state.staffAvailable / state.staffNeeded); const staffWorking = Math.min(state.staffNeeded, state.staffAvailable); // --- Kapazitäten --- const access = S.baseAccess + sum(state, 'access'); // Lage zählt: Höher gebaute Bahnen sind schneesicherer (+5 cm gefühlter // Schnee je Höhenstufe über 3), tief gebaute verlieren früher const bahnen = state.buildings.filter(b => b.type === 'lift' || b.type === 'gondola'); let liftCap = 0; let effLiftCap = 0; for (const b of bahnen) { const cap = effVal(b, 'cap'); liftCap += cap; const okSnow = Math.max(0, Math.min(1, (state.snow + ((b.alt ?? 3) - 3) * 5) / 28)); effLiftCap += cap * okSnow; } effLiftCap = Math.round(effLiftCap); // Skiverbund: nahe Bahnen (≤4 Felder) teilen sich die Gäste → attraktiver let verbund = 0; for (let i = 0; i < bahnen.length; i++) { for (let j = i + 1; j < bahnen.length; j++) { if (dist(bahnen[i], bahnen[j]) <= 4) verbund++; } } verbund = Math.min(2, verbund); const liftCount = bahnen.length; // Sommer-Kapazität: Fahrradverleih verstärkt Bikepark & Panoramaweg im // Umkreis von 4 Feldern (+25 % Gäste) – Komplementärangebot, keine eigene // Attraktion (siehe QUELLEN.md, Wertschöpfungskette). let summerCap = 0; const rentals = state.buildings.filter(b => b.type === 'bikerental'); for (const b of state.buildings) { const base = effVal(b, 'summerCap'); if (!base) continue; const boosted = (b.type === 'bikepark' || b.type === 'trail') && rentals.some(x => dist(x, b) <= 4); summerCap += Math.round(base * (boosted ? 1 + (BUILDINGS.bikerental.boost || 0.25) : 1)); } const terrainCap = winter ? effLiftCap : 80 + liftCount * 60 + summerCap; // --- Nachfrage --- const natureFactor = 0.5 + state.nature / 200; // 0.575 … 1.0 const satFactor = 0.6 + state.satisfaction / 250; // 0.6 … 1.0 const snowFactor = winter ? Math.max(0.15, liftCap > 0 ? effLiftCap / liftCap : Math.min(1, state.snow / 30)) : 1; const verbundFactor = winter ? 1 + 0.08 * verbund : 1; const priceFactor = winter ? Math.max(0.45, Math.min(1.3, 1.25 - (state.price - 15) * 0.025)) : 1; // Bekanntheit wächst langsam – schneller, wenn die Gäste zufrieden sind state.awareness = Math.min(1, state.awareness + 0.006 * (state.satisfaction / 70)); // Tageswetter: Regen hält Gäste ab, Sonne lockt const weatherFactor = w.kind === '🌧️' ? 0.7 : w.kind === '☀️' ? 1.08 : 1; // Sommer-Trend (Szene 3): Sommernachfrage wächst über die Jahre – aber nur, // wenn das Tal konsequent Sommerangebote aufbaut. Reale Vorlage: Serfaus- // Fiss-Ladis +95 % Sommernächtigungen in 10 Jahren (≈ +7 %/Jahr, QUELLEN.md). let trendFactor = 1; if (!winter && S.summerTrend) { const years = (state.day - 1) / DAYS_PER_YEAR; const summerOffers = state.buildings .filter(b => (BUILDINGS[b.type].summerCap || 0) > 0).length; trendFactor = 1 + S.summerTrend * years * Math.min(1, summerOffers / 3); } let demand = S.demand[state.season] * state.awareness * natureFactor * satFactor * snowFactor * priceFactor * weatherFactor * verbundFactor * trendFactor; if (winter && liftCap === 0) demand = 20; // eslint-hint: liftCount oben // ohne Lift kein Skigebiet // Ganz am Anfang: ohne Gasthaus bleibt das Tal unbekannt if (state.buildings.length === 0) demand = Math.min(demand, 15); const guests = Math.round(Math.min(demand, access, terrainCap) * staffFactor); state.guestsToday = Math.max(0, guests); // Engpass-Diagnose: WARUM kamen weniger, als wollten? (Transparenz!) state.demandToday = Math.round(demand); let bottleneck = null; if (staffFactor < 1) bottleneck = 'Personal fehlt'; else if (state.demandToday > guests + 2) { if (access <= Math.min(state.demandToday, terrainCap)) bottleneck = 'Anreise (Bus)'; else bottleneck = winter ? 'Liftplätze' : 'Wege & Attraktionen'; } state.bottleneckStreak = bottleneck && bottleneck === state.bottleneck ? state.bottleneckStreak + 1 : (bottleneck ? 1 : 0); state.bottleneck = bottleneck; state.stats.maxGuests = Math.max(state.stats.maxGuests, state.guestsToday); if (state.season === 'Sommer') { state.stats.maxSummerGuests = Math.max(state.stats.maxSummerGuests, state.guestsToday); } // abgelaufene Feste/Rennen aufräumen if (state.race && state.day > state.race.until) state.race = null; if (state.fest && state.day > state.fest.until) state.fest = null; // --- Geld --- let spendBonus = Math.min(15, sum(state, 'spendBonus')); // Lage zählt: Gasthaus im Einflusskreis (3 Felder) von Bahn/Attraktion/ // Hotel → deren Gäste kehren gleich mit ein (+2 €/Gast je Synergie) const restaurants = state.buildings.filter(b => b.type === 'restaurant'); const magnets = state.buildings.filter(b => ['lift', 'gondola', 'badesee', 'sled', 'trail', 'hotel'].includes(b.type)); let synergies = 0; for (const m of magnets) { if (restaurants.some(rst => dist(rst, m) <= 5)) synergies++; } const synergyBonus = Math.min(6, synergies * 2); spendBonus += synergyBonus; // Talnahe Bahn (Höhe ≤3): Gäste sind schneller dort und konsumieren mehr const lowLift = winter && bahnen.some(b => (b.alt ?? 3) <= 3); const lowLiftBonus = lowLift ? 2 : 0; const beds = sum(state, 'beds'); const overnight = Math.min(Math.round(guests * 0.5), beds); state.overnightToday = overnight; const perGuest = (winter ? state.price : S.summerSpend) + spendBonus + lowLiftBonus; // Außerhalb des Winters kaufen viele Gäste eine Bergfahrt mit dem Lift const liftRiders = (!winter && liftCount > 0) ? Math.round(guests * 0.4) : 0; const liftTicketIncome = liftRiders * 8; const income = guests * perGuest + overnight * S.overnightSpend + liftTicketIncome; // Betriebskosten je Gast (Liftstrom, Reinigung, Wäsche …): 55 % vom Umsatz const opsCost = Math.round(income * 0.58); const upkeep = Math.round(state.buildings.reduce((a, b) => a + effVal(b, 'upkeep') * seasonFactor(b.type, state.season), 0)); const wages = staffWorking * S.wage; state.money = Math.round(state.money + income - opsCost - upkeep - wages - cannonCost); state.lastNet = Math.round(income - opsCost - upkeep - wages - cannonCost); state.lossStreak = state.lastNet < 0 ? state.lossStreak + 1 : 0; // --- Zufriedenheit: Gedränge, Gastro, Preis, Schnee --- const crowding = terrainCap > 0 ? guests / terrainCap : 0; let satTarget = 70; if (count(state, 'restaurant') > 0) satTarget += 12; satTarget += Math.min(8, sum(state, 'satBonus')); if (crowding > 0.9) satTarget -= 20; if (staffFactor < 1) satTarget -= 15; if (winter) { satTarget -= (state.price - 25) * 0.6; if (state.snow < 15) satTarget -= 10; } satTarget += (state.nature - 70) / 4; state.satisfaction = Math.round( Math.min(100, Math.max(10, state.satisfaction + (satTarget - state.satisfaction) * 0.08)) ); // --- Natur erholt sich langsam --- state.nature = Math.min(100, state.nature + 0.04 - state.buildings.length * 0.004); // --- Umbau-Fertigstellung (Fallback) --- // Primär animiert der Renderer den Umbau (10 s Spielzeit = 5 Tage bei 1×) // und schaltet die Stufe scharf. Headless (Tests) übernimmt das Modell // nach 6 Tagen – einen Tag später, damit im Spiel der Renderer gewinnt. for (const b of state.buildings) { if (!b.underConstruction) continue; b._cDays = (b._cDays || 0) + 1; if (b._cDays >= 6) { b.level = b.constructTo; const t = state.map[b.r]?.[b.c]; if (t) t.level = b.constructTo; delete b.underConstruction; delete b.constructTo; delete b._cDays; delete b._cStart; } } // --- Notbremse Überschuldung --- // Ab 15.000 € Schulden verkauft die Bank das Gebäude mit den höchsten // laufenden Kosten (30 % Erlös), max. alle 30 Tage. Verhindert die endlose // Schuldenspirale des "alles sofort bauen"-Spielstils – didaktisch sichtbar. if (state.money < -15000 && state.buildings.length > 1 && state.day - (state.lastForcedSaleDay ?? -999) >= 15) { let worst = null; for (const b of state.buildings) { const up = effVal(b, 'upkeep') + effVal(b, 'staff') * S.wage; if (!worst || up > worst.up) worst = { b, up }; } if (worst) { const res = demolish(state, worst.b.c, worst.b.r); if (res.ok) { state.lastForcedSaleDay = state.day; state.forcedSale = { name: res.name, refund: res.refund, day: state.day }; } } } // --- Ziele --- if (!state.goalsDone && S.goals.every(g => g.check(state))) { state.goalsDone = true; } if (!state.goalsDone && state.day === S.deadlineDay) { state.deadlineMissed = true; } // --- Verlauf & Bericht --- state.history.money.push(state.money); state.history.guests.push(state.guestsToday); if (state.history.money.length > 240) { state.history.money.shift(); state.history.guests.shift(); } // --- Strukturierter Tagesbericht (wird in ui.js als Tabelle gerendert) --- const nachfrageFaktoren = [ { name: 'Bekanntheit', mult: state.awareness }, { name: 'Natur', mult: natureFactor }, { name: 'Zufriedenheit', mult: satFactor }, { name: 'Wetter', mult: weatherFactor }, ]; if (winter) { nachfrageFaktoren.push({ name: 'Schnee', mult: snowFactor }); nachfrageFaktoren.push({ name: 'Skipass-Preis', mult: priceFactor }); if (verbund > 0) nachfrageFaktoren.push({ name: 'Skiverbund', mult: verbundFactor }); } const einnahmen = [ { label: `Gäste (${guests} × ${perGuest} €)`, amount: guests * perGuest }, ]; if (overnight) einnahmen.push({ label: `Übernachtungen (${overnight} × ${S.overnightSpend} €)`, amount: overnight * S.overnightSpend }); if (liftRiders) einnahmen.push({ label: `Bergfahrten (${liftRiders} × 8 €)`, amount: liftTicketIncome }); const ausgaben = [ { label: 'Betrieb (58 % vom Umsatz)', amount: opsCost }, { label: 'Instandhaltung', amount: upkeep }, { label: 'Löhne', amount: wages }, ]; if (cannonCost) ausgaben.push({ label: 'Beschneiung', amount: cannonCost }); const hinweise = []; if (winter && count(state, 'snow') > 0 && !cannonActive && state.snow < 45 && w.temp > 2) hinweise.push('Zu warm für die Beschneiung (braucht ≤ 2 °C).'); if (winter && liftCap > 0 && effLiftCap < liftCap) hinweise.push(`Höhenlage: nur ${effLiftCap} von ${liftCap} Liftplätzen sind schneesicher – höher gebaute Bahnen halten länger.`); if (synergyBonus > 0) hinweise.push(`Gasthaus-Synergie: ${synergies} Nachbarschaft(en) → +${synergyBonus} €/Gast.`); if (lowLiftBonus > 0) hinweise.push('Talnahe Bahn: Gäste konsumieren +2 €/Gast.'); if (farHouses > 0) hinweise.push(`${farHouses} Personalhaus/-häuser stehen weiter als 6 Felder weg (nur ⅔ Wirkung).`); if (staffFactor < 1) hinweise.push(`Nur ${state.staffAvailable} von ${state.staffNeeded} Stellen besetzt – Betriebe auf ${Math.round(staffFactor * 100)} %.`); state.reportData = { date: state.dateStr, season: state.season, weather: `${w.kind} ${w.temp} °C`, snow: winter ? Math.round(state.snow) : null, warmWinter: state.weather.warmWinter, demandBase: Math.round(S.demand[state.season]), demandFactors: nachfrageFaktoren, demandResult: Math.round(demand), access, terrainCap, capLabel: winter ? 'Liftplätze' : 'Wege & Attraktionen', bottleneck, bottleneckLost: Math.max(0, state.demandToday - guests), guests, overnight, einnahmen, ausgaben, income, expenses: opsCost + upkeep + wages + cannonCost, net: state.lastNet, hinweise, }; // Kompakte Textfassung (Fallback / Log) state.report = [`${state.dateStr} – ${guests} Gäste, Netto ${state.lastNet} €`]; return state; }