cb60a8c16d
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>
902 lines
39 KiB
JavaScript
902 lines
39 KiB
JavaScript
// 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;
|
||
}
|