Stand 2026-04-13: PHP/MySQL Infrastruktur, Flussmanagement, Stadt-Prototyp

- PHP/MySQL Backend (XAMPP + Produktionsserver)
- Front-Controller, API-Endpunkte, Session-Management
- Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss)
- Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem)
- Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst
- persistence.ts: Dualer Speicher (localStorage + Server-API)
- 6 Unit-Test-Dateien fuer bestehende Simulationen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:43:42 +02:00
commit f22c5ebbfe
83 changed files with 22709 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import {
AUSTRIA, GERMANY_BAYERN, SWITZERLAND,
getLocalName, getPrimaryStages, requiresReading,
getReadingLevel, getSubjectName
} from '../../src/core/education-levels'
describe('Education Levels — Österreich', () => {
it('AT: 1. Klasse MS = Schulstufe 5', () => {
expect(getLocalName(AUSTRIA, 5)).toBe('1. Klasse')
})
it('AT: 4. Klasse MS = Schulstufe 8', () => {
expect(getLocalName(AUSTRIA, 8)).toBe('4. Klasse')
})
it('AT: Volksschule = Schulstufe 14', () => {
expect(getLocalName(AUSTRIA, 1)).toBe('1. Klasse Volksschule')
expect(getLocalName(AUSTRIA, 4)).toBe('4. Klasse Volksschule')
})
it('AT: Hauptfokus = Stufe 58 (4 Stufen)', () => {
const primary = getPrimaryStages(AUSTRIA)
expect(primary).toHaveLength(4)
expect(primary[0].level).toBe(5)
expect(primary[3].level).toBe(8)
})
it('AT: Fach heißt "Geografie und wirtschaftliche Bildung" ab Stufe 5', () => {
expect(getSubjectName(AUSTRIA, 5)).toBe('Geografie und wirtschaftliche Bildung')
})
it('AT: Fach heißt "Sachunterricht" in der Volksschule', () => {
expect(getSubjectName(AUSTRIA, 3)).toBe('Sachunterricht')
})
it('AT: Stundentafel korrekt (2-1-2-2)', () => {
const primary = getPrimaryStages(AUSTRIA)
expect(primary.map(s => s.hoursPerWeek)).toEqual([2, 1, 2, 2])
})
})
describe('Education Levels — Deutschland Bayern', () => {
it('DE-BY: Stufe 5 = "5. Jahrgangsstufe"', () => {
expect(getLocalName(GERMANY_BAYERN, 5)).toBe('5. Jahrgangsstufe')
})
it('DE-BY: Kein Geo in Stufe 6 und 9 (0 Stunden)', () => {
const stages = getPrimaryStages(GERMANY_BAYERN)
const stage6 = stages.find(s => s.level === 6)
const stage9 = stages.find(s => s.level === 9)
expect(stage6?.hoursPerWeek).toBe(0)
expect(stage9?.hoursPerWeek).toBe(0)
})
it('DE-BY: Fach heißt "Geographie"', () => {
expect(getSubjectName(GERMANY_BAYERN, 7)).toBe('Geographie')
})
})
describe('Education Levels — Schweiz', () => {
it('CH: Stufe 7 = "1. Oberstufe"', () => {
expect(getLocalName(SWITZERLAND, 7)).toBe('1. Oberstufe')
})
it('CH: Hauptfokus = Stufe 79 (Zyklus 3)', () => {
const primary = getPrimaryStages(SWITZERLAND)
expect(primary).toHaveLength(3)
expect(primary[0].level).toBe(7)
})
it('CH: Fach heißt "Räume, Zeiten, Gesellschaften" in Zyklus 3', () => {
expect(getSubjectName(SWITZERLAND, 8)).toBe('Räume, Zeiten, Gesellschaften')
})
it('CH: Fach heißt "Natur, Mensch, Gesellschaft" in Zyklus 2', () => {
expect(getSubjectName(SWITZERLAND, 5)).toBe('Natur, Mensch, Gesellschaft')
})
})
describe('Education Levels — Lesekompetenz', () => {
it('Stufe 1 (6-jährige): keine Lesekompetenz', () => {
expect(getReadingLevel(1)).toBe('none')
expect(requiresReading(1)).toBe(false)
})
it('Stufe 23 (7-9 Jahre): basale Lesekompetenz', () => {
expect(getReadingLevel(2)).toBe('basic')
expect(getReadingLevel(3)).toBe('basic')
})
it('Stufe 4+ (ab 9 Jahre): fließende Lesekompetenz', () => {
expect(getReadingLevel(4)).toBe('fluent')
expect(getReadingLevel(7)).toBe('fluent')
})
})
+178
View File
@@ -0,0 +1,178 @@
/**
* Klimawächter — Mechanik-Trace
*
* Spielt das Klimawächter-Spiel rein rechnerisch über alle 75 Jahre für
* verschiedene Strategien durch und gibt die Schlüssel-Variablen als
* ASCII-Tabellen aus. Dient zur Analyse der Spielmechanik:
* - Steigt CO₂ ohne Maßnahmen?
* - Wie reagiert die Temperatur auf verschiedene Strategien?
* - Wann wird die Stadt überflutet?
* - Sind die Maßnahmen sinnvoll dimensioniert?
*
* Wird mit `npx vitest run sim-05-trace` aufgerufen.
* Test schlägt nie fehl — er druckt nur.
*/
import { describe, it } from 'vitest'
import { KlimawaechterGame, MEASURES } from '../../src/sims/sim-05-treibhaus/game'
interface TraceRow {
tick: number
co2: number
temp: number
sea: number
flooded: number
budget: number
pop: number
measures: Record<string, number>
}
interface Strategy {
name: string
buy: (g: KlimawaechterGame, tick: number) => void
}
function runStrategy(strat: Strategy): TraceRow[] {
const game = new KlimawaechterGame()
// Tutorial überspringen, in 'playing' wechseln
;(game as any).state = 'playing'
;(game as any).tutorialStep = (game as any).tutorialSteps.length
const rows: TraceRow[] = []
// Startzustand auch erfassen
const snapshot0 = (): TraceRow => ({
tick: 0,
co2: game.getResource('co2'),
temp: game.getResource('temperature'),
sea: game.getResource('sealevel'),
flooded: game.getResource('flooded'),
budget: game.getResource('budget'),
pop: game.getResource('population'),
measures: countMeasures(game),
})
rows.push(snapshot0())
// 75 Ticks (= 75 Jahre)
for (let i = 1; i <= 75; i++) {
strat.buy(game, i)
// simulateTick ist protected → cast
;(game as any).simulateTick()
;(game as any).tick = i
rows.push({
tick: i,
co2: game.getResource('co2'),
temp: game.getResource('temperature'),
sea: game.getResource('sealevel'),
flooded: game.getResource('flooded'),
budget: game.getResource('budget'),
pop: game.getResource('population'),
measures: countMeasures(game),
})
}
return rows
}
function countMeasures(game: KlimawaechterGame): Record<string, number> {
const result: Record<string, number> = {}
for (const m of MEASURES) {
result[m.id] = game.getMeasureCount(m.id)
}
return result
}
function fmtRow(r: TraceRow): string {
const m = Object.entries(r.measures).filter(([_, c]) => c > 0).map(([k, c]) => `${k}:${c}`).join(',') || '-'
return [
String(2025 + r.tick).padStart(4),
r.co2.toFixed(0).padStart(5),
r.temp.toFixed(2).padStart(6),
r.sea.toFixed(1).padStart(6),
r.flooded.toFixed(0).padStart(4),
r.budget.toFixed(0).padStart(6),
String(r.pop).padStart(6),
m.padStart(20),
].join(' │ ')
}
function printTrace(name: string, rows: TraceRow[]) {
console.log('\n═══════════════════════════════════════════════════════════════════════════════════')
console.log(`STRATEGIE: ${name}`)
console.log('═══════════════════════════════════════════════════════════════════════════════════')
console.log(' Jahr │ CO₂ │ Temp │ Meer │ Flut │ Budget │ Bev │ Maßnahmen')
console.log('──────┼───────┼────────┼───────┼──────┼────────┼────────┼──────────────────────')
// Alle 5 Jahre + erstes + letztes
for (const r of rows) {
if (r.tick === 0 || r.tick === 75 || r.tick % 5 === 0) {
console.log(fmtRow(r))
}
}
// Endbewertung
const last = rows[rows.length - 1]
const won =
last.tick >= 75 &&
last.temp < 17 &&
last.budget > 0 &&
last.flooded < 30
console.log('──────┴───────┴────────┴───────┴──────┴────────┴────────┴──────────────────────')
console.log(` Status: ${won ? '✅ GEWONNEN' : '❌ VERLOREN'}`)
}
// === Strategien ===
const strategies: Strategy[] = [
{
name: 'Nichts tun (Baseline)',
buy: () => {},
},
{
name: 'Nur Wälder (alle 3 Jahre einer)',
buy: (g, t) => { if (t % 3 === 0) g.buyMeasure('forest') },
},
{
name: 'Nur Solar (sobald leistbar)',
buy: (g) => {
while (g.getResource('budget') >= 200) {
if (!g.buyMeasure('solar')) break
}
},
},
{
name: 'Nur Wind (sobald leistbar)',
buy: (g) => {
while (g.getResource('budget') >= 400) {
if (!g.buyMeasure('wind')) break
}
},
},
{
name: 'Nur Deiche (gegen Überflutung)',
buy: (g) => {
while (g.getResource('budget') >= 300) {
if (!g.buyMeasure('dike')) break
}
},
},
{
name: 'Wälder + Solar gemischt',
buy: (g, t) => {
if (t % 4 === 0 && g.getResource('budget') >= 200) g.buyMeasure('solar')
else if (g.getResource('budget') >= 50) g.buyMeasure('forest')
},
},
{
name: 'Optimal: Solar + Wind + 1 Deich',
buy: (g, t) => {
if (t === 5 && g.getResource('budget') >= 300) g.buyMeasure('dike')
if (g.getResource('budget') >= 400) g.buyMeasure('wind')
else if (g.getResource('budget') >= 200) g.buyMeasure('solar')
},
},
]
describe('Klimawächter — Mechanik-Trace über 75 Jahre', () => {
for (const strat of strategies) {
it(`Trace: ${strat.name}`, () => {
const rows = runStrategy(strat)
printTrace(strat.name, rows)
})
}
})
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest'
import { computeTemperature, computeEffects, TreibhausSimulation } from '../../src/sims/sim-05-treibhaus/logic'
describe('Treibhauseffekt — Physik-Modell', () => {
it('berechnet korrekte Temperatur ohne Treibhauseffekt (Albedo 0.3)', () => {
// Ohne CO₂ (theoretisch 0 ppm → ln(0) ist undefiniert, daher sehr niedrig)
// Bei 1 ppm sollte Temp nahe -18°C + 33°C - großer negativer Wert sein
// Besser: Prüfen, dass 280 ppm → ~15°C ergibt
const temp = computeTemperature(280, 0.3)
expect(temp).toBeGreaterThan(13.5)
expect(temp).toBeLessThan(16) // ~14-15°C, vereinfachtes Modell
})
it('ergibt ~16°C bei aktuellem CO₂-Niveau (425 ppm)', () => {
const temp = computeTemperature(425, 0.3)
expect(temp).toBeGreaterThan(15.5)
expect(temp).toBeLessThan(17)
})
it('Verdoppelung von CO₂ erhöht Temperatur um ~3°C', () => {
const temp280 = computeTemperature(280, 0.3)
const temp560 = computeTemperature(560, 0.3)
const delta = temp560 - temp280
expect(delta).toBeCloseTo(3.0, 0.5)
})
it('höhere Albedo → kältere Temperatur', () => {
const tempLow = computeTemperature(400, 0.2)
const tempHigh = computeTemperature(400, 0.5)
expect(tempLow).toBeGreaterThan(tempHigh)
})
it('steigende CO₂ → steigende Temperatur (monoton)', () => {
const temps = [200, 300, 400, 600, 800, 1000].map(co2 => computeTemperature(co2, 0.3))
for (let i = 1; i < temps.length; i++) {
expect(temps[i]).toBeGreaterThan(temps[i - 1])
}
})
})
describe('Treibhauseffekt — Folgen-Berechnung', () => {
it('vorindustrielle Temperatur → kein Meeresspiegelanstieg', () => {
const effects = computeEffects(15)
expect(effects.seaLevelRise).toBe(0)
})
it('+2°C → messbarer Meeresspiegelanstieg', () => {
const effects = computeEffects(17)
expect(effects.seaLevelRise).toBeGreaterThan(20)
})
it('arktisches Eis sinkt mit steigender Temperatur', () => {
const ice15 = computeEffects(15).arcticIce
const ice18 = computeEffects(18).arcticIce
expect(ice15).toBeGreaterThan(ice18)
})
it('Extremereignisse steigen mit Temperatur', () => {
const events15 = computeEffects(15).extremeEvents
const events20 = computeEffects(20).extremeEvents
expect(events20).toBeGreaterThan(events15)
})
})
describe('TreibhausSimulation — Klasse', () => {
it('hat korrekte Metadaten', () => {
const sim = new TreibhausSimulation()
expect(sim.meta.id).toBe('sim-05')
expect(sim.meta.primaryLevel).toBe(5)
expect(sim.meta.educationLevels).toContain(5)
expect(sim.meta.tier).toBe(1)
expect(sim.meta.dpiMinuten).toBe(20)
})
it('startet mit Standardwerten', () => {
const sim = new TreibhausSimulation()
expect(sim.getVariable('co2')).toBe(425)
expect(sim.getVariable('albedo')).toBe(0.3)
})
it('berechnet Ergebnisse nach Variable-Änderung', () => {
const sim = new TreibhausSimulation()
sim.setVariable('co2', 560)
const results = sim.compute()
expect(results.temperature).toBeGreaterThan(17)
})
it('loggt Variablenänderungen im Assessment', () => {
const sim = new TreibhausSimulation()
sim.setVariable('co2', 300)
sim.setVariable('co2', 600)
const assessment = sim.getAssessmentData()
expect(assessment.processLog.length).toBeGreaterThanOrEqual(2)
expect(assessment.processLog.some(l => l.action === 'set-variable')).toBe(true)
})
it('Predict-Observe-Explain Workflow funktioniert', () => {
const sim = new TreibhausSimulation()
// Predict
sim.nextPhase() // intro → predict
sim.setPrediction('temp_at_800ppm', 'Ich glaube über 20°C')
// Simulate
sim.nextPhase() // predict → simulate
sim.setVariable('co2', 800)
// Observe
sim.nextPhase() // simulate → observe
const results = sim.compute()
expect(results.temperature).toBeDefined()
// Reflect
sim.nextPhase() // observe → reflect
sim.addReflection('Die Temperatur ist höher als ich dachte')
const assessment = sim.getAssessmentData()
expect(assessment.predictions['temp_at_800ppm']).toBeDefined()
expect(assessment.reflections.length).toBe(1)
expect(assessment.completedPhases).toContain('reflect')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { computeMagnitude, computeCityImpact, ErdbebenSimulation } from '../../src/sims/sim-07-erdbeben/logic'
describe('Erdbeben — Magnitude-Berechnung', () => {
it('geringe Spannung → niedrige Magnitude', () => {
const mag = computeMagnitude(1, 0.5)
expect(mag).toBeLessThan(4)
})
it('hohe Spannung → hohe Magnitude', () => {
const mag = computeMagnitude(5000, 1.5)
expect(mag).toBeGreaterThan(5)
})
it('Magnitude nie über 9.5', () => {
const mag = computeMagnitude(10000, 10)
expect(mag).toBeLessThanOrEqual(9.5)
})
it('härteres Gestein → stärkeres Beben bei gleicher Spannung', () => {
const soft = computeMagnitude(10, 0.5)
const hard = computeMagnitude(10, 1.5)
expect(hard).toBeGreaterThan(soft)
})
})
describe('Erdbeben — Stadtauswirkungen', () => {
it('hohe Bauqualität → weniger Schaden', () => {
const rich = computeCityImpact(7, 30, 0.9)
const poor = computeCityImpact(7, 30, 0.1)
expect(rich.damage).toBeLessThan(poor.damage)
})
it('gleiche Magnitude, gleiche Distanz, verschiedene Bauqualität → verschiedene Opferzahlen', () => {
const rich = computeCityImpact(7, 30, 0.9)
const poor = computeCityImpact(7, 30, 0.1)
expect(poor.casualties).toBeGreaterThan(rich.casualties)
})
it('größere Entfernung → weniger Schaden', () => {
const near = computeCityImpact(7, 10, 0.5)
const far = computeCityImpact(7, 300, 0.5)
expect(far.damage).toBeLessThan(near.damage)
})
it('schwaches Beben → wenig Schaden auch bei schlechter Bauqualität', () => {
const impact = computeCityImpact(3, 30, 0.1)
expect(impact.damage).toBeLessThan(20)
})
it('starkes Beben + schlechte Bauqualität → hoher Gebäudekollaps', () => {
const impact = computeCityImpact(8, 20, 0.1)
expect(impact.buildingCollapse).toBeGreaterThan(30)
})
})
describe('ErdbebenSimulation — Klasse', () => {
it('hat korrekte Metadaten', () => {
const sim = new ErdbebenSimulation()
expect(sim.meta.id).toBe('sim-07')
expect(sim.meta.primaryLevel).toBe(5)
expect(sim.meta.tier).toBe(1)
})
it('Spannung baut sich über Ticks auf', () => {
const sim = new ErdbebenSimulation()
sim.tick()
sim.tick()
sim.tick()
expect(sim.getStress()).toBeGreaterThan(0)
})
it('irgendwann kommt ein Erdbeben', () => {
const sim = new ErdbebenSimulation()
sim.setVariable('plateSpeed', 15)
let quake = null
for (let i = 0; i < 100 && !quake; i++) {
quake = sim.tick()
}
expect(quake).not.toBeNull()
expect(quake!.magnitude).toBeGreaterThan(0)
})
it('Vergleich der Stadtauswirkungen funktioniert', () => {
const sim = new ErdbebenSimulation()
const { cityA, cityB } = sim.compareImpact(7)
expect(cityA.type).toBe('rich')
expect(cityB.type).toBe('poor')
expect(cityB.damage).toBeGreaterThan(cityA.damage)
})
it('schnellere Platten → häufigere Beben', () => {
const slow = new ErdbebenSimulation()
slow.setVariable('plateSpeed', 2)
const fast = new ErdbebenSimulation()
fast.setVariable('plateSpeed', 15)
let slowQuakes = 0, fastQuakes = 0
for (let i = 0; i < 200; i++) {
if (slow.tick()) slowQuakes++
if (fast.tick()) fastQuakes++
}
expect(fastQuakes).toBeGreaterThan(slowQuakes)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { computeMixResult, ENERGY_SOURCES, EnergiemixSimulation } from '../../src/sims/sim-09-energiemix/logic'
describe('Energiemix — Mix-Berechnung', () => {
it('100% Kohle → hoher CO₂-Ausstoß', () => {
const result = computeMixResult({ coal: 100 })
expect(result.totalCO2).toBeGreaterThan(700)
expect(result.renewableShare).toBe(0)
})
it('100% Wind → niedriger CO₂, niedrige Zuverlässigkeit', () => {
const result = computeMixResult({ wind: 100 })
expect(result.totalCO2).toBeLessThan(20)
expect(result.totalReliability).toBeLessThan(0.5)
})
it('100% Solar → billigste Option', () => {
const solarCost = computeMixResult({ solar: 100 }).totalCost
const coalCost = computeMixResult({ coal: 100 }).totalCost
expect(solarCost).toBeLessThan(coalCost)
})
it('gemischter Mix → Werte dazwischen', () => {
const result = computeMixResult({ coal: 30, wind: 30, solar: 20, hydro: 20 })
expect(result.totalCO2).toBeGreaterThan(50)
expect(result.totalCO2).toBeLessThan(400)
expect(result.renewableShare).toBe(70)
})
it('Erneuerbare-Anteil wird korrekt berechnet', () => {
const result = computeMixResult({ wind: 50, solar: 50 })
expect(result.renewableShare).toBe(100)
})
it('Score bevorzugt saubere + zuverlässige + günstige Mixes', () => {
const dirty = computeMixResult({ coal: 100 })
const balanced = computeMixResult({ nuclear: 30, wind: 30, hydro: 20, solar: 20 })
expect(balanced.score).toBeGreaterThan(dirty.score)
})
it('leerer Mix gibt Nullwerte', () => {
const result = computeMixResult({})
expect(result.totalCO2).toBe(0)
})
})
describe('Energiemix — Quellenddaten', () => {
it('hat 6 Energiequellen', () => {
expect(ENERGY_SOURCES).toHaveLength(6)
})
it('Wind und Solar sind erneuerbar', () => {
const wind = ENERGY_SOURCES.find(s => s.id === 'wind')!
const solar = ENERGY_SOURCES.find(s => s.id === 'solar')!
expect(wind.renewable).toBe(true)
expect(solar.renewable).toBe(true)
})
it('Kohle hat den höchsten CO₂-Wert', () => {
const coal = ENERGY_SOURCES.find(s => s.id === 'coal')!
const maxCO2 = Math.max(...ENERGY_SOURCES.map(s => s.co2PerGWh))
expect(coal.co2PerGWh).toBe(maxCO2)
})
})
describe('EnergiemixSimulation — Klasse', () => {
it('hat korrekte Metadaten', () => {
const sim = new EnergiemixSimulation()
expect(sim.meta.id).toBe('sim-09')
expect(sim.meta.primaryLevel).toBe(6)
})
it('Startwerte summieren sich auf ~100%', () => {
const sim = new EnergiemixSimulation()
const total = ENERGY_SOURCES.reduce((s, src) => s + sim.getVariable(src.id), 0)
expect(total).toBe(100)
})
it('compute() gibt sinnvolle Werte', () => {
const sim = new EnergiemixSimulation()
const result = sim.compute()
expect(result.totalCO2).toBeGreaterThan(0)
expect(result.totalCost).toBeGreaterThan(0)
})
it('Variablenänderung loggt im Assessment', () => {
const sim = new EnergiemixSimulation()
sim.setVariable('coal', 0)
sim.setVariable('solar', 50)
const assessment = sim.getAssessmentData()
expect(assessment.processLog.length).toBeGreaterThanOrEqual(2)
})
})
+216
View File
@@ -0,0 +1,216 @@
/**
* Tests fuer SIM-12: Flussmanagement
*/
import { describe, it, expect } from 'vitest'
import {
simulateRound, computeScore, computeCost, computeTotalCost,
checkWinLose, checkFinalWin,
LEVELS, CONTROL_META, STATE_META,
type Controls, type State, type Conditions,
} from '../../src/sims/sim-12-fluss/logic'
import { FlussGame } from '../../src/sims/sim-12-fluss/game'
// Hilfsfunktion: Default-Zustand
function defaultState(): State {
return {
floodLocal: 50, floodDownstream: 40, erosion: 30,
soilFertility: 60, biodiversity: 60, groundwater: 55,
usableLand: 50, economy: 50,
}
}
function emptyControls(): Controls {
return {
straightening: 0, levees: 0, dredging: 0,
floodplainRelease: 0, renaturation: 0, irrigation: 0,
}
}
function defaultConditions(): Conditions {
return { rainfall: 60, extremeWeather: 30, slope: 40, populationPressure: 50, budget: 150 }
}
describe('Fluss-Simulation Logik', () => {
describe('simulateRound', () => {
it('gibt einen neuen State zurueck (Immutabilitaet)', () => {
const state = defaultState()
const result = simulateRound(state, emptyControls(), defaultConditions())
expect(result).not.toBe(state) // Neues Objekt
})
it('ohne Massnahmen aendert sich wenig', () => {
const state = defaultState()
const result = simulateRound(state, emptyControls(), defaultConditions())
// Werte sollten nah am Ausgangszustand sein
for (const key of Object.keys(state) as (keyof State)[]) {
expect(Math.abs(result[key] - state[key])).toBeLessThan(10)
}
})
it('Deiche reduzieren lokales Hochwasser', () => {
const state = defaultState()
const controls = { ...emptyControls(), levees: 80 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.floodLocal).toBeLessThan(state.floodLocal)
})
it('Begradigung erhoeht Hochwasser flussabwaerts', () => {
const state = defaultState()
const controls = { ...emptyControls(), straightening: 80 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.floodDownstream).toBeGreaterThan(state.floodDownstream)
})
it('Renaturierung erhoeht Biodiversitaet', () => {
const state = defaultState()
const controls = { ...emptyControls(), renaturation: 70 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.biodiversity).toBeGreaterThan(state.biodiversity)
})
it('Bewaesserung erhoeht Bodenfruchtbarkeit', () => {
const state = defaultState()
const controls = { ...emptyControls(), irrigation: 60 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.soilFertility).toBeGreaterThan(state.soilFertility)
})
it('Auen freigeben reduziert Hochwasser lokal und flussabwaerts', () => {
const state = defaultState()
const controls = { ...emptyControls(), floodplainRelease: 70 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.floodLocal).toBeLessThan(state.floodLocal)
expect(result.floodDownstream).toBeLessThan(state.floodDownstream)
})
it('alle Werte bleiben zwischen 0 und 100', () => {
// Extreme Kontrollen
const controls: Controls = {
straightening: 100, levees: 100, dredging: 100,
floodplainRelease: 100, renaturation: 100, irrigation: 100,
}
const result = simulateRound(defaultState(), controls, defaultConditions())
for (const key of Object.keys(result) as (keyof State)[]) {
expect(result[key]).toBeGreaterThanOrEqual(0)
expect(result[key]).toBeLessThanOrEqual(100)
}
})
it('Hochwasser-Event erhoeht Floodwerte', () => {
const state = defaultState()
const event = { round: 1, type: 'flood_event' as const, intensity: 80 }
const result = simulateRound(state, emptyControls(), defaultConditions(), event)
expect(result.floodLocal).toBeGreaterThan(state.floodLocal)
})
it('Duerre-Event reduziert Grundwasser', () => {
const state = defaultState()
const event = { round: 1, type: 'drought' as const, intensity: 70 }
const result = simulateRound(state, emptyControls(), defaultConditions(), event)
expect(result.groundwater).toBeLessThan(state.groundwater)
})
})
describe('computeScore', () => {
it('berechnet alle Scores zwischen 0 und 100', () => {
const score = computeScore(defaultState(), { safety: 0.25, ecology: 0.25, agriculture: 0.25, economy: 0.25 })
expect(score.safety).toBeGreaterThanOrEqual(0)
expect(score.safety).toBeLessThanOrEqual(100)
expect(score.ecology).toBeGreaterThanOrEqual(0)
expect(score.total).toBeGreaterThanOrEqual(0)
expect(score.total).toBeLessThanOrEqual(100)
})
it('Gewichtung beeinflusst Gesamtscore', () => {
const state: State = { ...defaultState(), economy: 90 }
const scoreEco = computeScore(state, { safety: 0, ecology: 0, agriculture: 0, economy: 1 })
const scoreSafe = computeScore(state, { safety: 1, ecology: 0, agriculture: 0, economy: 0 })
expect(scoreEco.total).not.toEqual(scoreSafe.total)
})
})
describe('computeCost', () => {
it('0 Intensitaet kostet 0', () => {
expect(computeCost('levees', 0)).toBe(0)
})
it('hoehere Intensitaet kostet mehr (ueberproportional)', () => {
const cost50 = computeCost('levees', 50)
const cost100 = computeCost('levees', 100)
expect(cost100).toBeGreaterThan(cost50 * 1.5) // ueberproportional
})
})
describe('Levels', () => {
it('hat 5 Levels', () => {
expect(LEVELS).toHaveLength(5)
})
it('jedes Level hat gueltige Struktur', () => {
for (const l of LEVELS) {
expect(l.id).toBeTruthy()
expect(l.title).toBeTruthy()
expect(l.rounds).toBeGreaterThan(0)
expect(l.allowedControls.length).toBeGreaterThan(0)
expect(l.conditions.budget).toBeGreaterThan(0)
}
})
it('keine dominante Strategie: extreme Massnahme hat Nachteile', () => {
// Teste ob maximale Begradigung nicht alles verbessert
const state = LEVELS[3].initialState // Level 4 (Gleichgewicht)
const conditions = LEVELS[3].conditions
const extremeControls = { ...emptyControls(), straightening: 100 }
const result = simulateRound({ ...state }, extremeControls, conditions)
// Begradigung muss MINDESTENS einen Nachteil haben
const hasDownside =
result.biodiversity < state.biodiversity ||
result.floodDownstream > state.floodDownstream ||
result.groundwater < state.groundwater ||
result.erosion > state.erosion
expect(hasDownside).toBe(true)
})
})
describe('CONTROL_META / STATE_META', () => {
it('hat Metadaten fuer alle 6 Massnahmen', () => {
expect(Object.keys(CONTROL_META)).toHaveLength(6)
})
it('hat Metadaten fuer alle 8 Zustandsparameter', () => {
expect(Object.keys(STATE_META)).toHaveLength(8)
})
})
})
describe('FlussGame', () => {
it('startet mit level-select Phase', () => {
const game = new FlussGame()
expect(game.phase).toBe('level-select')
})
it('selectLevel setzt korrekt auf', () => {
const game = new FlussGame()
game.selectLevel('L1')
expect(game.level.id).toBe('L1')
expect(game.phase).toBe('intro')
expect(game.round).toBe(0)
expect(game.budgetRemaining).toBe(120) // L1 budget
})
it('serialize / deserialize roundtrip', () => {
const game = new FlussGame()
game.selectLevel('L2')
game.startPlaying()
game.setControl('levees', 40)
game.executeRound()
const json = game.serialize()
const game2 = new FlussGame()
expect(game2.deserialize(json)).toBe(true)
expect(game2.level.id).toBe('L2')
expect(game2.round).toBe(1)
})
})