/** * Farmer / Landwirtschaft — Engine (Phase 1.6 — Monokultur, Krankheit, Unlock) * * Neu in 1.6: * - Monokultur-Penalty (Bodenmüdigkeit): pro nahem Feld gleicher Sorte * (< 1200 km, gem. crops.json) -15 % Brutto, max -60 %. * - Krankheits-Ausbruch: bei Cluster ≥ 3 (gleiche Sorte, transitiv im * 1200-km-Umkreis verbunden) jährlich 25 % Wahrscheinlichkeit für * Totalausfall des ganzen Clusters. Deterministisch via Mulberry32. * - Unlock-System: Sorten haben unlockYear; ab dem Jahr verfügbar. * Counter pro Sorte sinkt je nach Anzahl freigeschalteter Sorten: * 1–3 Sorten = 3, 4 = 2, 5 = 2, 6+ = 1. * * Daten (assets/data/): * crops.json — Sorten, Eignung, Diagnose, monoculture-Konfig * hubs.json — Handelshubs + Verkehrsmittel * koeppen-zones.json — Klimazonen-Lookup * world-countries.json — Länder + Land-Detection (Natural Earth 110m) */ window.FarmerEngine = (function () { 'use strict'; let CROPS_INDEX = null; let ZONES_DATA = null; let HUBS_DATA = null; let COUNTRIES_DATA = null; let MONOCULTURE = null; const DATA_VERSION = '23'; // Regionale Lieferung: wenn die nächste Konsumcity des aktiven Markt-Hubs // innerhalb dieser Distanz vom Feld liegt, geht die Lieferung direkt dorthin // (Hub-Umweg gespart — realistisch für innerregionale Erzeuger). const REGIONAL_DELIVERY_KM = 2000; // Importpreis-Abschlag: wenn Origin und Markt-Hub auf VERSCHIEDENEN Kontinenten, // bekommt die Ware -30 % Brutto-Preis (lokale Konkurrenz drückt den // Importpreis). Wasser-Pflanzungen (Fisch) zählen als neutral — kein Abschlag. // Tropische Hochmargen-Sorten verkraften das (Banane, Datteln, Kaffee), // Massengut wie Weizen wird über See unattraktiv — entspricht echten // Welthandels-Strömen. const CROSS_CONTINENT_PENALTY = 0.30; // Bodenwirkung: Kreis-Radius in km, schrumpft pro Jahr (Boden erholt sich). // Berührung zweier Kreise bedeutet: Distanz < (R_a + R_b) → Bodenmüdigkeit greift. // Nach RECOVERY_YEARS (4) ist die Wirkung 0 — Boden vollständig erholt. const RECOVERY_YEARS = 4; /** Lädt alle Daten. Idempotent. */ async function loadData() { if (CROPS_INDEX && ZONES_DATA && HUBS_DATA && COUNTRIES_DATA) return; const base = (window.FARMER_BASE || './') + 'assets/data/'; const v = '?v=' + DATA_VERSION; const [cRes, zRes, hRes, ctRes] = await Promise.all([ fetch(base + 'crops.json' + v), fetch(base + 'koeppen-zones.json' + v), fetch(base + 'hubs.json' + v), fetch(base + 'world-countries.json' + v), ]); const cJson = await cRes.json(); ZONES_DATA = await zRes.json(); HUBS_DATA = await hRes.json(); COUNTRIES_DATA = await ctRes.json(); CROPS_INDEX = { raw: cJson, crops: Object.fromEntries(cJson.crops.map(c => [c.id, c])), colors: cJson.suitabilityColors, }; // Fallback identisch mit crops.json — beide auf 1200 km synchron. MONOCULTURE = cJson.monoculture || { radiusKm: 1200, penaltyPerNeighbor: 0.15, maxPenalty: 0.60, outbreakThreshold: 3, outbreakChance: 0.25, }; } // ---- RNG ---- function mulberry32(seed) { let s = seed >>> 0; return function () { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function hashStr(str) { let h = 2166136261 >>> 0; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h; } // ---- Geometrie ---- function pointInRing(lat, lon, ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const xi = ring[i][0], yi = ring[i][1]; const xj = ring[j][0], yj = ring[j][1]; const intersect = ((yi > lat) !== (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi); if (intersect) inside = !inside; } return inside; } function getCountry(latlng) { if (!COUNTRIES_DATA) return null; const lat = latlng.lat; const lon = latlng.lng !== undefined ? latlng.lng : latlng.lon; for (const feature of COUNTRIES_DATA.features) { const gm = feature.geometry; if (gm.type === 'Polygon') { if (pointInRing(lat, lon, gm.coordinates[0])) return feature.properties; } else if (gm.type === 'MultiPolygon') { for (const poly of gm.coordinates) { if (pointInRing(lat, lon, poly[0])) return feature.properties; } } } return null; } function isLand(latlng) { return getCountry(latlng) !== null; } function getZoneAt(latlng) { if (!ZONES_DATA) return null; const lat = latlng.lat; const lon = latlng.lng !== undefined ? latlng.lng : latlng.lon; for (const feature of ZONES_DATA.features) { for (const poly of feature.geometry.coordinates) { if (pointInRing(lat, lon, poly[0])) return feature; } } return null; } function defaultZoneByLatitude(lat, lon) { const a = Math.abs(lat); if (a < 15) return 'tropisch_feucht'; if (a < 23) return 'tropisch_trocken'; if (a < 32) return 'subtropisch_feucht'; if (a < 38) { // Mittelmeerklima gibt es NUR an bestimmten West-/Küstenlagen — nicht im // kontinentalen Inneren (z. B. Westchina!). Ohne passendes Klima-Polygon // daher nur in den echten Mittelmeer-Längenfenstern „mittelmeer", sonst // trockenes Binnenland → „wueste". const L = (lon == null) ? 0 : lon; const med = (lat > 0 && ((L >= -10 && L <= 42) || (L >= -123 && L <= -116))) // Mittelmeerbecken · Kalifornien || (lat < 0 && ((L >= -74 && L <= -70) || (L >= 17 && L <= 25) || (L >= 115 && L <= 120))); // Zentralchile · Kapregion · SW-Australien return med ? 'mittelmeer' : 'wueste'; } if (a < 50) return 'gemaessigt_feucht'; if (a < 60) return 'kontinental'; if (a < 68) return 'boreal'; return 'polar'; } function haversineKm(lat1, lon1, lat2, lon2) { const R = 6371; const toRad = d => d * Math.PI / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } function nearestHub(latlng) { if (!HUBS_DATA) return null; const lat = latlng.lat; const lon = latlng.lng !== undefined ? latlng.lng : latlng.lon; let best = null; for (const hub of HUBS_DATA.hubs) { const d = haversineKm(lat, lon, hub.lat, hub.lon); if (!best || d < best.distanceKm) best = { hub, distanceKm: d }; } return best; } function chooseTransport(distKm, perishable, tons, originContinent, hubContinent) { const t = HUBS_DATA.transport; // Realistische Modus-Wahl: // - Origin und Hub im selben Kontinent → LKW (egal wie weit) // Frankreich/Ukraine/Rumänien → Hamburg per Schiff wäre absurd — // Land-Verkehr (Bahn/LKW) ist Standard innerhalb Eurasiens. // - Verschiedene Kontinente ODER Origin auf Wasser → Schiff // (Reefer-Container für Verderbliches, +50 % Kosten + 100 % CO2) // - Flieger nur für sehr kurzlebige Sorten (kommt mit highlyPerishable später). let key; if (!originContinent) { key = 'ship'; // Wasser-Pflanzung (Fisch) → Schiff } else if (originContinent === hubContinent) { key = 'truck'; // selber Kontinent → Land-Transport } else { key = 'ship'; // anderer Kontinent → Seetransport } const mode = t[key]; const reefer = !!perishable && key === 'ship'; const reeferCostFactor = reefer ? 1.5 : 1.0; const reeferCo2Factor = reefer ? 2.0 : 1.0; const cost = Math.round(distKm * mode.factorPerTonKm * (tons || 1) * reeferCostFactor); const co2g = distKm * (mode.co2PerTonKm || 0) * (tons || 1) * reeferCo2Factor; const co2kg = Math.round(co2g / 1000); return { modeKey: key, mode, reefer, cost, co2kg }; } // ---- Markt-Hub pro Jahr (zyklisch) ---- // Markt bleibt MARKET_BLOCK_YEARS Jahre in derselben Region — Schüler*in // hat Zeit, Anbau in der passenden Klimazone aufzubauen, bevor er wechselt. const MARKET_BLOCK_YEARS = 3; /** Markt-Auftrag des Jahres: pro 3 Jahre ein Hub, dann zum nächsten zyklisch. * Jahr 1–3 → Hub 0, Jahr 4–6 → Hub 1, ... */ function getMarketHubForYear(year) { if (!HUBS_DATA || !HUBS_DATA.hubs.length) return null; const block = Math.floor((year - 1) / MARKET_BLOCK_YEARS); const idx = ((block % HUBS_DATA.hubs.length) + HUBS_DATA.hubs.length) % HUBS_DATA.hubs.length; return HUBS_DATA.hubs[idx]; } /** Wie viele Jahre noch dieser Markt? (für UI-Anzeige) */ function yearsLeftInMarketBlock(year) { const into = ((year - 1) % MARKET_BLOCK_YEARS); return MARKET_BLOCK_YEARS - into; } // ---- Unlock-System ---- /** Liste der Sorten, die im gegebenen Jahr verfügbar sind. */ function getUnlockedCrops(year) { if (!CROPS_INDEX) return []; return CROPS_INDEX.raw.crops.filter(c => (c.unlockYear || 1) <= year); } /** Wie viele Versuche pro Sorte und Jahr — sinkt mit zunehmender Vielfalt. */ function counterPerCropForYear(year) { const n = getUnlockedCrops(year).length; if (n <= 3) return 3; if (n === 4) return 2; if (n === 5) return 2; return 1; } /** Sorten, die GENAU in diesem Jahr neu hinzukommen. */ function newlyUnlockedThisYear(year) { if (!CROPS_INDEX) return []; return CROPS_INDEX.raw.crops.filter(c => (c.unlockYear || 1) === year); } // ---- Init / Counter ---- function init(levelId, seed) { const counters = {}; if (CROPS_INDEX) { const perCrop = counterPerCropForYear(1); for (const crop of getUnlockedCrops(1)) counters[crop.id] = perCrop; } return { levelId: levelId || 'l1', seed: (seed >>> 0) || 1, year: 1, fields: [], counters, money: 0, reports: [], }; } function canPlant(game, cropId) { return (game.counters[cropId] || 0) > 0; } function plant(game, cropId, latlng) { if (!canPlant(game, cropId)) return null; const lon = latlng.lng !== undefined ? latlng.lng : latlng.lon; const country = getCountry(latlng); let zoneId, isWater; if (!country) { zoneId = 'wasser'; isWater = true; } else { const zone = getZoneAt(latlng); zoneId = zone ? zone.properties.id : defaultZoneByLatitude(latlng.lat, lon); isWater = false; } const field = { id: 'f-' + (game.fields.length + 1), cropId, latlng: { lat: latlng.lat, lon }, zoneId, countryName: country ? (country.name_de || country.name) : null, countryIso2: country ? country.iso2 : null, continent: country ? country.continent : null, isWater, plantedYear: game.year, status: 'planted', }; game.fields.push(field); game.counters[cropId] -= 1; return field; } // ---- Bodenwirkungs-Kreise (Monokultur + Krankheit) ---- /** Risiko-Kreis-Radius eines Feldes im aktuellen Jahr, in km. * Schrumpft linear über RECOVERY_YEARS (Boden erholt sich). */ function getFieldImpactRadiusKm(field, currentYear) { const baseHalfR = MONOCULTURE.radiusKm / 2; const age = Math.max(0, currentYear - field.plantedYear); const factor = Math.max(0, 1 - age / RECOVERY_YEARS); return baseHalfR * factor; } /** Zwei Felder beeinflussen sich, wenn ihre Risiko-Kreise sich berühren. */ function fieldsInfluence(a, b, currentYear) { const ra = getFieldImpactRadiusKm(a, currentYear); const rb = getFieldImpactRadiusKm(b, currentYear); if (ra <= 0 || rb <= 0) return false; const d = haversineKm(a.latlng.lat, a.latlng.lon, b.latlng.lat, b.latlng.lon); return d < (ra + rb); } /** Anzahl anderer Felder gleicher Sorte deren Kreise diesen Field-Kreis berühren. */ function countNeighbors(field, allFields, currentYear) { if (getFieldImpactRadiusKm(field, currentYear) <= 0) return 0; let n = 0; for (const other of allFields) { if (other.id === field.id) continue; if (other.cropId !== field.cropId) continue; if (fieldsInfluence(field, other, currentYear)) n++; } return n; } /** Connected components pro Sorte (BFS via Kreis-Berührung). */ function findClusters(allFields, currentYear) { const byCrop = {}; for (const f of allFields) { if (getFieldImpactRadiusKm(f, currentYear) <= 0) continue; // erholt → nicht im Cluster if (!byCrop[f.cropId]) byCrop[f.cropId] = []; byCrop[f.cropId].push(f); } const clusters = []; for (const cropId in byCrop) { const same = byCrop[cropId]; const visited = new Set(); for (const start of same) { if (visited.has(start.id)) continue; const queue = [start]; const comp = []; while (queue.length) { const f = queue.shift(); if (visited.has(f.id)) continue; visited.add(f.id); comp.push(f); for (const other of same) { if (visited.has(other.id)) continue; if (fieldsInfluence(f, other, currentYear)) queue.push(other); } } comp.sort((a, b) => a.id.localeCompare(b.id)); const key = cropId + '|' + comp.map(x => x.id).join(','); clusters.push({ cropId, fields: comp, key }); } } return clusters; } // ---- Ernte ---- function pickFromPool(pool, rng) { if (!pool || pool.length === 0) return '(kein Bericht)'; const idx = Math.floor(rng() * pool.length) % pool.length; return pool[idx]; } // Zone-charakteristische Stichwörter: wählt bei mehrdeutigen Pools den // KLIMAPASSENDEN Grund-Text statt zufällig einen klimafremden (Bug: Polar-Text // auf tropischem Feld). Greift zusätzlich zu den spezifischen // narratives[sui+'_'+zoneId]-Keys. const ZONE_KEYWORDS = { tropisch_feucht: ['tropisch', 'regenwald', 'schwül', 'permanente feucht', 'ganzjährig feucht'], tropisch_trocken: ['savanne', 'tropisch trocken', 'regenzeit', 'trockenzeit'], wueste: ['wüste', 'aride', 'dürre', 'extrem trocken', 'sand'], mittelmeer: ['mittelmeer', 'mediterran'], subtropisch_feucht: ['subtropisch', 'schwül'], gemaessigt_feucht: ['gemäßigt', 'mitteleurop', 'feuchtes klima'], kontinental: ['kontinental', 'steppe', 'binnenland'], boreal: ['boreal', 'taiga', 'kurze sommer', 'frost'], polar: ['polar', 'permafrost', 'arktis', 'ewiger schnee', 'tundra', 'schnee-tier'], }; function pickZoneAware(pool, zoneId, rng) { if (!pool || pool.length === 0) return '(kein Bericht)'; if (pool.length === 1) return pool[0]; const kws = ZONE_KEYWORDS[zoneId]; if (kws) { let best = null, bestScore = 0; for (const t of pool) { const s = (typeof t === 'string' ? t : (t && t.de) || '').toLowerCase(); let score = 0; for (const kw of kws) if (s.indexOf(kw) >= 0) score++; if (score > bestScore) { bestScore = score; best = t; } } if (best) return best; // klar passender Text gefunden } return pickFromPool(pool, rng); // sonst wie bisher } function harvestYear(game) { if (!CROPS_INDEX) return []; const reports = []; const rng = mulberry32(game.seed ^ (game.year * 2654435761)); // Cluster + Disease-Roll pro Cluster (Kreise berühren sich → verbunden) const clusters = findClusters(game.fields, game.year); const diseasedFieldIds = new Set(); const fieldClusterSize = {}; for (const cluster of clusters) { for (const f of cluster.fields) fieldClusterSize[f.id] = cluster.fields.length; if (cluster.fields.length >= MONOCULTURE.outbreakThreshold) { const clusterRng = mulberry32(game.seed ^ (game.year * 2654435761) ^ hashStr(cluster.key)); if (clusterRng() < MONOCULTURE.outbreakChance) { for (const f of cluster.fields) diseasedFieldIds.add(f.id); } } } // Markt-Hub dieses Jahres (zyklisch durch alle Hubs, deterministisch) const marketHub = getMarketHubForYear(game.year); for (const field of game.fields) { if (field.status !== 'planted') continue; const crop = CROPS_INDEX.crops[field.cropId]; if (!crop) continue; // Aquatic-Sorten (Fisch) haben eigene Wasser-Eignung. Sonst: Wasser = N2. const sui = field.isWater ? (crop.suitability.wasser || 'N2') : (crop.suitability[field.zoneId] || 'N2'); const baseGross = (crop.yieldByClass && crop.yieldByClass[sui]) || 0; const tons = (crop.unit && crop.unit.tons) || 1; // Bodenmüdigkeit aus Kreis-Berührungen const neighbors = countNeighbors(field, game.fields, game.year); const mudPenalty = Math.min(MONOCULTURE.maxPenalty, neighbors * MONOCULTURE.penaltyPerNeighbor); const grossAfterMud = Math.round(baseGross * (1 - mudPenalty)); // Importpreis-Abschlag: gilt nur wenn Origin auf einem anderen Kontinent // als der Markt-Hub liegt. Wasser-Pflanzung (kein continent) → neutral. const isLocalDelivery = !field.continent || !marketHub.continent || (field.continent === marketHub.continent); const localFactor = isLocalDelivery ? 1.0 : (1 - CROSS_CONTINENT_PENALTY); let gross = Math.round(grossAfterMud * localFactor); const importPenaltyAmount = grossAfterMud - gross; // Krankheit überschreibt alles const disease = diseasedFieldIds.has(field.id); if (disease) gross = 0; // Narrative — Lookup-Priorität: // 1. narratives[sui + '_' + zoneId] (z.B. 'N2_wueste') — sehr spezifisch // 2. narratives[sui] (z.B. 'N2') — generisch // Bug-Vermeidung: kein "Permafrost"-Text in Israel, kein "Wüste"-Text in Norwegen. let narrative; if (disease) { narrative = pickFromPool(crop.narratives && crop.narratives.disease, rng); } else if (field.isWater) { narrative = pickFromPool(crop.narratives && crop.narratives.wasser, rng); } else { const specificKey = sui + '_' + field.zoneId; const specificPool = crop.narratives && crop.narratives[specificKey]; const classPool = crop.narratives && crop.narratives[sui]; const pool = (specificPool && specificPool.length > 0) ? specificPool : classPool; narrative = pickZoneAware(pool, field.zoneId, rng); } // Trade — Markt-Hub des Jahres, ggf. regional direkt zur Konsumcity let hubInfo = null, transport = null, net = 0; if (gross > 0 && marketHub) { let dest = { lat: marketHub.lat, lon: marketHub.lon, name: null }; let isRegional = false; // Prüfe ob eine Konsumcity näher ist als der Hub if (marketHub.consumerCities && marketHub.consumerCities.length > 0) { let bestCity = null, bestDist = Infinity; for (const city of marketHub.consumerCities) { const d = haversineKm(field.latlng.lat, field.latlng.lon, city.lat, city.lon); if (d < bestDist) { bestDist = d; bestCity = city; } } if (bestCity && bestDist < REGIONAL_DELIVERY_KM) { // Regionale Lieferung: direkt zur Konsumcity, Hub wird übersprungen dest = { lat: bestCity.lat, lon: bestCity.lon, name: bestCity.name }; isRegional = true; } } const dist = haversineKm(field.latlng.lat, field.latlng.lon, dest.lat, dest.lon); hubInfo = { hub: marketHub, // immer der Markt-Hub (für Bericht-Bezug) destination: dest, // wo das Vehicle tatsächlich hinfährt distanceKm: dist, isRegional, }; transport = chooseTransport(dist, !!crop.perishable, tons, field.continent, marketHub.continent); net = gross - transport.cost; } const report = { fieldId: field.id, cropId: field.cropId, cropTitle: crop.title, cropIcon: crop.icon, cropType: crop.type || 'plant', cropUnit: crop.unit, suitability: sui, suitabilityColor: CROPS_INDEX.colors[sui], narrative, zoneId: field.zoneId, zoneName: getZoneName(field.zoneId), countryName: field.countryName, countryIso2: field.countryIso2, continent: field.continent, isWater: field.isWater, latlng: field.latlng, year: game.year, gross, net, tons, hub: hubInfo ? hubInfo.hub : null, destination: hubInfo ? hubInfo.destination : null, isRegional: hubInfo ? hubInfo.isRegional : false, distanceKm: hubInfo ? Math.round(hubInfo.distanceKm) : null, transport, // Monokultur-Info für UI baseGross, mudPenalty, neighborCount: neighbors, clusterSize: fieldClusterSize[field.id] || 1, disease, // Importpreis-Info für UI isLocalDelivery, importPenalty: isLocalDelivery ? 0 : CROSS_CONTINENT_PENALTY, importPenaltyAmount, marketContinent: marketHub.continent || null, }; field.status = 'harvested'; field.lastReport = report; reports.push(report); } game.reports.push(...reports); return reports; } function applyReportToBudget(game, report) { game.money += report.net; } function nextYear(game) { game.year += 1; if (CROPS_INDEX) { const perCrop = counterPerCropForYear(game.year); for (const crop of getUnlockedCrops(game.year)) { game.counters[crop.id] = perCrop; } } } // ---- Helpers ---- function getZoneName(zoneId) { if (zoneId === 'wasser') return { de: 'Im Wasser', easy: 'Im Wasser' }; if (!ZONES_DATA) return zoneId; const f = ZONES_DATA.features.find(x => x.properties.id === zoneId); return f ? f.properties.name : zoneId; } function getCrops() { return CROPS_INDEX ? CROPS_INDEX.raw.crops : []; } function getColors() { return CROPS_INDEX ? CROPS_INDEX.colors : {}; } function getZones() { return ZONES_DATA; } function getHubs() { return HUBS_DATA ? HUBS_DATA.hubs : []; } function getTransport() { return HUBS_DATA ? HUBS_DATA.transport : {}; } function getMonoculture() { return MONOCULTURE; } return { loadData, init, canPlant, plant, harvestYear, applyReportToBudget, nextYear, getCrops, getColors, getZones, getHubs, getTransport, getZoneAt, getZoneName, getCountry, isLand, haversineKm, nearestHub, chooseTransport, getUnlockedCrops, counterPerCropForYear, newlyUnlockedThisYear, getMonoculture, countNeighbors, findClusters, getFieldImpactRadiusKm, getMarketHubForYear, yearsLeftInMarketBlock, RECOVERY_YEARS, MARKET_BLOCK_YEARS, REGIONAL_DELIVERY_KM, CROSS_CONTINENT_PENALTY, }; })();