Files
geograsim/App/sims/tourismustal/js/model.js
T
Adminator cb60a8c16d Tourismustal: Aussichtsplattform-Baubedingungen entknotet
Vorher drei verschraenkte Regeln (Hoehe >=6 + Talstation <=4 + Hochlagen)
-> fast nirgends baubar. Jetzt: Hoehe >=5 UND Naehe zu Tal- ODER
BERGstation (oberes Pistenende) einer Bahn - thematisch korrekt, die
Bahn bringt die Ausflugsgaeste ja hinauf. 52 baubare Felder um zwei
Bahnen statt Handvoll. Klare Fehlermeldungen je Bedingung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:59:12 +02:00

902 lines
39 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.
// 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') {
// Bahn-Nähe: Talstation zählt immer; für die Aussichtsplattform zählt
// auch die BERGstation (oberes Pistenende) dort gehört sie ja hin.
const nearBahn = state.buildings.some(b => {
if (b.type !== 'lift' && b.type !== 'gondola') return false;
if (dist(b, { c, r }) <= 4) return true;
if (type !== 'platform') return false;
const top = { c: b.c, r: b.r - (BUILDINGS[b.type].pisteLen || 5) };
return dist(top, { c, r }) <= 4;
});
if (!nearBahn) {
return { ok: false, reason: type === 'platform'
? 'Die Aussichtsplattform braucht eine Bahn in der Nähe (max. 4 Felder um Tal- ODER Bergstation).'
: `${def.name} braucht eine Bahn in der Nähe (max. 4 Felder) sie bringt die Gäste hinauf.` };
}
}
if (type === 'platform' && tile.elev < 5) {
return { ok: false, reason: 'Die Aussichtsplattform braucht Aussicht hoch am Berg bauen (weiter oben)!' };
}
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);
}
// 4 gleiche im Quadrat? → Megabauwerk (Skaleneffekt)
const merged = tryMerge(state, c, r);
return { wasForest, merged };
}
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 entry = state.buildings.find(b => b.c === c && b.r === r);
const size = entry?.size || def.size || 1;
// Mega = 4 verschmolzene Gebäude → 4-facher Bauwert als Abriss-Basis
const refund = Math.round(def.cost * (entry?.mega ? 4 : 1) * 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;
delete t.mega;
}
}
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;
let val = v * (1 + 0.5 * ((b.level || 1) - 1));
// Megabauwerk (4 verschmolzene Gebäude): 4-fache Leistung, aber nur
// 3-fache Betriebskosten Skaleneffekt („economies of scale").
if (b.mega) val *= (key === 'upkeep' || key === 'staff') ? 3 : 4;
return Math.round(val);
}
// Verschmelzen: liegen 4 gleiche 1×1-Gebäude im 2×2-Quadrat, werden sie zu
// EINEM Megabauwerk (size 2, mega). Bahnen, Bus & Bikepark ausgenommen.
const MERGEABLE = ['restaurant', 'hotel', 'staffhouse', 'snow',
'badesee', 'ropes', 'playground', 'bikerental', 'trail'];
function tryMerge(state, c, r) {
const type = state.map[r]?.[c]?.building;
if (!type || !MERGEABLE.includes(type)) return null;
const at = (cc, rr) => state.buildings.find(b =>
b.c === cc && b.r === rr && b.type === type
&& (b.size || 1) === 1 && !b.mega && !b.underConstruction);
// alle 4 möglichen Quadrate, die (c,r) enthalten
for (const [ac, ar] of [[c, r], [c - 1, r], [c, r - 1], [c - 1, r - 1]]) {
const parts = [at(ac, ar), at(ac + 1, ar), at(ac, ar + 1), at(ac + 1, ar + 1)];
if (parts.some(p => !p)) continue;
const level = Math.max(...parts.map(p => p.level || 1));
// drei Teile entfernen, Anker wird Mega
const anchor = parts[0];
state.buildings = state.buildings.filter(b => b === anchor || !parts.includes(b));
anchor.size = 2; anchor.mega = true; anchor.level = level;
for (let dr = 0; dr < 2; dr++) {
for (let dc = 0; dc < 2; dc++) {
const t = state.map[ar + dr][ac + dc];
t.building = type;
t.level = level;
if (dr || dc) t.buildingRef = { c: ac, r: ar };
else { delete t.buildingRef; t.mega = true; }
}
}
return { type, name: BUILDINGS[type].name, c: ac, r: ar, level };
}
return null;
}
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`);
if (b.mega) lines.push('🏛️ Skaleneffekt: 4-fache Leistung, aber nur 3-fache Betriebskosten');
// 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: b.mega ? `Mega-${def.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;
}