Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

793 lines
33 KiB
JavaScript

/**
* Flussmanagement — Engine (Single Source of Truth)
* ------------------------------------------------------------------
* Headless Spiellogik: kein DOM, keine Audio-Aufrufe, kein Zeichnen.
* Der Konsument (game.html) ruft die Engine-Methoden auf und zeichnet
* den zurueckgegebenen Zustand. Events werden in einem internen Puffer
* `game._pendingEvents` gesammelt und bei der naechsten mutierenden
* Aktion zurueckgegeben.
*
* Portiert aus App/fluss.html (V1). Logik bleibt weitgehend erhalten,
* wird aber in eine serialisierbare State-Struktur gehoben, damit
* Autosave und Resume sauber funktionieren.
*
* Exportiert als globales `window.FlussEngine` (kein ES-Module).
*/
(function () {
'use strict';
/* ============================================================
KONSTANTEN — Grid, Werkzeuge, Wetter, Schwierigkeit
============================================================ */
const COLS = 20;
const ROWS = 16;
const RIVER_SEGMENTS = ROWS; // ein Fluss-Punkt pro Zeile
const MAX_YEARS_DEFAULT = 100;
const TOOLS = [
{ id: 'inspect', name: 'Ansehen', icon: '🔍', cost: 0, desc: 'Klicken, um ein Feld zu untersuchen.' },
{ id: 'deich', name: 'Deich bauen', icon: '🛡️', cost: 8, desc: 'Schützt angrenzende Felder vor Hochwasser.' },
{ id: 'feld', name: 'Feld anlegen', icon: '🌾', cost: 4, desc: 'Mehr Nahrung, aber weniger Biodiversität.' },
{ id: 'siedlung', name: 'Siedlung bauen', icon: '🏘️', cost: 12, desc: 'Mehr Bevölkerung und Wirtschaft.' },
{ id: 'wald', name: 'Wald pflanzen', icon: '🌳', cost: 6, desc: 'Stärkt Biodiversität und Retention.' },
{ id: 'abriss', name: 'Abriss', icon: '🧨', cost: 2, desc: 'Entfernt ein gebautes Feld.' },
{ id: 'begradigen', name: 'Fluss begradigen', icon: '📏', cost: 15, desc: 'Gewinnt Bauland, schadet Biodiversität.' },
];
const WEATHER = [
{ key: 'drought', name: 'Dürre', icon: '☀️', factor: 0.2, prob: 0.12 },
{ key: 'dry', name: 'Trocken', icon: '🌤️', factor: 0.4, prob: 0.25 },
{ key: 'normal', name: 'Normal', icon: '⛅', factor: 0.8, prob: 0.33 },
{ key: 'rain', name: 'Regen', icon: '🌧️', factor: 1.5, prob: 0.20 },
{ key: 'heavy_rain', name: 'Starkregen', icon: '⛈️', factor: 2.8, prob: 0.10 },
];
// Achievements — werden in tick/applyTool gesetzt und bei Level-Ende
// ausgewertet.
// Tierarten als Biodiversitaets-Belohnung. Je hoeher der Biodiv-Wert,
// desto seltenere Arten siedeln sich an. Hysterese: Arten verschwinden
// erst, wenn Biodiv 5 Punkte unter die Schwelle faellt — verhindert
// Flapping an der Grenze.
const SPECIES = [
{ key: 'hase', icon: '🐇', name: 'Feldhase', threshold: 40 },
{ key: 'frosch', icon: '🐸', name: 'Laubfrosch', threshold: 50 },
{ key: 'ente', icon: '🦆', name: 'Stockente', threshold: 58 },
{ key: 'biene', icon: '🐝', name: 'Wildbiene', threshold: 62 },
{ key: 'falter', icon: '🦋', name: 'Schmetterling', threshold: 68 },
{ key: 'reh', icon: '🦌', name: 'Reh', threshold: 74 },
{ key: 'eichhorn', icon: '🐿️', name: 'Eichhörnchen', threshold: 80 },
{ key: 'eule', icon: '🦉', name: 'Waldkauz', threshold: 86 },
{ key: 'fuchs', icon: '🦊', name: 'Fuchs', threshold: 91 },
{ key: 'storch', icon: '🕊️', name: 'Weißstorch', threshold: 96 },
];
const SPECIES_HYSTERESIS = 5;
const ACHIEVEMENTS = [
{ key: 'first_deich', icon: '🛡️', title: 'Sicher ist sicher', desc: 'Ersten Deich gebaut.' },
{ key: 'first_wald', icon: '🌳', title: 'Grüner Daumen', desc: 'Ersten Wald gepflanzt.' },
{ key: 'decade_safe', icon: '🏆', title: 'Zehn Jahre stabil', desc: '10 Jahre ohne zerstörte Siedlung.' },
{ key: 'balance_found', icon: '⚖️', title: 'Ausgleich gefunden', desc: 'Biodiv. > 60 UND Wirtschaft > 50 über 50 Jahre.' },
{ key: 'renaturierung', icon: '🦋', title: 'Renaturiert', desc: 'Begradigung wieder aufgelöst.' },
{ key: 'survivor', icon: '🎖️', title: 'Century-Hüter', desc: '100 Jahre am Fluss überlebt.' },
];
// Hilfs-PRNG — wir verlassen uns auf Math.random(), aber der Seed-Block
// dient als Platzhalter, falls spaeter deterministische Replays noetig werden.
function rollWeather() {
let r = Math.random(), cum = 0;
for (const w of WEATHER) { cum += w.prob; if (r < cum) return w; }
return WEATHER[2];
}
/* ============================================================
GAME STATE FABRIK
============================================================ */
function createGame(opts) {
opts = opts || {};
const level = opts.level || 1;
const cfg = opts.levelConfig || {};
const maxYears = cfg.timeLimit || MAX_YEARS_DEFAULT;
const startBudget = cfg.startBudget != null ? cfg.startBudget : 60;
const eventFreq = cfg.eventFrequency != null ? cfg.eventFrequency : 0.3;
const toolsAvailable = cfg.toolsAvailable || TOOLS.map(t => t.id);
const game = {
level: level,
levelConfig: cfg,
maxYears: maxYears,
eventFreq: eventFreq,
toolsAvailable: toolsAvailable,
// Fluss: 16 Spaltenpositionen + Engineering-Stufe
riverCol: [8,9,10,11,10,9,8,7,8,9,10,11,10,9,8,7],
riverColSmooth: [8,9,10,11,10,9,8,7,8,9,10,11,10,9,8,7],
riverEng: new Array(RIVER_SEGMENTS).fill(0),
riverRadius: [],
// Land-Grid (ROWS x COLS)
land: [],
tilePop: [],
tileYield: [],
tileFishYield: [], // Fischfang pro Siedlung, fuer Rendering
floodIntensity: [],
tileFloodYears: [],
// Wetter & Zeit
weather: WEATHER[2],
wxTimer: 0,
year: 0,
budget: startBudget,
gameOver: false,
// Verlaufsgeschichte fuer Graphen (pro Jahr ein Eintrag)
history: [],
// Didaktik-Tracking
achievements: {}, // key -> true
species: {}, // key -> true (vorhandene Tierarten)
stats: {
settlementsLost: 0,
fieldsLost: 0,
yearsWithoutLoss: 0,
balancedYears: 0,
straightenCount: 0,
denaturalizeCount: 0,
builtTools: {}, // tool -> count
},
_pendingEvents: [],
_nextEventId: 1,
};
// Fluss-Radien initialisieren (variieren)
for (let i = 0; i < RIVER_SEGMENTS; i++) {
game.riverRadius[i] = 0.8 + Math.sin(i * 0.9) * 0.3 + Math.random() * 0.2;
}
// Grid initialisieren
for (let r = 0; r < ROWS; r++) {
game.land[r] = []; game.tilePop[r] = []; game.tileYield[r] = [];
game.tileFishYield[r] = [];
game.floodIntensity[r] = []; game.tileFloodYears[r] = [];
for (let c = 0; c < COLS; c++) {
const dist = Math.abs(c - game.riverCol[r]);
if (dist <= 1) game.land[r][c] = 'meadow';
else if (dist > 4 && Math.random() < 0.5) game.land[r][c] = 'forest';
else game.land[r][c] = 'meadow';
game.tilePop[r][c] = 0;
game.tileYield[r][c] = 0;
game.tileFishYield[r][c] = 0;
game.floodIntensity[r][c] = 0;
game.tileFloodYears[r][c] = 0;
}
}
// Startgebaeude (eine Siedlung + zwei Felder)
game.land[4][13] = 'settlement';
game.tilePop[4][13] = 3;
game.land[5][13] = 'field';
game.land[5][14] = 'field';
// Startzustand hat biodiv ~100: alle Arten, deren Schwelle erfuellt ist,
// sind bereits da (ohne Toast-Flut).
const startBiodiv = estimateStartBiodiv(game);
SPECIES.forEach(sp => {
if (startBiodiv >= sp.threshold) game.species[sp.key] = true;
});
pushEvent(game, '🏞️', 'Willkommen am Fluss! Er wandert — plane voraus.', 'neutral');
return game;
}
function estimateStartBiodiv(game) {
let forests = 0, settlements = 0, fields = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = game.land[r][c];
if (t === 'forest') forests++;
else if (t === 'settlement') settlements++;
else if (t === 'field') fields++;
}
// Selbe Formel wie im Tick-Snapshot.
return clamp100(forests * 2 - (settlements + fields) * 2.5);
}
/* ============================================================
FLUSS-GEOMETRIE
============================================================ */
function distToRiver(game, r, c) {
let minD = 999;
for (let i = 0; i < ROWS; i++) {
const rc = game.riverColSmooth[i];
const d = Math.sqrt((r - i) * (r - i) + (c - rc) * (c - rc));
if (d < minD) minD = d;
if (i < ROWS - 1) {
const rc2 = game.riverColSmooth[i + 1];
const d2 = Math.sqrt((r - (i + 1)) * (r - (i + 1)) + (c - rc2) * (c - rc2));
if (d2 < minD) minD = d2;
}
}
return minD;
}
function isRiverTile(game, r, c) { return distToRiver(game, r, c) < 1.3; }
function hasLeveeNear(game, r, c) {
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && game.land[nr][nc] === 'levee') return true;
}
return false;
}
/* ============================================================
TICK — ein Jahr weiter
============================================================ */
function tick(game) {
if (game.gameOver) return [];
game.year++;
// 1. Einkommen / Nahrung / Bevoelkerungsdynamik
// Globale Einnahmen-Daempfung: Werkzeuge sollen sich nicht zu schnell
// refinanzieren, sonst hat die Lernende keinen echten Trade-off.
const INCOME_FACTOR = 0.5;
let income = 0, totalPop = 0, totalFood = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const dist = distToRiver(game, r, c);
const moisture = Math.max(0, 1 - Math.abs(dist - 2.5) / 3);
const fi = game.floodIntensity[r][c];
const tile = game.land[r][c];
if (tile === 'field') {
let dryPenalty = 0;
for (let i = Math.max(0, r-1); i <= Math.min(ROWS-1, r+1); i++) {
if (game.riverEng[i] > 0) dryPenalty = Math.max(dryPenalty, game.riverEng[i] * 0.25);
}
const effMoisture = Math.max(0, moisture - dryPenalty);
let workerBonus = 0;
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
const nr = r+dr, nc = c+dc;
if (nr>=0&&nr<ROWS&&nc>=0&&nc<COLS && game.land[nr][nc] === 'settlement')
workerBonus += game.tilePop[nr][nc] * 0.15;
}
workerBonus = Math.min(1, workerBonus);
const baseYield = (1 + effMoisture * 5) * (1 - fi * 0.9);
const y = Math.round(Math.max(0, Math.min(8, baseYield * (0.3 + workerBonus * 0.7))));
game.tileYield[r][c] = y;
income += y * 0.5;
totalFood += y;
}
if (tile === 'settlement') {
const cur = game.tilePop[r][c];
const hasFood = totalFood > totalPop * 0.4;
if (cur < 8 && hasFood && fi < 0.15 && Math.random() < 0.35) {
game.tilePop[r][c] = Math.min(8, cur + 1);
}
if (fi > 0.2 && cur > 0 && Math.random() < fi * 0.2) {
game.tilePop[r][c] = Math.max(0, cur - 1);
}
totalPop += game.tilePop[r][c];
income += game.tilePop[r][c] * 0.8;
// Fischfang: je naeher am Fluss, umso mehr Fische. Hochwasser
// truebt das Wasser und halbiert den Fang.
if (dist <= 3 && game.tilePop[r][c] > 0) {
const floodFactor = fi > 0.3 ? 0.5 : 1;
const fishCaught = Math.round((3 - dist) * game.tilePop[r][c] * 0.75 * floodFactor);
totalFood += fishCaught;
game.tileFishYield[r][c] = fishCaught;
} else if (game.tileFishYield && game.tileFishYield[r]) {
game.tileFishYield[r][c] = 0;
}
}
}
game.budget += Math.round(income * INCOME_FACTOR);
// 2. Wetter wechselt alle 3 Jahre
game.wxTimer++;
if (game.wxTimer >= 3) { game.wxTimer = 0; game.weather = rollWeather(); }
// 3. Fluss wandert — bis zu ZWEI Punkte pro Jahr, wenn nicht befestigt
// (vorher nur einer; jetzt deutlich lebendiger).
const moves = 2;
for (let m = 0; m < moves; m++) {
const idx = Math.floor(Math.random() * ROWS);
if (game.riverEng[idx] !== 0) continue;
if (Math.random() >= 0.95 + game.eventFreq * 0.05) continue; // fast immer
const dir = Math.random() < 0.5 ? -1 : 1;
const newCol = game.riverCol[idx] + dir;
const prevOk = idx === 0 || Math.abs(newCol - game.riverCol[idx - 1]) <= 3;
const nextOk = idx === ROWS - 1 || Math.abs(newCol - game.riverCol[idx + 1]) <= 3;
if (!(newCol >= 1 && newCol < COLS - 1 && prevOk && nextOk)) continue;
{
game.riverCol[idx] = newCol;
const prevTile = game.land[idx][newCol];
if (prevTile === 'settlement') {
game.land[idx][newCol] = 'ruin';
game.stats.settlementsLost++;
pushEvent(game, '🌊', 'Fluss verschlingt Siedlung — die Bewohner fliehen.', 'bad', 'flood-settlement');
} else if (prevTile === 'field') {
game.land[idx][newCol] = 'meadow';
game.stats.fieldsLost++;
pushEvent(game, '🌊', 'Fluss überflutet ein Feld.', 'bad', 'flood-field');
} else if (prevTile === 'levee') {
// Deich haelt — Fluss wird zurueckgedraengt
game.riverCol[idx] -= dir;
pushEvent(game, '🛡️', 'Ein Deich hält dem Wasser stand!', 'good', 'levee-hold');
}
}
}
// 4. Hochwasser / Feuchtigkeit smooth updaten
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const dist = distToRiver(game, r, c);
let target = 0;
if (game.weather.factor >= 1.0 && dist < 4) {
const base = Math.max(0, 1 - dist / 4);
const tile = game.land[r][c];
const leveeProt = tile === 'levee' ? 0.9 : (hasLeveeNear(game, r, c) ? 0.5 : 0);
target = base * game.weather.factor * 0.4 * (1 - leveeProt);
}
game.floodIntensity[r][c] += (target - game.floodIntensity[r][c]) * 0.2;
if (game.floodIntensity[r][c] > 0.35) {
game.tileFloodYears[r][c] = (game.tileFloodYears[r][c] || 0) + 1;
} else {
game.tileFloodYears[r][c] = 0;
}
const tile = game.land[r][c];
// Schadens-Schwellen: deutlich haerter als V1, damit die Lernende
// Hochwasser ernst nimmt.
if (tile === 'field' && game.tileFloodYears[r][c] >= 1) {
game.land[r][c] = 'meadow';
game.stats.fieldsLost++;
pushEvent(game, '🌾', 'Ein Feld ist ertrunken — zu lange unter Wasser.', 'bad', 'flood-field');
}
if (tile === 'settlement' && game.floodIntensity[r][c] > 0.4 && Math.random() < 0.12) {
game.land[r][c] = 'ruin';
game.stats.settlementsLost++;
pushEvent(game, '🏚️', 'Eine Siedlung ist durch das Hochwasser zerstört worden!', 'bad', 'flood-settlement');
}
if (tile === 'forest' && game.tileFloodYears[r][c] >= 2) {
game.land[r][c] = 'dead_tree';
pushEvent(game, '🌳', 'Ein Wald ist im Dauerhochwasser abgestorben.', 'bad', 'tree-flood-die');
}
if (tile === 'levee' && game.floodIntensity[r][c] > 0.35 && Math.random() < 0.15) {
game.land[r][c] = 'meadow';
pushEvent(game, '🧱', 'Ein Deich wurde vom Hochwasser weggespült!', 'bad', 'levee-break');
}
}
// 4b. Begradigung trocknet Umgebung aus → Baeume sterben
for (let i = 0; i < ROWS; i++) {
if (game.riverEng[i] > 0) {
const rc = game.riverCol[i];
for (let dc = -3; dc <= 3; dc++) {
const cc = rc + dc;
if (cc < 0 || cc >= COLS) continue;
if (Math.abs(dc) <= 1) continue;
if (game.land[i][cc] === 'forest' && Math.random() < 0.03 * game.riverEng[i]) {
game.land[i][cc] = 'dead_tree';
pushEvent(game, '🌳', 'Ein Baum vertrocknet neben der Begradigung.', 'bad', 'tree-straighten-die');
}
}
}
}
// 5. Flussbreite passt sich an (Hochwasser → breiter)
for (let i = 0; i < ROWS; i++) {
const target = game.weather.factor > 1.2 ? 1.8 : 1.2;
game.riverRadius[i] += (target - game.riverRadius[i]) * 0.1;
}
// 6. Ruinen und tote Baeume verfallen
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (game.land[r][c] === 'ruin' && Math.random() < 0.03) game.land[r][c] = 'meadow';
if (game.land[r][c] === 'dead_tree' && Math.random() < 0.03) game.land[r][c] = 'meadow';
}
// 7. Smooth-Fluss-Animation (auch im Headless: wir brauchen die Werte
// fuer Distanz-Berechnungen im naechsten Tick)
for (let i = 0; i < ROWS; i++) {
game.riverColSmooth[i] += (game.riverCol[i] - game.riverColSmooth[i]) * 0.3;
}
// 8. History eintragen
const counts = countTiles(game);
const avgEng = game.riverEng.reduce((s,v) => s+v, 0) / ROWS;
const floodTotal = sum2d(game.floodIntensity) / (ROWS * COLS);
const snapshot = {
year: game.year,
pop: clamp100(totalPop * 5 - counts.ruin * 8),
// Nahrungs-Skala: 100 wird erst erreicht, wenn ungefaehr die Haelfte
// des Talbodens unter Weizen steht (totalFood ~333). Vorher 1.5
// war zu locker — Anzeige war fast immer voll.
food: clamp100(totalFood * 0.3),
// Biodiv: Wald-Gewicht halbiert (4 -> 2), Bau-Strafe erhoeht (1.5 -> 2.5),
// damit der Startwert nahe 100 liegt und jeder Bau sofort spuerbar ist.
biodiv: clamp100(counts.forest * 2 - avgEng * 30 - (counts.settlement + counts.field) * 2.5),
flood: clamp100(15 + counts.levee * 6 + avgEng * 8 - floodTotal * 80),
economy: clamp100(totalPop * 3 + counts.field * 2 - counts.ruin * 5),
budget: game.budget,
};
game.history.push(snapshot);
// Didaktik-Tracking
if (game.stats.settlementsLost === 0) game.stats.yearsWithoutLoss++;
if (snapshot.biodiv > 60 && snapshot.economy > 50) game.stats.balancedYears++;
else game.stats.balancedYears = 0;
// Arten-Check: Schwelle nach oben → neue Art; Schwelle + Hysterese
// nach unten → Art verschwindet. Die Events landen im Stapel und als
// Toast, damit der Biodiv-Wandel spuerbar wird.
SPECIES.forEach(sp => {
const has = !!game.species[sp.key];
if (!has && snapshot.biodiv >= sp.threshold) {
game.species[sp.key] = true;
pushEvent(game, sp.icon, 'Neue Art im Gebiet: ' + sp.name, 'good', 'species-arrived');
} else if (has && snapshot.biodiv < sp.threshold - SPECIES_HYSTERESIS) {
delete game.species[sp.key];
pushEvent(game, sp.icon, sp.name + ' verschwindet aus dem Gebiet.', 'bad', 'species-left');
}
});
checkAchievements(game, snapshot);
if (game.year >= game.maxYears) {
game.gameOver = true;
unlockAchievement(game, 'survivor');
}
return drainEvents(game);
}
/* ============================================================
TOOLS — Interaktion mit dem Grid
============================================================ */
function applyTool(game, toolId, r, c) {
if (game.gameOver) return { ok: false, reason: 'game_over', events: drainEvents(game) };
if (!game.toolsAvailable.includes(toolId)) return { ok: false, reason: 'locked', events: [] };
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return { ok: false, reason: 'out_of_bounds', events: [] };
const tool = TOOLS.find(t => t.id === toolId);
if (!tool) return { ok: false, reason: 'unknown', events: [] };
const onRiver = isRiverTile(game, r, c);
const cur = game.land[r][c];
if (toolId === 'inspect') {
const msg = inspectTile(game, r, c);
pushEvent(game, msg.icon, msg.text, msg.type);
return { ok: true, events: drainEvents(game) };
}
if (toolId === 'begradigen') {
if (!onRiver) return fail(game, 'Nur auf dem Fluss anwendbar.');
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.riverEng[r] = Math.min(3, game.riverEng[r] + 1);
game.riverCol[r] = Math.floor(COLS / 2);
game.budget -= tool.cost;
game.stats.straightenCount++;
pushEvent(game, '📏', 'Fluss begradigt — Bauland, aber Ufer trocknet aus.', 'bad', 'begradigen');
track(game, toolId);
return { ok: true, events: drainEvents(game) };
}
if (toolId === 'deich') {
if (onRiver) return fail(game, 'Deich nicht direkt im Fluss möglich.');
const d = distToRiver(game, r, c);
if (d > 4) return fail(game, 'Zu weit vom Fluss — Deich muss am Ufer stehen.');
if (cur !== 'meadow' && cur !== 'forest') return fail(game, 'Nur auf Wiese oder Wald möglich.');
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.land[r][c] = 'levee';
game.budget -= tool.cost;
unlockAchievement(game, 'first_deich');
track(game, toolId);
pushEvent(game, '🛡️', 'Deich gebaut — Schutz für die Nachbarschaft.', 'good');
return { ok: true, events: drainEvents(game) };
}
if (toolId === 'feld') {
if (onRiver) return fail(game, 'Feld nicht im Fluss möglich.');
if (cur !== 'meadow') return fail(game, 'Nur auf freier Wiese möglich.');
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.land[r][c] = 'field';
game.budget -= tool.cost;
track(game, toolId);
const d = distToRiver(game, r, c);
if (d < 2.5) pushEvent(game, '🌾', 'Feld nah am Fluss — guter Ertrag, aber Hochwassergefahr.', 'good');
else if (d < 4) pushEvent(game, '🌾', 'Feld in guter Lage.', 'good');
else pushEvent(game, '🌾', 'Feld weit vom Fluss — wird trocken, geringer Ertrag.', 'bad');
return { ok: true, events: drainEvents(game) };
}
if (toolId === 'siedlung') {
if (onRiver) return fail(game, 'Siedlung nicht im Fluss möglich.');
if (cur !== 'meadow' && cur !== 'field') return fail(game, 'Nur auf Wiese oder Feld möglich.');
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.land[r][c] = 'settlement';
game.tilePop[r][c] = 0;
game.budget -= tool.cost;
track(game, toolId);
const d = distToRiver(game, r, c);
if (d < 2) pushEvent(game, '🏘️', 'Siedlung sehr nah am Fluss — Deich dringend empfohlen!', 'bad');
else if (d < 4) pushEvent(game, '🏘️', 'Siedlung gebaut — gute Lage.', 'good');
else pushEvent(game, '🏘️', 'Siedlung sicher, aber weit weg vom Wasser.', 'neutral');
return { ok: true, events: drainEvents(game) };
}
if (toolId === 'wald') {
if (onRiver) return fail(game, 'Wald nicht im Fluss möglich.');
if (cur !== 'meadow') return fail(game, 'Nur auf Wiese möglich.');
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.land[r][c] = 'forest';
game.budget -= tool.cost;
unlockAchievement(game, 'first_wald');
track(game, toolId);
pushEvent(game, '🌳', 'Wald gepflanzt — stärkt Biodiversität und Retention.', 'good');
return { ok: true, events: drainEvents(game) };
}
if (toolId === 'abriss') {
if (onRiver && game.riverEng[r] > 0) {
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.riverEng[r] = 0;
game.budget -= tool.cost;
game.stats.denaturalizeCount++;
unlockAchievement(game, 'renaturierung');
pushEvent(game, '🦋', 'Begradigung aufgelöst — der Fluss darf wieder wandern.', 'good', 'renaturierung');
return { ok: true, events: drainEvents(game) };
}
if (!['field','settlement','levee','ruin','dead_tree','forest'].includes(cur))
return fail(game, 'Hier gibt es nichts zum Abreißen.');
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
game.land[r][c] = 'meadow';
game.budget -= tool.cost;
track(game, toolId);
pushEvent(game, '🧨', 'Gebäude abgerissen.', 'neutral');
return { ok: true, events: drainEvents(game) };
}
return { ok: false, reason: 'no_match', events: [] };
}
function inspectTile(game, r, c) {
const dist = distToRiver(game, r, c);
const fi = game.floodIntensity[r]?.[c] || 0;
const fy = game.tileFloodYears[r]?.[c] || 0;
const onRiver = isRiverTile(game, r, c);
const cur = game.land[r][c];
if (onRiver) {
const eng = game.riverEng[r];
const engNames = ['natürlich','Holzverbau','Bruchstein','Beton'];
let t = 'Fluss — Befestigung: ' + (engNames[eng] || 'natürlich') + '. ';
t += eng > 0 ? 'Fluss ist hier fixiert. Umgebung trocknet aus.'
: 'Fließt frei — kann jedes Jahr wandern!';
return { icon: '🌊', text: t, type: eng > 0 ? 'bad' : 'neutral' };
}
if (cur === 'field') {
const yi = game.tileYield[r]?.[c] || 0;
let t = 'Feld — Ertrag: ' + yi + '/8. ';
if (fi > 0.35) t += 'Abgesoffen! Noch ' + Math.max(0, 2 - fy) + ' Jahr(e) bis es stirbt.';
else if (fi > 0.15) t += 'Zu feucht — Ertrag reduziert.';
else if (dist > 5) t += 'Sehr trocken — weit vom Fluss.';
else if (dist > 3.5) t += 'Etwas trocken.';
else t += 'Gute Feuchtigkeit!';
return { icon: '🌾', text: t, type: yi > 4 ? 'good' : yi > 1 ? 'neutral' : 'bad' };
}
if (cur === 'settlement') {
const pop = game.tilePop[r]?.[c] || 0;
let t = 'Siedlung — ' + pop + '/8 Bewohner. ';
if (fi > 0.3) t += 'Feucht! Bewohner werden krank und fliehen.';
else if (fi > 0.1) t += 'Etwas feucht — manche Bewohner sind unwohl.';
else t += 'Trocken und sicher.';
return { icon: '🏘️', text: t, type: fi > 0.2 ? 'bad' : 'good' };
}
if (cur === 'levee') {
let t = 'Deich. ';
t += fi > 0.3 ? 'Unter Druck! Kann bei Dauerflut brechen.' : 'Intakt. Schützt die Nachbarschaft.';
return { icon: '🛡️', text: t, type: fi > 0.3 ? 'bad' : 'good' };
}
if (cur === 'forest') {
let t = 'Wald — schützt vor Erosion, speichert Grundwasser. ';
let nearStraight = false;
for (let i = Math.max(0,r-1); i <= Math.min(ROWS-1,r+1); i++)
if (game.riverEng[i] > 0) nearStraight = true;
if (nearStraight) t += 'Neben Begradigung — Baum in Gefahr!';
else if (fi > 0.2) t += 'Feucht. Bei Dauerflut stirbt der Baum.';
return { icon: '🌳', text: t, type: nearStraight ? 'bad' : 'good' };
}
if (cur === 'ruin') return { icon: '🏚️', text: 'Ruine. Kann abgerissen werden. Verfällt auch von selbst.', type: 'bad' };
if (cur === 'dead_tree') return { icon: '🪵', text: 'Toter Baum — vertrocknet oder ertrunken.', type: 'bad' };
// meadow
let t = 'Wiese — freie Fläche. ';
if (dist < 2.5) t += 'Nah am Fluss: fruchtbar, aber hochwassergefährdet.';
else if (dist < 4) t += 'Gute Lage für Feld oder Siedlung.';
else t += 'Weit vom Fluss. Trocken.';
return { icon: '🌱', text: t, type: 'neutral' };
}
/* ============================================================
SUMMARY / SERIALIZE
============================================================ */
function getStatus(game) {
const counts = countTiles(game);
const avgEng = game.riverEng.reduce((s,v) => s+v, 0) / ROWS;
const floodTotal = sum2d(game.floodIntensity) / (ROWS * COLS);
let pop = 0, food = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (game.land[r][c] === 'settlement') pop += game.tilePop[r][c];
if (game.land[r][c] === 'field') food += game.tileYield[r][c];
}
return {
year: game.year,
maxYears: game.maxYears,
budget: game.budget,
weather: game.weather,
gameOver: game.gameOver,
params: {
population: pop,
populationMax: 200,
// Identische Formeln wie im Tick-Snapshot oben — Single Source of Truth
// sollte langfristig einer extrahierten Formel-Funktion folgen.
food: clamp100(food * 0.3),
biodiv: clamp100(counts.forest * 2 - avgEng * 30 - (counts.settlement + counts.field) * 2.5),
flood: clamp100(15 + counts.levee * 6 + avgEng * 8 - floodTotal * 80),
economy: clamp100(pop * 3 + counts.field * 2 - counts.ruin * 5),
},
counts: counts,
achievements: Object.keys(game.achievements),
};
}
function getSummary(game) {
const st = getStatus(game);
let score = Math.round((st.params.biodiv + st.params.flood + st.params.economy) / 3);
score = Math.max(0, Math.min(100, score - game.stats.settlementsLost * 5));
const stars = score >= 85 ? 5 : score >= 70 ? 4 : score >= 55 ? 3 : score >= 35 ? 2 : 1;
return {
simId: 'fluss',
level: game.level,
stars: stars,
score: score,
completed: game.year >= game.maxYears,
params: st.params,
stats: game.stats,
badges_earned: Object.keys(game.achievements),
durationYears: game.year,
};
}
function serialize(game) {
return JSON.stringify({
v: 1,
level: game.level,
levelConfig: game.levelConfig,
maxYears: game.maxYears,
eventFreq: game.eventFreq,
toolsAvailable: game.toolsAvailable,
riverCol: game.riverCol,
riverColSmooth: game.riverColSmooth,
riverEng: game.riverEng,
riverRadius: game.riverRadius,
land: game.land,
tilePop: game.tilePop,
tileYield: game.tileYield,
tileFishYield: game.tileFishYield,
floodIntensity: game.floodIntensity,
tileFloodYears: game.tileFloodYears,
weather: game.weather,
wxTimer: game.wxTimer,
year: game.year,
budget: game.budget,
gameOver: game.gameOver,
history: game.history,
achievements: game.achievements,
species: game.species,
stats: game.stats,
});
}
function deserialize(json) {
let s; try { s = JSON.parse(json); } catch { return null; }
if (!s || s.v !== 1) return null;
const game = createGame({ level: s.level, levelConfig: s.levelConfig });
Object.assign(game, {
maxYears: s.maxYears, eventFreq: s.eventFreq, toolsAvailable: s.toolsAvailable,
riverCol: s.riverCol, riverColSmooth: s.riverColSmooth, riverEng: s.riverEng,
riverRadius: s.riverRadius, land: s.land, tilePop: s.tilePop, tileYield: s.tileYield,
tileFishYield: s.tileFishYield || [],
floodIntensity: s.floodIntensity, tileFloodYears: s.tileFloodYears,
weather: s.weather, wxTimer: s.wxTimer, year: s.year, budget: s.budget,
gameOver: s.gameOver, history: s.history, achievements: s.achievements,
species: s.species || {},
stats: s.stats,
_pendingEvents: [],
});
return game;
}
/* ============================================================
HELPERS
============================================================ */
function countTiles(game) {
const out = { meadow:0, forest:0, field:0, settlement:0, levee:0, ruin:0, dead_tree:0 };
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = game.land[r][c];
if (out[t] !== undefined) out[t]++;
}
return out;
}
function sum2d(arr) {
let s = 0;
for (let r = 0; r < arr.length; r++) for (let c = 0; c < arr[r].length; c++) s += arr[r][c];
return s;
}
function clamp100(v) { return Math.max(0, Math.min(100, Math.round(v))); }
function pushEvent(game, icon, text, type, topic) {
game._pendingEvents.push({
id: game._nextEventId++, icon: icon, text: text,
type: type || 'neutral', year: game.year,
topic: topic || null,
});
}
function drainEvents(game) {
const out = game._pendingEvents;
game._pendingEvents = [];
return out;
}
function fail(game, msg) {
pushEvent(game, '⚠️', msg, 'bad');
return { ok: false, reason: 'constraint', events: drainEvents(game) };
}
function track(game, toolId) {
game.stats.builtTools[toolId] = (game.stats.builtTools[toolId] || 0) + 1;
}
function unlockAchievement(game, key) {
if (game.achievements[key]) return false;
const def = ACHIEVEMENTS.find(a => a.key === key);
if (!def) return false;
game.achievements[key] = true;
pushEvent(game, def.icon, 'Erfolg freigeschaltet: ' + def.title, 'good');
return true;
}
function checkAchievements(game, snap) {
if (game.stats.yearsWithoutLoss >= 10) unlockAchievement(game, 'decade_safe');
if (game.stats.balancedYears >= 50) unlockAchievement(game, 'balance_found');
}
/* ============================================================
EXPORT
============================================================ */
window.FlussEngine = {
TOOLS: TOOLS,
WEATHER: WEATHER,
ACHIEVEMENTS: ACHIEVEMENTS,
SPECIES: SPECIES,
COLS: COLS,
ROWS: ROWS,
MAX_YEARS_DEFAULT: MAX_YEARS_DEFAULT,
createGame: createGame,
tick: tick,
applyTool: applyTool,
distToRiver: distToRiver,
isRiverTile: isRiverTile,
inspectTile: inspectTile,
countTiles: countTiles,
getStatus: getStatus,
getSummary: getSummary,
serialize: serialize,
deserialize: deserialize,
};
})();