fecd8bb9c9
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (= "bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex /([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein Logik-Key) -> safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
599 lines
23 KiB
JavaScript
599 lines
23 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
})();
|