Files
geograsim/App/sims/farmer/engine.js
T
Adminator b32097b617 Farmer-Fix: Begruendungstext passt jetzt zur echten Klimazone (nicht mehr zufaellig)
Bug: Kamel in tropischem Brasilien zeigte "Polargebiet/Permafrost", weil der
generische N2-Pool tropisch+polar mischt und pickFromPool zufaellig zog. Ebenso
"Kamele in Nordeuropa" auf einem Feld in Suedamerika (fester Regionsname).
- engine.js: pickZoneAware() waehlt per Klima-Stichwoertern (ZONE_KEYWORDS) den
  zur Zone passenden Grund-Text statt zufaellig; Fallback = alt.
- crops.json: Kamel-N1-Text regions-neutral ("viel zu feucht" statt "Nordeuropa").
S1-Erfolgstexte mit Regions-Beispielen (Reis/Kaffee/Datteln) bewusst belassen
(Vergleich auf passendem Feld, didaktisch ok).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:19:32 +02:00

590 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:
* 13 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) {
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) return 'mittelmeer';
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 13 → Hub 0, Jahr 46 → 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);
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,
};
})();