diff --git a/README.md b/README.md index f6dcc70..cb9b2e9 100644 --- a/README.md +++ b/README.md @@ -4,73 +4,77 @@ [![CodeQL](https://github.com/arrase/Evolution/actions/workflows/codeql.yml/badge.svg)](https://github.com/arrase/Evolution/actions/workflows/codeql.yml) [![Security Audit](https://github.com/arrase/Evolution/actions/workflows/security.yml/badge.svg)](https://github.com/arrase/Evolution/actions/workflows/security.yml) -**Evolution** is an interactive bio-cultural and evolutionary population simulator built with **Godot 4.7**, featuring a handcrafted cozy pixel-art aesthetic on an isometric hexagonal ecosystem. +**Evolution** is an interactive bio-cultural and evolutionary population simulator built with **Godot 4.7**, featuring a high-DPI 2.5D illustrated isometric hexagonal ecosystem (`169` hexes, `map_radius = 7`) powered by GPU terrain/ocean shaders (`ocean_waves.gdshader`, `hex_terrain.gdshader`, `prop_sway_glow.gdshader`) and procedural `#23150c` ink-outlined artwork. -Rather than commanding individual units directly, you act as the **Selective Pressure** and **Evolutionary Architect**—configuring **1v1 Evolutionary Strategy Duels (`Strategy A ⚔️ Strategy B`)** before pressing Play, forging custom strategies and traits, and shaping climates, terrain altitude, sea levels, and habitat carrying capacity ($K$)—while local subpopulations (*demes*) autonomously evaluate their surroundings, adapt, migrate, cooperate, innovate, or diverge into new species. +Rather than commanding individual units directly, you act as the **Selective Pressure** and **Evolutionary Architect**—configuring **1v1 Evolutionary Strategy Duels (`Strategy A ⚔️ Strategy B`)** before pressing Play, forging custom strategies and traits, and shaping climates, terrain altitude, sea levels, and habitat carrying capacity ($K$)—while single-deme-per-hex subpopulations (*demes*) autonomously evaluate their surroundings, cultivate adjacent rural hinterlands (`crops` and `pasture`), manage urban salubrity (`salubrity`), erect hex-edge perimeter fortifications, wage wars, unite into multi-hex walled conurbations (`walled_union_id`), innovate, or diverge into new species. -![The Rift Valley — Isometric Hexagonal Ecosystem & HUD](gh-pages/assets/img/screenshot_rift_savanna.png) +![The Rift Valley — 169-Hex 2.5D Illustrated Ecosystem & HUD](gh-pages/assets/img/screenshot_rift_savanna.png) --- ## Key Features ### 1. 1v1 Evolutionary Strategy Duels (`Strategy A ⚔️ Strategy B`) -Before launching a simulation at Generation 0, you choose which two **Evolutionary Strategies** (`EvoTypes.StrategyDef`) will face off across the valley—or forge your own in the **Evolutionary Laboratory** (persisted automatically to `~/.config/Evolution/user_catalog.json`). Each strategy bundles compatible biological adaptations and cultural traditions, imbuing its founding lineage and daughter villages with a distinct emblem, color identity, and combined 6-axis biophysical footprint: +Before launching a simulation at Generation 0, you choose which two **Evolutionary Strategies** (`EvoTypes.StrategyDef`) will face off across the `169`-hex valley—or forge your own in the **Evolutionary Laboratory** (persisted automatically to `~/.config/Evolution/user_catalog.json`). Each strategy bundles compatible biological adaptations and cultural traditions, imbuing its founding lineage and daughter settlements with a distinct emblem, color identity, and combined 6-axis biophysical footprint: | Strategy ID | Emblem & Name (EN / ES) | Color | Bundled Traits (`bio-*` + `cul-*`) | Evolutionary Philosophy | | :--- | :--- | :---: | :--- | :--- | -| `strat-cooperation` | 🤝 **Cooperative Alliance**
*Alianza Cooperativa* | `#10B981` | `bio-k-strategy`, `bio-social-instinct` + `cul-communal-conservation` | Fosters social cohesion, communal habitat conservation, and resource buffering. | +| `strat-cooperation` | 🤝 **Cooperative Alliance**
*Alianza Cooperativa* | `#10B981` | `bio-k-strategy`, `bio-social-instinct` + `cul-communal-conservation` | Fosters social cohesion, communal habitat conservation, sanitation, and resource buffering. | | `strat-expansion` | 🧭 **Expansionist Pioneers**
*Pioneros Expansionistas* | `#3B82F6` | `bio-r-strategy`, `bio-thermal-tolerance`, `bio-social-instinct` + `cul-fire-processing` | Fast reproduction, high mobility, and thermal tolerance to colonize new territories. | | `strat-trade` | ⚖️ **Merchant League**
*Liga Mercantil* | `#F59E0B` | `bio-k-strategy`, `bio-encephalization` + `cul-interdeme-barter` | Drives inter-village barter, commercial symbiosis, and horizontal cultural diffusion. | -| `strat-war` | ⚔️ **Conquering Horde**
*Horda Conquistadora* | `#EF4444` | `bio-r-strategy`, `bio-social-instinct` + `cul-warrior-expansion`, `cul-extraction-tools` | Aggressive martial expansion and intensive extraction to dominate rival territories. | +| `strat-war` | ⚔️ **Conquering Horde**
*Horda Conquistadora* | `#EF4444` | `bio-r-strategy`, `bio-social-instinct` + `cul-warrior-expansion`, `cul-extraction-tools` | Aggressive martial expansion, hinterland pillaging, and intensive extraction to dominate rival territories. | | `strat-innovation` | 🔬 **Innovator Technocrats**
*Tecnócratas Innovadores* | `#A855F7` | `bio-encephalization`, `bio-digestive-spec` + `cul-fire-processing`, `cul-extraction-tools` | High encephalization, thermal food processing, and specialized extraction toolkits. | -### 2. Authentic Evolutionary, Geopolitical & Socio-Cultural Dynamics -The simulation separates **behavioral intent** (probabilistic decision-making per deme) from **biophysical consequence** (deterministic laws of energy conservation, carrying capacity $K$, metabolic cost, and demography): -- **$r/K$ Selection Trade-offs:** Prolific dispersal vs. high-longevity carrying-capacity buffering and fortification cost discounts. -- **The Baldwin Effect (Genetic Assimilation):** Sustained behavioral plasticity under environmental stress consolidates into heritable biological adaptations derived from the dominant behavioral vector. -- **Dual Inheritance & Complementary Trade (`exchange`):** Populations with cognitive or social capacity (*Encephalization* or *Instinctive Sociability*) unlock cumulative cultural traditions, toolmaking, and horizontal cultural transmission across trade routes—scaling caloric trade surplus up to **$1.35\times$** when trading across distinct biomes ($+12\%$) and distinct cultural repertoires ($+8\%$). -- **Multi-Tier Defense System (`defense_tier: 0..3`):** Settlements invest energy reserves and local hex resources (with a $15\%$ discount for conservation, storage, or $K$-strategy traits, plus **allied co-financing** from wealthy pact partners) to construct perimeter fortifications with per-turn upkeep: - - **Tier 1 — Palisade & Watchtower (`+45%` defense, `-25%` raid loot & casualties, `1.5` upkeep/turn)** - - **Tier 2 — Stone Wall & Fort (`+95%` defense, `-60%` raid loot, `-50%` raid casualties, `3.0` upkeep/turn)** - - **Tier 3 — Castle / Fortified Citadel (`+155%` defense, complete immunity to minor plunder raids, heavy siege attrition to attackers, `5.0` upkeep/turn)** -- **Emergent Geopolitics (Pacts, Vendettas, Tribute, Defensive Coalitions, Sieges & Wars):** Neighboring and cohabiting demes forge formal **Alliance Pacts** (providing $+15\%$ mutual caloric bonuses, distress aid transfers, and up to $+1.10$ shared military defense), form **Defensive Coalitions** (lowered cooperation threshold `0.22` when facing an adjacent martial horde threat), pay **Tribute / Appeasement** ($18\%$ reserves to deter raids with zero population casualties and establish a temporary truce), harbor multi-turn **Vendettas / Grudges (`active_pacts < 0`)** after betrayals (`-4` turns) or raids/wars (`-2`/`-3` turns) that block immediate pact reformation, repel **Fortified Sieges** (spending reserves to repair walls), and wage **Wars of Conquest** (`-w` warband settlements, flanking bonuses, and battle sparks) or face *Hawk-Dove* disputes and the *Tragedy of the Commons* ($K < 60\%$ of $K_{\max}$). -- **City Unification / Synoecism (`merge`, Tier 5 Unified Metropolis):** Long-standing cohabiting (`pact >= 3`) or adjacent (`pact >= 4`, or `>= 3` under threat or fortification) allied settlements unite into a single **Tier 5 Unified Metropolis (`Deme`)**—combining population, reserves (up to `280`), and biological/cultural traits, elevating shared castle defenses, expanding host hex urban carrying capacity $K$ ($+35$ up to $1.35 \times K_{\max}$), flying twin strategy pennants, and **directly reducing the number of LLM inference requests per turn**. -- **Allopatric Speciation:** Geographic isolation (beyond 3 passable hex hops from founder or living lineage kin) across mountain ridges or flooded glacial land bridges triggers phylogenetic divergence into new lineages while preserving their founding `strategy_id`. - -### 3. Dual-Model System 1 Cognitive Architecture, Historical Memory & Environmental Blending (Ollama) -- **System 1 (High-Frequency Turn Scoring — `d3:4b` & `tev1:4b`):** Evaluates each deme via `/v1/systemone` every generation, providing its intrinsic trait prior (`strategy_traits_prior`), a **sliding 6-generation historical memory digest** (`war`, `raided`, `betrayal`, `pact`, `fortify`, `merge`, `tribute`, `siege`), active vendetta count, nearby conquering horde detection (1–2 hex radius), and **rich neighbor relational tags** (`kin`, `ally`, `rival`, `vendetta` + posture + relative strength + defense tier). Rival factions are deterministically routed across `d3:4b` and `tev1:4b` via non-blocking background threads (`_cache_epoch` invalidation, coarsened 10-part signatures for higher cache hit rates, and `8`-signature batch prefetching), blending neural outputs (**$75\%$**) with deterministic environmental & geopolitical pressures (**$25\%$**). -- **Deterministic Milestone Synthesis (`EvoMilestonePhase`):** Synthesizes emergent traits, cultural innovations, allopatric speciation, macro-outcomes (*Evolutionary Hegemony*, *Dynamic Equilibrium*), and geopolitical natural history chronicle entries within strict thermodynamic bounds. - -### 4. Standardized 6-Axis Biophysical Vector & Thermodynamic Conservation -Every biological trait, cultural tradition, and behavioral decision maps to a bounded 6-axis vector: -1. `metabolic_cost` ($[-1, +1]$) -2. `extraction_efficiency` ($[-1, +1]$) -3. `ecological_impact` ($[-1, +1]$) -4. `spatial_mobility` ($[-1, +1]$) -5. `social_cohesion` ($[-1, +1]$) -6. `transmission_threshold` ($[0, +1]$) - -No trait or decision can violate thermodynamic conservation: high caloric extraction or neural complexity requires commensurate metabolic or ecological costs. +### 2. Single-Deme-Per-Hex Spatial Ecology, 4-Tier Urban Hierarchy & Rural Hinterlands +Every scenario operates on a **`169`-hex (`map_radius = 7`, `HEX_SIZE = 240.0`)** pointy-top axial grid under a strict **1-Deme-Per-Hex Exclusivity invariant (`cell.deme_ids.size() <= 1`)**. Each settlement commands the full footprint of its hexagon (`HexMath.compute_village_sector_offset == Vector2.ZERO`), and all cooperation, trade, Hawk-Dove competition, raids, wars, and synoecism occur across adjacent hex edges: + +- **4-Tier Urban Hierarchy (`I..IV` — `Deme.get_village_tier()`):** + - **`I · Camp / Campamento` (`tier == 1`):** Nomadic frontier camp (`population < 120`, `defense_tier == 0`) with thatched huts (`hut_thatch`) and central campfires (`campfire`); relies entirely on local hex foraging (`urban_forage_mult = 1.0`). + - **`II · Village / Aldea` (`tier == 2`):** Settled pastoral/farming village (`population >= 120` or `defense_tier == 1`) with clay houses (`house_clay`) and granaries (`granary`); claims up to **`1` adjacent rural hinterland hex**. + - **`III · Town / Villa` (`tier == 3`):** Urbanized town (`population >= 240`, or `>= 170` with fortifications/unification) with stone houses (`house_stone`), temple totems (`temple_totem`), and windmills (`windmill`); urban density reduces local wild foraging (`urban_forage_mult = 0.55`) while expanding rural capacity to **`2` adjacent hinterland hexes**. + - **`IV · Metropolis / Metrópolis` (`tier == 4`):** Monumental walled city (`population >= 380`, or `>= 280` with `defense_tier >= 2`, `merged_count >= 1`, or `walled_union_id != ""`) crowned by a citadel keep (`citadel_keep`), cathedral (`cathedral`), and watchtowers (`watchtower`); local wild foraging drops to `0.30` while the city cultivates up to **`4` adjacent rural hinterland hexes**. +- **Rural Hinterlands (`crops` & `pasture`) & Pillaging (`has_ruins`):** Tier II–IV settlements automatically claim and cultivate up to `1`, `2`, or `4` adjacent uninhabited terrestrial hexes (`altitude <= 0.85`), converting fertile biomes into golden wheat fields with windmills (`land_use = "crops"`, `crop_field`, `windmill`) and arid/boreal biomes into fenced livestock pastures with barns (`land_use = "pasture"`, `pasture_pen`, `granary`) connected by rural farm tracks with animated `covered_wagon`s. Each owned hinterland yields `min(R_rural, 0.35 * pop) * 0.70` calories per turn. During cross-border raids or sieges, attackers can **pillage rural hinterlands**, plundering up to `18.0` biomass and scorching the parcel into persistent stone ruins (`has_ruins = true`, `ruins_stone`) to starve fortified cities. +- **Urban Salubrity (`salubrity`) & Epidemics (`"epidemic"`):** High urban crowding (`pop / K`), Tier III–IV urban density (`+0.16 * max(0, tier - 2)`), and multi-hex union clustering (`+0.14 * union_partners`) degrade a settlement's `salubrity` (`[0.05, 1.0]`), whereas `cul-communal-conservation` (`+0.20`), `bio-k-strategy` (`+0.14`), and ambient humidity (`+0.08 * humidity`) bolster sanitation. Degraded salubrity dampens positive demographic growth (`* clamp(0.30 + 0.70 * salubrity, 0.25, 1.0)`) and, when **`salubrity < 0.35` with `population >= 130`**, triggers an **Urban Epidemic Outbreak (`"epidemic"`)** that inflicts `22%` population mortality (minimum `10`), rebounds salubrity by `+0.28`, and logs a causal entry in the Chronicle. + +![Settlement Tiers I–III, Perimeter Walls & Cultivated Rural Hinterlands](gh-pages/assets/img/screenshot_settlement_tiers_hinterlands.png) + +### 3. Authentic Evolutionary, Geopolitical & Dual Synoecism Dynamics +The simulation separates **agency** (each settlement chooses a concrete targeted action from a feasible menu evaluated by Ollama `/v1/systemone`) from **biophysical consequence** (deterministic laws of energy conservation, carrying capacity $K$, metabolic cost, and demography): + +- **$r/K$ Selection Trade-offs & The Baldwin Effect:** Prolific density-dependent dispersal (`bio-r-strategy`) vs. high-longevity carrying-capacity buffering, sanitation, and fortification cost discounts (`bio-k-strategy`). Sustained non-foraging behavioral plasticity (`baldwin_counters >= 8`) consolidates into heritable biological adaptations. +- **Dual Inheritance & Complementary Trade (`trade`):** Populations with cognitive or social capacity (*Encephalization* or *Instinctive Sociability*) unlock cumulative cultural traditions (`innovate`), toolmaking, and horizontal cultural transmission across trade routes—earning unilateral trade surplus (`55%`) or full mutual trade surplus (`100%`, up to $+35\%$ across complementary biomes and cultural repertoires) while building bilateral trust (`+0.12` unilateral, `+0.20` mutual). +- **Hex-Edge Perimeter Fortifications (`fortify`, `defense_tier: 0..3`):** Settlements invest energy reserves (with a $15\%$ discount for conservation or $K$-strategy traits) to construct 2.5D hex-edge perimeter walls (`palisade_wall` at Lv.1 `+35%` defense / `30%` loot shield, `stone_wall` at Lv.2 `+75%` defense / `60%` loot shield, and bastion-reinforced stone curtain walls at Lv.3 `+130%` defense / `80%` loot shield). +- **Emergent Geopolitics (`offer_pact`, `accept_pact`, `aid`, `demand_tribute`, `pay_tribute`, `raid`, `betray`, `conquer`):** Neighboring demes forge **Alliance Pacts** ($+12\%$ mutual caloric bonus and shared military defense), send **Surplus Aid** (`aid`, `+0.25` trust and automatic pact sealing), issue **Tribute Ultimatums** (`demand_tribute` / `pay_tribute` for a 2-turn truce, or face an immediate punitive enforcement raid upon refusal), harbor multi-turn **Feuds (`feud_turns > 0`)** after raids (`2` turns), wars (`3` turns), or surprise **Betrayals (`betray`, `4` turns and `-0.50` global reputation)**, repel **Fortified Sieges**, and wage **Wars of Conquest (`conquer`)** (`-w` warbands). When an invading warband conquers an occupied hex, the defeated defender **retreats to an adjacent empty passable hex** or is **absorbed in place** (`1/3` population assimilated) if surrounded, strictly preserving `cell.deme_ids.size() <= 1`. +- **Dual Synoecism Architecture (`unite` / `accept_union` — Asymmetric Absorption vs. Continuous Multi-Hex Walled Unions):** + 1. **Asymmetric Category Absorption (`tier_a != tier_b`):** When two long-standing allied towns (`pact_turns >= 3`, `tier >= 2`) of *different* urban categories (`tier_a != tier_b`) unite, the lower-tier settlement merges into the higher-tier host city—combining population, reserves (up to `280`), and traits, expanding host $K$ (`+35` up to $1.35 \times K_{\max}$), and **automatically converting the vacated hex into a cultivated rural hinterland (`"crops"` or `"pasture"`) owned by the host city**. + 2. **Continuous Multi-Hex Walled Unions (`tier_a == tier_b >= 2`, `walled_union_id`):** When two allied neighboring settlements of the *same* urban category (`tier >= 2`) unite, they form a **Continuous Multi-Hex Walled Union (`union--`, up to 3 hexes)**—remaining on their respective hexes, upgrading perimeter walls (`defense_tier >= 1`), **culling the shared internal wall edge** between their hexes, connecting their plazas via a paved central union boulevard (`"merge"` flow), and pooling `+55%` partner population and `+25%` fortification bonus during combat. +- **Allopatric Speciation:** Geographic isolation (beyond 3 passable hex hops from founder or living lineage kin) across mountain ridges or flooded glacial land bridges triggers phylogenetic divergence into new species lineages while preserving their founding `strategy_id`. + +![Continuous Multi-Hex Walled Union (Tier IV Metropolises) & Rural Hinterlands](gh-pages/assets/img/screenshot_late_simulation.png) + +### 4. Dual-Model System 1 Payoff-Forecast Cognitive Engine & 2.5D Illustrated Shader World +- **System 1 Payoff-Forecast Agency (`d3:4b` & `tev1:4b` via Ollama, `num_ctx = 16384`):** Every generation, each settlement builds a feasible menu of `6–14` concrete, target-bound actions (`EvoActionMenu.build`) across 16 action types (`forage`, `intensify`, `fortify`, `innovate`, `settle `, `trade `, `aid `, `offer_pact `, `accept_pact `, `demand_tribute `, `pay_tribute `, `raid `, `betray `, `conquer `, `unite `, `accept_union `). Each menu option's criterion is its quantified expected outcome (`EvoOutcomeForecast.describe`: food harvest, loot, win odds, casualties, trust/reputation delta, or territory gain), paired with a natural-language situation report (`EvoSituationReport.build`: strategy doctrine, caloric balance, causal outcome of last turn's action, 8-event personal memory, and bilateral neighbor trust/feuds/proposals). Ollama `/v1/systemone` scores the options directly, and `EvoActionPolicy.sample` samples the settlement's concrete action stochastically (`T = 0.65`) with zero heuristic blending. +- **2.5D High-DPI Illustrated & Shader Presentation:** Renders a GPU-accelerated ocean backdrop (`ocean_waves.gdshader`) with multi-ring coastal foam, 10-tier 2.5D cliff extrusions, `256x224` illustrated biome hex tiles (`hex_terrain.gdshader`), `128x128`–`160x160` isometric buildings and swaying flora (`prop_sway_glow.gdshader`) with `#23150c` ink outlines and mipmapped linear filtering (`TEXTURE_FILTER_LINEAR_WITH_MIPMAPS`), and a locked-yaw (`yaw_deg = 0.0`) 2.5D camera (`EvoCameraController`). + +### 5. Standardized 6-Axis Biophysical Vector & Thermodynamic Conservation +Every biological trait, cultural tradition, and behavioral decision maps to a bounded 6-axis vector (`metabolic_cost`, `extraction_efficiency`, `ecological_impact`, `spatial_mobility`, `social_cohesion` in $[-1, +1]$, and `transmission_threshold` in $[0, +1]$). No trait or decision can violate thermodynamic conservation: positive advantages beyond `BASE_BUDGET = 0.60` strictly require commensurate metabolic or ecological costs ($C_{\text{curr}} \ge 0.50 \cdot (A^+ - 0.60)$). --- -## Scientific Scenarios & 1v1 World Presets +## Scientific Scenarios & 1v1 World Presets (`169` Hexes, `map_radius = 7`) -Every scenario initializes a balanced **1v1 Evolutionary Strategy Duel** (`Strategy A` vs. `Strategy B`, defaulting to `strat-cooperation` 🤝 vs. `strat-war` ⚔️) that can be customized at Generation 0 from the top HUD matchup bar: +Every scenario initializes a balanced **1v1 Evolutionary Strategy Duel** (`Strategy A` vs. `Strategy B`, defaulting to `strat-cooperation` 🤝 vs. `strat-war` ⚔️) across a `169`-hex (`map_radius = 7`) world that can be customized at Generation 0 from the top HUD matchup bar: ### The Rift Valley (`preset-rift-savanna`) -A tectonic mountain ridge divides a stable western tropical rainforest from a drying eastern savanna-steppe mosaic. **Western Forest Hominids** (`deme-rift-west` at `(-2, 1)`, Strategy A, `pop = 140`) face off across the ridge against **Eastern Savanna Hominids** (`deme-rift-east` at `(2, -1)`, Strategy B, `pop = 140`). Seasonal aridification in the east drives cooperation, storage, migration, Baldwin assimilation, and cultural innovation while western forest populations leverage abundant biomass. - -![Late Simulation in The Rift Valley — Consolidated Settlements & Road Networks](gh-pages/assets/img/screenshot_late_simulation.png) +A tectonic mountain ridge (`q = 0`, `altitude = 0.88`, pierced by three savanna mountain passes at `(0, -3)`, `(0, 0)`, and `(0, 3)`) divides a lush western tropical rainforest (`84` hexes) from a drying eastern savanna-steppe mosaic (`84` hexes). **Western Forest Hominids** (`deme-rift-west` at `(-2, 1)`, Strategy A, `pop = 140`) face off against **Eastern Savanna Hominids** (`deme-rift-east` at `(2, -1)`, Strategy B, `pop = 140`). ### Darwin's Archipelago (`preset-darwin-archipelago`) -Volcanic islands linked by shallow coastal land bridges (`0,-1`, `0,1`, `2,0`) that periodically emerge and flood with 8-generation glacial sea-level cycles. Two rival island founders—**Western Island Founders** (`deme-darwin-founder` at `(-1, 0)`, Strategy A, `pop = 95`) and **Eastern Island Rivals** (`deme-darwin-rival` at `(1, 0)`, Strategy B, `pop = 95`)—race across temporary land bridges during low tides and undergo allopatric speciation when rising seas isolate colonies across tundra, forest, and arid steppe islands. +Volcanic islands linked by shallow intertidal land bridges (`0,-1`, `0,1`, `2,0`, `-2,1`, `0,-4`, `0,4`, `4,-1`) that periodically emerge and flood with 8-generation glacial sea-level cycles (`0.12` $\leftrightarrow$ `0.25`). Two rival island founders—**Western Island Founders** (`deme-darwin-founder` at `(-1, 0)`, Strategy A, `pop = 95`) and **Eastern Island Rivals** (`deme-darwin-rival` at `(1, 0)`, Strategy B, `pop = 95`)—race across temporary land bridges during low tides and undergo allopatric speciation when rising seas isolate colonies across northern tundra, southern rainforest, and eastern steppe islands. -![Darwin's Archipelago — Insular Dispersal & Land Bridges](gh-pages/assets/img/screenshot_darwin_archipelago.png) +![Darwin's Archipelago — 169-Hex Insular Dispersal & Tidal Land Bridges](gh-pages/assets/img/screenshot_darwin_archipelago.png) ### The Finite Island (`preset-finite-island`) -A fertile but geographically bounded island surrounded by deep ocean where two competing clans—**Northern Quarry Clan** (`deme-island-1` at `(-1, 0)`, Strategy A, `pop = 95`) and **Southern Timber Clan** (`deme-island-2` at `(1, 0)`, Strategy B, `pop = 95`)—share a fragile savanna core ($r_{\text{regen}} = 0.08$). Without communal conservation norms, intensive overexploitation and Hawk-Dove conflict degrade local carrying capacity $K$ below $60\%$ of $K_{\max}$ and trigger the *Tragedy of the Commons*. +A fertile but geographically bounded island surrounded by deep ocean where two competing clans—**Northern Quarry Clan** (`deme-island-1` at `(-1, 0)`, Strategy A, `pop = 95`) and **Southern Timber Clan** (`deme-island-2` at `(1, 0)`, Strategy B, `pop = 95`)—share a fragile savanna core ($d \le 1$, $r_{\text{regen}} = 0.08$) encircled by arid steppe ($2 \le d \le 3$) and coastal shallows ($d = 4$). Without communal conservation norms and sustainable hinterland management, intensive overexploitation and Hawk-Dove conflict degrade carrying capacity $K$ below $60\%$ of $K_{\max}$ and trigger the *Tragedy of the Commons*. ![The Finite Island — Ecological Health Lens & Valley Legend](gh-pages/assets/img/screenshot_finite_island.png) @@ -79,17 +83,17 @@ A fertile but geographically bounded island surrounded by deep ocean where two c ## Interactive Modals & Analytical Tools ### Naturalist's Notebook — Evolutionary Chronicle -Tracks every evolutionary and geopolitical milestone (**Wars**, **Sieges**, **Plunder Raids**, **Tributes**, **Alliance Pacts**, **City Unifications**, **Fortifications**, **Betrayals**, **Baldwin Effect**, **Cultural Innovation**, **Allopatric Speciation**, **Symbiosis**, **Tragedy of the Commons**, **Cultural Loss**, **Extinction**, **Evolutionary Hegemony**, and **Dynamic Equilibrium**) across **18 category filters** with one-click camera jumps to the exact hexagon where the event occurred. +Tracks every evolutionary, demographic, and geopolitical milestone (**Wars**, **Sieges**, **Plunder Raids**, **Tributes**, **Alliance Pacts**, **Settlement Unifications & Walled Unions**, **Fortifications**, **Betrayals**, **Baldwin Effect**, **Cultural Innovation**, **Allopatric Speciation**, **Symbiosis**, **Tragedy of the Commons**, **Cultural Loss**, **Extinction**, **Urban Epidemics**, **Evolutionary Hegemony**, and **Dynamic Equilibrium**) across **19 category filters** with one-click camera jumps to the exact hexagon where the event occurred. ![Naturalist's Notebook — Evolutionary Chronicle Modal](gh-pages/assets/img/screenshot_notebook_modal.png) ### Phylo-Cultural Lineage Tree -Visualizes ancestral and diverged lineages, founding and extinction generations, inherited `strategy_id`, acquired biological and cultural traits, and active villages, with interactive map lineage highlighting. +Visualizes ancestral and diverged lineages, founding and extinction generations, inherited `strategy_id`, acquired biological and cultural traits, and active settlements (`I–IV`), with interactive map lineage highlighting. ![Phylo-Cultural Lineage Tree Modal](gh-pages/assets/img/screenshot_tree_modal.png) -### Strategy, Trait & Decision Forge — Evolutionary Laboratory -An open 4-tab catalog editor (`🧬 Biological Traits`, `🔥 Cultural Traditions`, `⚖️ Behavioral Decisions`, and `🛡️ Strategies`) to inspect, tweak, or craft new traits, decisions, and custom evolutionary strategies—complete with a live **Thermodynamic Balance Meter**, combined 6-axis footprint preview, one-click assignment to **Strategy A / Strategy B**, direct trait injection into any active village, and automatic persistence to `~/.config/Evolution/user_catalog.json`. +### Strategy & Trait Forge — Evolutionary Laboratory +An open 3-tab catalog editor (`🛡️ Strategies`, `🧬 Biological Traits`, and `🔥 Cultural Traditions`) to inspect, tweak, or craft new traits and custom evolutionary strategies—complete with an editable natural-language **Strategy Doctrine**, a live **Thermodynamic Balance Meter**, combined 6-axis footprint preview, one-click assignment to **Strategy A / Strategy B**, direct trait injection into any active settlement, and automatic persistence to `~/.config/Evolution/user_catalog.json`. ![Trait & Decision Forge — Evolutionary Laboratory Modal](gh-pages/assets/img/screenshot_lab_modal.png) @@ -99,11 +103,11 @@ An open 4-tab catalog editor (`🧬 Biological Traits`, `🔥 Cultural Tradition | Input / Shortcut | Action | | :--- | :--- | -| **Left Click** | Select village or hex (Observe mode) / Apply active terrain or climate brush | -| **Right Click + Drag** / **Middle Click + Drag** | Pan camera across the hexagonal valley | -| **Mouse Wheel** | Zoom camera in / out | +| **Left Click** | Select settlement or hex (Observe mode) / Apply active terrain or climate brush (`1x` or `7x` radius) | +| **W / A / S / D** or **Middle / Right Click + Drag** | Pan camera across the `169`-hex 2.5D valley; **R / F** or **Right Drag** tilts pitch ($32^\circ\text{–}68^\circ$, locked horizontal yaw $0^\circ$) | +| **Mouse Wheel** or **+ / -** | Cursor-pivoted zoom (`0.18x` macro archipelago view to `2.80x` settlement close-up) | | **Space** | Toggle Play / Pause generational simulation | -| **Keys `1` – `0`** | Equip slot from the 10-slot bottom Hotbar (`1`: Observe, `2–6`: Biome Brushes, `7–8`: Raise/Lower Altitude, `9`: Enrich Food $K$, `0`: Sea Level) | +| **Keys `1` – `0`** | Equip slot from the 10-slot bottom Hotbar (`1`: Observe, `2–5`: Biome Brushes, `6–7`: Raise/Lower Altitude, `8–9`: Enrich/Deplete $K$, `0`: Sea Level & Tidal Bar) | | **Escape** | Close active modal or return to the `Observe` tool | --- @@ -148,13 +152,13 @@ godot --path . ``` ### Running the Automated Test Suite -The repository includes a headless unit, integration, and UI verification suite covering hexagonal math, thermodynamic conservation, demographic turn phases, i18n, and HUD interactions: +The repository includes a headless unit, integration, and UI verification suite covering `169`-hex axial geometry, 1-deme-per-hex invariants, 6-axis thermodynamic conservation, rural hinterlands, urban salubrity & epidemics, dual synoecism (`walled_union_id` & category absorption), i18n, and HUD interactions: ```bash godot --headless --path . --script res://tests/test_runner.gd ``` ### Regenerating Documentation Screenshots -To regenerate all English screenshots in `gh-pages/assets/img/`: +To regenerate all documentation screenshots in `gh-pages/assets/img/`: ```bash godot --path . --script res://tests/test_visual_capture.gd ``` @@ -164,14 +168,14 @@ godot --path . --script res://tests/test_visual_capture.gd ## Project Structure ```text -├── assets/ # Themes and sprite directories -├── gh-pages/ # MkDocs documentation & shared screenshots (ignored by Godot via .gdignore) +├── assets/ # Themes and auto-cached high-DPI illustrated sprite directories (v3_illustrated_hdpi) +├── gh-pages/ # MkDocs documentation & shared 1920x1080 screenshots (ignored by Godot via .gdignore) ├── scenes/main.tscn # Root scene entry point ├── src/ │ ├── main.gd # Main controller, turn loop timer, and signal orchestration -│ ├── core/ # Data structures (EvoTypes), 6-axis thermodynamics, hex math, catalog, presets, i18n -│ ├── engine/ # Generational turn engine, biophysical phases, and dual-model cognitive engine -│ ├── ui/ # Pixel-art HUD, scroll inspector, hotbar dock, and modals (Journal, Lineages, Forge) -│ └── world/ # 2.5D isometric hexagonal renderer, procedural pixel-art sprites, road networks, camera +│ ├── core/ # Data structures (EvoTypes), 6-axis thermodynamics, 169-hex math, catalog, presets, i18n +│ ├── engine/ # Generational turn engine, biophysical/demographic phases, and dual-model cognitive engine +│ ├── ui/ # Field-journal HUD, scroll inspector, hotbar dock, and modals (Journal, Lineages, Forge) +│ └── world/ # 2.5D illustrated hex renderer, GPU shaders, procedural HD sprites, walled unions, camera └── tests/ # Headless simulation/UI test runner and visual capture script ``` diff --git a/assets/sprites/schema_version.txt b/assets/sprites/schema_version.txt new file mode 100644 index 0000000..4a0bd71 --- /dev/null +++ b/assets/sprites/schema_version.txt @@ -0,0 +1 @@ +v4_readability \ No newline at end of file diff --git a/assets/sprites/terrain/acacia_tree.png b/assets/sprites/terrain/acacia_tree.png new file mode 100644 index 0000000..e563bc0 Binary files /dev/null and b/assets/sprites/terrain/acacia_tree.png differ diff --git a/assets/sprites/terrain/bush_arid.png b/assets/sprites/terrain/bush_arid.png index fdcdc10..ef05962 100644 Binary files a/assets/sprites/terrain/bush_arid.png and b/assets/sprites/terrain/bush_arid.png differ diff --git a/assets/sprites/terrain/bush_berry.png b/assets/sprites/terrain/bush_berry.png index 32bf48e..389648f 100644 Binary files a/assets/sprites/terrain/bush_berry.png and b/assets/sprites/terrain/bush_berry.png differ diff --git a/assets/sprites/terrain/bush_cluster.png b/assets/sprites/terrain/bush_cluster.png new file mode 100644 index 0000000..ef05962 Binary files /dev/null and b/assets/sprites/terrain/bush_cluster.png differ diff --git a/assets/sprites/terrain/coastal_reeds.png b/assets/sprites/terrain/coastal_reeds.png index 5de1bd2..bf6975d 100644 Binary files a/assets/sprites/terrain/coastal_reeds.png and b/assets/sprites/terrain/coastal_reeds.png differ diff --git a/assets/sprites/terrain/dry_scrub.png b/assets/sprites/terrain/dry_scrub.png index f60c97b..7f91e00 100644 Binary files a/assets/sprites/terrain/dry_scrub.png and b/assets/sprites/terrain/dry_scrub.png differ diff --git a/assets/sprites/terrain/fallen_log.png b/assets/sprites/terrain/fallen_log.png index a2e434c..0ca92fe 100644 Binary files a/assets/sprites/terrain/fallen_log.png and b/assets/sprites/terrain/fallen_log.png differ diff --git a/assets/sprites/terrain/hex_tile_arid_steppe.png b/assets/sprites/terrain/hex_tile_arid_steppe.png index c335466..5102473 100644 Binary files a/assets/sprites/terrain/hex_tile_arid_steppe.png and b/assets/sprites/terrain/hex_tile_arid_steppe.png differ diff --git a/assets/sprites/terrain/hex_tile_coastal_shallows.png b/assets/sprites/terrain/hex_tile_coastal_shallows.png index fb19fa0..94e1a84 100644 Binary files a/assets/sprites/terrain/hex_tile_coastal_shallows.png and b/assets/sprites/terrain/hex_tile_coastal_shallows.png differ diff --git a/assets/sprites/terrain/hex_tile_high_mountain.png b/assets/sprites/terrain/hex_tile_high_mountain.png index a112388..1a06578 100644 Binary files a/assets/sprites/terrain/hex_tile_high_mountain.png and b/assets/sprites/terrain/hex_tile_high_mountain.png differ diff --git a/assets/sprites/terrain/hex_tile_ocean.png b/assets/sprites/terrain/hex_tile_ocean.png index 0b02cf0..88ab104 100644 Binary files a/assets/sprites/terrain/hex_tile_ocean.png and b/assets/sprites/terrain/hex_tile_ocean.png differ diff --git a/assets/sprites/terrain/hex_tile_savanna.png b/assets/sprites/terrain/hex_tile_savanna.png index 1753880..12dcf81 100644 Binary files a/assets/sprites/terrain/hex_tile_savanna.png and b/assets/sprites/terrain/hex_tile_savanna.png differ diff --git a/assets/sprites/terrain/hex_tile_tropical_forest.png b/assets/sprites/terrain/hex_tile_tropical_forest.png index 99fb1b2..279ee74 100644 Binary files a/assets/sprites/terrain/hex_tile_tropical_forest.png and b/assets/sprites/terrain/hex_tile_tropical_forest.png differ diff --git a/assets/sprites/terrain/hex_tile_tundra.png b/assets/sprites/terrain/hex_tile_tundra.png index e32032b..57c028b 100644 Binary files a/assets/sprites/terrain/hex_tile_tundra.png and b/assets/sprites/terrain/hex_tile_tundra.png differ diff --git a/assets/sprites/terrain/marsh_reeds.png b/assets/sprites/terrain/marsh_reeds.png new file mode 100644 index 0000000..bf6975d Binary files /dev/null and b/assets/sprites/terrain/marsh_reeds.png differ diff --git a/assets/sprites/terrain/mossy_rock.png b/assets/sprites/terrain/mossy_rock.png index 1a75193..c2a5afd 100644 Binary files a/assets/sprites/terrain/mossy_rock.png and b/assets/sprites/terrain/mossy_rock.png differ diff --git a/assets/sprites/terrain/mountain_peak.png b/assets/sprites/terrain/mountain_peak.png new file mode 100644 index 0000000..91d0d87 Binary files /dev/null and b/assets/sprites/terrain/mountain_peak.png differ diff --git a/assets/sprites/terrain/palm_cluster.png b/assets/sprites/terrain/palm_cluster.png new file mode 100644 index 0000000..b7ae597 Binary files /dev/null and b/assets/sprites/terrain/palm_cluster.png differ diff --git a/assets/sprites/terrain/pine_tree.png b/assets/sprites/terrain/pine_tree.png new file mode 100644 index 0000000..f286716 Binary files /dev/null and b/assets/sprites/terrain/pine_tree.png differ diff --git a/assets/sprites/terrain/rock_mountain.png b/assets/sprites/terrain/rock_mountain.png index 239112d..91d0d87 100644 Binary files a/assets/sprites/terrain/rock_mountain.png and b/assets/sprites/terrain/rock_mountain.png differ diff --git a/assets/sprites/terrain/savanna_tree.png b/assets/sprites/terrain/savanna_tree.png new file mode 100644 index 0000000..a9d420e Binary files /dev/null and b/assets/sprites/terrain/savanna_tree.png differ diff --git a/assets/sprites/terrain/soil_crack.png b/assets/sprites/terrain/soil_crack.png index 26e2881..c87002d 100644 Binary files a/assets/sprites/terrain/soil_crack.png and b/assets/sprites/terrain/soil_crack.png differ diff --git a/assets/sprites/terrain/tall_grass.png b/assets/sprites/terrain/tall_grass.png index 3ab9a82..ae67fd1 100644 Binary files a/assets/sprites/terrain/tall_grass.png and b/assets/sprites/terrain/tall_grass.png differ diff --git a/assets/sprites/terrain/tree_canopy.png b/assets/sprites/terrain/tree_canopy.png new file mode 100644 index 0000000..3f5b09f Binary files /dev/null and b/assets/sprites/terrain/tree_canopy.png differ diff --git a/assets/sprites/terrain/tree_savanna.png b/assets/sprites/terrain/tree_savanna.png index 93ffea2..a9d420e 100644 Binary files a/assets/sprites/terrain/tree_savanna.png and b/assets/sprites/terrain/tree_savanna.png differ diff --git a/assets/sprites/terrain/tree_savanna_2.png b/assets/sprites/terrain/tree_savanna_2.png index e4952de..e563bc0 100644 Binary files a/assets/sprites/terrain/tree_savanna_2.png and b/assets/sprites/terrain/tree_savanna_2.png differ diff --git a/assets/sprites/terrain/tree_stump.png b/assets/sprites/terrain/tree_stump.png index 61980d9..cccab7b 100644 Binary files a/assets/sprites/terrain/tree_stump.png and b/assets/sprites/terrain/tree_stump.png differ diff --git a/assets/sprites/terrain/tree_tropical.png b/assets/sprites/terrain/tree_tropical.png index 83bd043..3f5b09f 100644 Binary files a/assets/sprites/terrain/tree_tropical.png and b/assets/sprites/terrain/tree_tropical.png differ diff --git a/assets/sprites/terrain/tree_tropical_2.png b/assets/sprites/terrain/tree_tropical_2.png index bc6483d..b7ae597 100644 Binary files a/assets/sprites/terrain/tree_tropical_2.png and b/assets/sprites/terrain/tree_tropical_2.png differ diff --git a/assets/sprites/terrain/tree_tundra.png b/assets/sprites/terrain/tree_tundra.png index 0cea7a2..f286716 100644 Binary files a/assets/sprites/terrain/tree_tundra.png and b/assets/sprites/terrain/tree_tundra.png differ diff --git a/assets/sprites/terrain/tree_tundra_2.png b/assets/sprites/terrain/tree_tundra_2.png index 9550b31..c41b818 100644 Binary files a/assets/sprites/terrain/tree_tundra_2.png and b/assets/sprites/terrain/tree_tundra_2.png differ diff --git a/assets/sprites/terrain/wildflowers.png b/assets/sprites/terrain/wildflowers.png index be0bf7c..b72d987 100644 Binary files a/assets/sprites/terrain/wildflowers.png and b/assets/sprites/terrain/wildflowers.png differ diff --git a/assets/sprites/ui/icon_betrayal.png b/assets/sprites/ui/icon_betrayal.png index 1b0bd0f..2a3c467 100644 Binary files a/assets/sprites/ui/icon_betrayal.png and b/assets/sprites/ui/icon_betrayal.png differ diff --git a/assets/sprites/ui/icon_broken_link.png b/assets/sprites/ui/icon_broken_link.png index dedfb9d..32e0c6d 100644 Binary files a/assets/sprites/ui/icon_broken_link.png and b/assets/sprites/ui/icon_broken_link.png differ diff --git a/assets/sprites/ui/icon_casualties.png b/assets/sprites/ui/icon_casualties.png new file mode 100644 index 0000000..bca9391 Binary files /dev/null and b/assets/sprites/ui/icon_casualties.png differ diff --git a/assets/sprites/ui/icon_conflict_swords.png b/assets/sprites/ui/icon_conflict_swords.png index fb732d1..2a9154d 100644 Binary files a/assets/sprites/ui/icon_conflict_swords.png and b/assets/sprites/ui/icon_conflict_swords.png differ diff --git a/assets/sprites/ui/icon_coop_link.png b/assets/sprites/ui/icon_coop_link.png index 5d00cae..5715067 100644 Binary files a/assets/sprites/ui/icon_coop_link.png and b/assets/sprites/ui/icon_coop_link.png differ diff --git a/assets/sprites/ui/icon_cooperate.png b/assets/sprites/ui/icon_cooperate.png index 1c5b86f..8330dbc 100644 Binary files a/assets/sprites/ui/icon_cooperate.png and b/assets/sprites/ui/icon_cooperate.png differ diff --git a/assets/sprites/ui/icon_custom.png b/assets/sprites/ui/icon_custom.png index 72cdc43..667d661 100644 Binary files a/assets/sprites/ui/icon_custom.png and b/assets/sprites/ui/icon_custom.png differ diff --git a/assets/sprites/ui/icon_defense.png b/assets/sprites/ui/icon_defense.png index b43ef6a..2f82f7f 100644 Binary files a/assets/sprites/ui/icon_defense.png and b/assets/sprites/ui/icon_defense.png differ diff --git a/assets/sprites/ui/icon_dispute.png b/assets/sprites/ui/icon_dispute.png index 1373cba..ae8eda0 100644 Binary files a/assets/sprites/ui/icon_dispute.png and b/assets/sprites/ui/icon_dispute.png differ diff --git a/assets/sprites/ui/icon_epidemic.png b/assets/sprites/ui/icon_epidemic.png new file mode 100644 index 0000000..3817f30 Binary files /dev/null and b/assets/sprites/ui/icon_epidemic.png differ diff --git a/assets/sprites/ui/icon_exchange.png b/assets/sprites/ui/icon_exchange.png index 1a48154..4f15faa 100644 Binary files a/assets/sprites/ui/icon_exchange.png and b/assets/sprites/ui/icon_exchange.png differ diff --git a/assets/sprites/ui/icon_famine.png b/assets/sprites/ui/icon_famine.png new file mode 100644 index 0000000..a316f81 Binary files /dev/null and b/assets/sprites/ui/icon_famine.png differ diff --git a/assets/sprites/ui/icon_forage.png b/assets/sprites/ui/icon_forage.png index f0e0301..4e25a46 100644 Binary files a/assets/sprites/ui/icon_forage.png and b/assets/sprites/ui/icon_forage.png differ diff --git a/assets/sprites/ui/icon_immune.png b/assets/sprites/ui/icon_immune.png new file mode 100644 index 0000000..00424d2 Binary files /dev/null and b/assets/sprites/ui/icon_immune.png differ diff --git a/assets/sprites/ui/icon_innovate.png b/assets/sprites/ui/icon_innovate.png index d1c8234..d9989ca 100644 Binary files a/assets/sprites/ui/icon_innovate.png and b/assets/sprites/ui/icon_innovate.png differ diff --git a/assets/sprites/ui/icon_loot.png b/assets/sprites/ui/icon_loot.png new file mode 100644 index 0000000..bdafcf0 Binary files /dev/null and b/assets/sprites/ui/icon_loot.png differ diff --git a/assets/sprites/ui/icon_merge.png b/assets/sprites/ui/icon_merge.png index 37d8d68..e60213d 100644 Binary files a/assets/sprites/ui/icon_merge.png and b/assets/sprites/ui/icon_merge.png differ diff --git a/assets/sprites/ui/icon_migrants.png b/assets/sprites/ui/icon_migrants.png new file mode 100644 index 0000000..7062eac Binary files /dev/null and b/assets/sprites/ui/icon_migrants.png differ diff --git a/assets/sprites/ui/icon_migrate.png b/assets/sprites/ui/icon_migrate.png index f47f3cb..ed353b1 100644 Binary files a/assets/sprites/ui/icon_migrate.png and b/assets/sprites/ui/icon_migrate.png differ diff --git a/assets/sprites/ui/icon_overexploit.png b/assets/sprites/ui/icon_overexploit.png index 03d8cab..3aea8e1 100644 Binary files a/assets/sprites/ui/icon_overexploit.png and b/assets/sprites/ui/icon_overexploit.png differ diff --git a/assets/sprites/ui/icon_pact.png b/assets/sprites/ui/icon_pact.png index 6615750..85882a3 100644 Binary files a/assets/sprites/ui/icon_pact.png and b/assets/sprites/ui/icon_pact.png differ diff --git a/assets/sprites/ui/icon_raid.png b/assets/sprites/ui/icon_raid.png index 4fff444..120892a 100644 Binary files a/assets/sprites/ui/icon_raid.png and b/assets/sprites/ui/icon_raid.png differ diff --git a/assets/sprites/ui/icon_siege.png b/assets/sprites/ui/icon_siege.png index 634ef76..210228d 100644 Binary files a/assets/sprites/ui/icon_siege.png and b/assets/sprites/ui/icon_siege.png differ diff --git a/assets/sprites/ui/icon_tribute.png b/assets/sprites/ui/icon_tribute.png index 612d5af..2f763ca 100644 Binary files a/assets/sprites/ui/icon_tribute.png and b/assets/sprites/ui/icon_tribute.png differ diff --git a/assets/sprites/ui/icon_vendetta.png b/assets/sprites/ui/icon_vendetta.png new file mode 100644 index 0000000..ad8c513 Binary files /dev/null and b/assets/sprites/ui/icon_vendetta.png differ diff --git a/assets/sprites/ui/icon_war.png b/assets/sprites/ui/icon_war.png index c9bae3a..3cbbe39 100644 Binary files a/assets/sprites/ui/icon_war.png and b/assets/sprites/ui/icon_war.png differ diff --git a/assets/sprites/ui/season_dry.png b/assets/sprites/ui/season_dry.png index 3d03a66..55e52be 100644 Binary files a/assets/sprites/ui/season_dry.png and b/assets/sprites/ui/season_dry.png differ diff --git a/assets/sprites/ui/season_wet.png b/assets/sprites/ui/season_wet.png index d800317..04b7452 100644 Binary files a/assets/sprites/ui/season_wet.png and b/assets/sprites/ui/season_wet.png differ diff --git a/assets/sprites/ui/status_stable.png b/assets/sprites/ui/status_stable.png index e06a75e..94ce834 100644 Binary files a/assets/sprites/ui/status_stable.png and b/assets/sprites/ui/status_stable.png differ diff --git a/assets/sprites/ui/status_starving.png b/assets/sprites/ui/status_starving.png index 9d7ff27..c300f21 100644 Binary files a/assets/sprites/ui/status_starving.png and b/assets/sprites/ui/status_starving.png differ diff --git a/assets/sprites/ui/status_struggling.png b/assets/sprites/ui/status_struggling.png index c145ad7..cc761ef 100644 Binary files a/assets/sprites/ui/status_struggling.png and b/assets/sprites/ui/status_struggling.png differ diff --git a/assets/sprites/ui/status_thriving.png b/assets/sprites/ui/status_thriving.png index 129635a..825714b 100644 Binary files a/assets/sprites/ui/status_thriving.png and b/assets/sprites/ui/status_thriving.png differ diff --git a/assets/sprites/ui/thought_bubble.png b/assets/sprites/ui/thought_bubble.png index 27a54f0..33c9621 100644 Binary files a/assets/sprites/ui/thought_bubble.png and b/assets/sprites/ui/thought_bubble.png differ diff --git a/assets/sprites/ui/tool_altitude.png b/assets/sprites/ui/tool_altitude.png index 1afde82..a154388 100644 Binary files a/assets/sprites/ui/tool_altitude.png and b/assets/sprites/ui/tool_altitude.png differ diff --git a/assets/sprites/ui/tool_biome.png b/assets/sprites/ui/tool_biome.png index 0317471..c9ac2d8 100644 Binary files a/assets/sprites/ui/tool_biome.png and b/assets/sprites/ui/tool_biome.png differ diff --git a/assets/sprites/ui/tool_fertility.png b/assets/sprites/ui/tool_fertility.png index 6f7c335..0cb4851 100644 Binary files a/assets/sprites/ui/tool_fertility.png and b/assets/sprites/ui/tool_fertility.png differ diff --git a/assets/sprites/ui/tool_inspect.png b/assets/sprites/ui/tool_inspect.png index 70d1f1b..48979aa 100644 Binary files a/assets/sprites/ui/tool_inspect.png and b/assets/sprites/ui/tool_inspect.png differ diff --git a/assets/sprites/ui/tool_sealevel.png b/assets/sprites/ui/tool_sealevel.png index 0d06389..8439231 100644 Binary files a/assets/sprites/ui/tool_sealevel.png and b/assets/sprites/ui/tool_sealevel.png differ diff --git a/assets/sprites/villages/act_betrayal.png b/assets/sprites/villages/act_betrayal.png index daf944b..d446f77 100644 Binary files a/assets/sprites/villages/act_betrayal.png and b/assets/sprites/villages/act_betrayal.png differ diff --git a/assets/sprites/villages/act_cooperate.png b/assets/sprites/villages/act_cooperate.png index 6142c6b..4338d7e 100644 Binary files a/assets/sprites/villages/act_cooperate.png and b/assets/sprites/villages/act_cooperate.png differ diff --git a/assets/sprites/villages/act_custom.png b/assets/sprites/villages/act_custom.png index dd3680b..5d806d3 100644 Binary files a/assets/sprites/villages/act_custom.png and b/assets/sprites/villages/act_custom.png differ diff --git a/assets/sprites/villages/act_dispute.png b/assets/sprites/villages/act_dispute.png index bf1923e..1322f0c 100644 Binary files a/assets/sprites/villages/act_dispute.png and b/assets/sprites/villages/act_dispute.png differ diff --git a/assets/sprites/villages/act_exchange.png b/assets/sprites/villages/act_exchange.png index f999474..24049b9 100644 Binary files a/assets/sprites/villages/act_exchange.png and b/assets/sprites/villages/act_exchange.png differ diff --git a/assets/sprites/villages/act_forage.png b/assets/sprites/villages/act_forage.png index c8fb0c1..24fc919 100644 Binary files a/assets/sprites/villages/act_forage.png and b/assets/sprites/villages/act_forage.png differ diff --git a/assets/sprites/villages/act_innovate.png b/assets/sprites/villages/act_innovate.png index f34ba0b..5d806d3 100644 Binary files a/assets/sprites/villages/act_innovate.png and b/assets/sprites/villages/act_innovate.png differ diff --git a/assets/sprites/villages/act_migrate.png b/assets/sprites/villages/act_migrate.png index 6fd3be6..7f7fb30 100644 Binary files a/assets/sprites/villages/act_migrate.png and b/assets/sprites/villages/act_migrate.png differ diff --git a/assets/sprites/villages/act_overexploit.png b/assets/sprites/villages/act_overexploit.png index bc70a52..3bf3a9c 100644 Binary files a/assets/sprites/villages/act_overexploit.png and b/assets/sprites/villages/act_overexploit.png differ diff --git a/assets/sprites/villages/act_pact.png b/assets/sprites/villages/act_pact.png index 4630916..37297bb 100644 Binary files a/assets/sprites/villages/act_pact.png and b/assets/sprites/villages/act_pact.png differ diff --git a/assets/sprites/villages/act_raid.png b/assets/sprites/villages/act_raid.png index 23710cb..4739612 100644 Binary files a/assets/sprites/villages/act_raid.png and b/assets/sprites/villages/act_raid.png differ diff --git a/assets/sprites/villages/act_war.png b/assets/sprites/villages/act_war.png index 1dad405..35ceae0 100644 Binary files a/assets/sprites/villages/act_war.png and b/assets/sprites/villages/act_war.png differ diff --git a/assets/sprites/villages/barricade_spikes.png b/assets/sprites/villages/barricade_spikes.png index 0f9912b..c1d6f9b 100644 Binary files a/assets/sprites/villages/barricade_spikes.png and b/assets/sprites/villages/barricade_spikes.png differ diff --git a/assets/sprites/villages/campfire.png b/assets/sprites/villages/campfire.png new file mode 100644 index 0000000..6dece93 Binary files /dev/null and b/assets/sprites/villages/campfire.png differ diff --git a/assets/sprites/villages/caravan_cart.png b/assets/sprites/villages/caravan_cart.png index 6df36ed..7f7fb30 100644 Binary files a/assets/sprites/villages/caravan_cart.png and b/assets/sprites/villages/caravan_cart.png differ diff --git a/assets/sprites/villages/cathedral.png b/assets/sprites/villages/cathedral.png new file mode 100644 index 0000000..48e9e15 Binary files /dev/null and b/assets/sprites/villages/cathedral.png differ diff --git a/assets/sprites/villages/citadel_keep.png b/assets/sprites/villages/citadel_keep.png new file mode 100644 index 0000000..cb4d7f4 Binary files /dev/null and b/assets/sprites/villages/citadel_keep.png differ diff --git a/assets/sprites/villages/covered_wagon.png b/assets/sprites/villages/covered_wagon.png new file mode 100644 index 0000000..7f7fb30 Binary files /dev/null and b/assets/sprites/villages/covered_wagon.png differ diff --git a/assets/sprites/villages/crop_field.png b/assets/sprites/villages/crop_field.png new file mode 100644 index 0000000..24fc919 Binary files /dev/null and b/assets/sprites/villages/crop_field.png differ diff --git a/assets/sprites/villages/cultural_totem.png b/assets/sprites/villages/cultural_totem.png index b2839d6..5d806d3 100644 Binary files a/assets/sprites/villages/cultural_totem.png and b/assets/sprites/villages/cultural_totem.png differ diff --git a/assets/sprites/villages/effect_battle_sparks.png b/assets/sprites/villages/effect_battle_sparks.png index b4b90e7..f629a4e 100644 Binary files a/assets/sprites/villages/effect_battle_sparks.png and b/assets/sprites/villages/effect_battle_sparks.png differ diff --git a/assets/sprites/villages/granary.png b/assets/sprites/villages/granary.png new file mode 100644 index 0000000..5e7ffbf Binary files /dev/null and b/assets/sprites/villages/granary.png differ diff --git a/assets/sprites/villages/house_clay.png b/assets/sprites/villages/house_clay.png new file mode 100644 index 0000000..9786c1a Binary files /dev/null and b/assets/sprites/villages/house_clay.png differ diff --git a/assets/sprites/villages/house_stone.png b/assets/sprites/villages/house_stone.png new file mode 100644 index 0000000..40f85a1 Binary files /dev/null and b/assets/sprites/villages/house_stone.png differ diff --git a/assets/sprites/villages/hut_thatch.png b/assets/sprites/villages/hut_thatch.png new file mode 100644 index 0000000..542b6b0 Binary files /dev/null and b/assets/sprites/villages/hut_thatch.png differ diff --git a/assets/sprites/villages/palisade_wall.png b/assets/sprites/villages/palisade_wall.png new file mode 100644 index 0000000..493b5b8 Binary files /dev/null and b/assets/sprites/villages/palisade_wall.png differ diff --git a/assets/sprites/villages/pasture_pen.png b/assets/sprites/villages/pasture_pen.png new file mode 100644 index 0000000..44e4491 Binary files /dev/null and b/assets/sprites/villages/pasture_pen.png differ diff --git a/assets/sprites/villages/prop_betrayal_ruin.png b/assets/sprites/villages/prop_betrayal_ruin.png index daf944b..d446f77 100644 Binary files a/assets/sprites/villages/prop_betrayal_ruin.png and b/assets/sprites/villages/prop_betrayal_ruin.png differ diff --git a/assets/sprites/villages/prop_defense_fortress.png b/assets/sprites/villages/prop_defense_fortress.png index 061fae0..0fe7dea 100644 Binary files a/assets/sprites/villages/prop_defense_fortress.png and b/assets/sprites/villages/prop_defense_fortress.png differ diff --git a/assets/sprites/villages/prop_defense_palisade.png b/assets/sprites/villages/prop_defense_palisade.png index 8082fa3..493b5b8 100644 Binary files a/assets/sprites/villages/prop_defense_palisade.png and b/assets/sprites/villages/prop_defense_palisade.png differ diff --git a/assets/sprites/villages/prop_defense_wall.png b/assets/sprites/villages/prop_defense_wall.png index ad06baa..b6e2959 100644 Binary files a/assets/sprites/villages/prop_defense_wall.png and b/assets/sprites/villages/prop_defense_wall.png differ diff --git a/assets/sprites/villages/prop_pact_totem.png b/assets/sprites/villages/prop_pact_totem.png index 4630916..37297bb 100644 Binary files a/assets/sprites/villages/prop_pact_totem.png and b/assets/sprites/villages/prop_pact_totem.png differ diff --git a/assets/sprites/villages/prop_raid_loot.png b/assets/sprites/villages/prop_raid_loot.png index 23710cb..4739612 100644 Binary files a/assets/sprites/villages/prop_raid_loot.png and b/assets/sprites/villages/prop_raid_loot.png differ diff --git a/assets/sprites/villages/prop_siege_engine.png b/assets/sprites/villages/prop_siege_engine.png index 57b5706..f2398b3 100644 Binary files a/assets/sprites/villages/prop_siege_engine.png and b/assets/sprites/villages/prop_siege_engine.png differ diff --git a/assets/sprites/villages/prop_tribute_chest.png b/assets/sprites/villages/prop_tribute_chest.png index 6516e4f..ea570bb 100644 Binary files a/assets/sprites/villages/prop_tribute_chest.png and b/assets/sprites/villages/prop_tribute_chest.png differ diff --git a/assets/sprites/villages/prop_unity_monument.png b/assets/sprites/villages/prop_unity_monument.png index f94f8cf..f5135ef 100644 Binary files a/assets/sprites/villages/prop_unity_monument.png and b/assets/sprites/villages/prop_unity_monument.png differ diff --git a/assets/sprites/villages/prop_war_banner.png b/assets/sprites/villages/prop_war_banner.png index 1dad405..35ceae0 100644 Binary files a/assets/sprites/villages/prop_war_banner.png and b/assets/sprites/villages/prop_war_banner.png differ diff --git a/assets/sprites/villages/road_block_sign.png b/assets/sprites/villages/road_block_sign.png index 50170c0..7403952 100644 Binary files a/assets/sprites/villages/road_block_sign.png and b/assets/sprites/villages/road_block_sign.png differ diff --git a/assets/sprites/villages/road_lantern.png b/assets/sprites/villages/road_lantern.png index a0aa18c..af0e292 100644 Binary files a/assets/sprites/villages/road_lantern.png and b/assets/sprites/villages/road_lantern.png differ diff --git a/assets/sprites/villages/ruins.png b/assets/sprites/villages/ruins.png index c459928..184f64c 100644 Binary files a/assets/sprites/villages/ruins.png and b/assets/sprites/villages/ruins.png differ diff --git a/assets/sprites/villages/ruins_stone.png b/assets/sprites/villages/ruins_stone.png new file mode 100644 index 0000000..184f64c Binary files /dev/null and b/assets/sprites/villages/ruins_stone.png differ diff --git a/assets/sprites/villages/settler_wagon.png b/assets/sprites/villages/settler_wagon.png index 1a5ea50..7f7fb30 100644 Binary files a/assets/sprites/villages/settler_wagon.png and b/assets/sprites/villages/settler_wagon.png differ diff --git a/assets/sprites/villages/siege_totem.png b/assets/sprites/villages/siege_totem.png new file mode 100644 index 0000000..f2398b3 Binary files /dev/null and b/assets/sprites/villages/siege_totem.png differ diff --git a/assets/sprites/villages/stone_wall.png b/assets/sprites/villages/stone_wall.png new file mode 100644 index 0000000..b6e2959 Binary files /dev/null and b/assets/sprites/villages/stone_wall.png differ diff --git a/assets/sprites/villages/temple_totem.png b/assets/sprites/villages/temple_totem.png new file mode 100644 index 0000000..5a81227 Binary files /dev/null and b/assets/sprites/villages/temple_totem.png differ diff --git a/assets/sprites/villages/tier1_campfire.png b/assets/sprites/villages/tier1_campfire.png index 9a0191f..6dece93 100644 Binary files a/assets/sprites/villages/tier1_campfire.png and b/assets/sprites/villages/tier1_campfire.png differ diff --git a/assets/sprites/villages/tier1_campfire_2.png b/assets/sprites/villages/tier1_campfire_2.png index a1e7baf..17517e5 100644 Binary files a/assets/sprites/villages/tier1_campfire_2.png and b/assets/sprites/villages/tier1_campfire_2.png differ diff --git a/assets/sprites/villages/tier1_tent.png b/assets/sprites/villages/tier1_tent.png index 837d9c0..542b6b0 100644 Binary files a/assets/sprites/villages/tier1_tent.png and b/assets/sprites/villages/tier1_tent.png differ diff --git a/assets/sprites/villages/tier2_barn.png b/assets/sprites/villages/tier2_barn.png index a5b1a6b..5e7ffbf 100644 Binary files a/assets/sprites/villages/tier2_barn.png and b/assets/sprites/villages/tier2_barn.png differ diff --git a/assets/sprites/villages/tier2_cottage.png b/assets/sprites/villages/tier2_cottage.png index 1a7f007..9786c1a 100644 Binary files a/assets/sprites/villages/tier2_cottage.png and b/assets/sprites/villages/tier2_cottage.png differ diff --git a/assets/sprites/villages/tier3_house.png b/assets/sprites/villages/tier3_house.png index f420a3f..40f85a1 100644 Binary files a/assets/sprites/villages/tier3_house.png and b/assets/sprites/villages/tier3_house.png differ diff --git a/assets/sprites/villages/tier3_townhall.png b/assets/sprites/villages/tier3_townhall.png index 562b435..5a81227 100644 Binary files a/assets/sprites/villages/tier3_townhall.png and b/assets/sprites/villages/tier3_townhall.png differ diff --git a/assets/sprites/villages/tier4_citadel.png b/assets/sprites/villages/tier4_citadel.png index c118b52..cb4d7f4 100644 Binary files a/assets/sprites/villages/tier4_citadel.png and b/assets/sprites/villages/tier4_citadel.png differ diff --git a/assets/sprites/villages/tier4_tower.png b/assets/sprites/villages/tier4_tower.png index e49e6c5..0fe7dea 100644 Binary files a/assets/sprites/villages/tier4_tower.png and b/assets/sprites/villages/tier4_tower.png differ diff --git a/assets/sprites/villages/tier4_windmill.png b/assets/sprites/villages/tier4_windmill.png index d952394..6034b3b 100644 Binary files a/assets/sprites/villages/tier4_windmill.png and b/assets/sprites/villages/tier4_windmill.png differ diff --git a/assets/sprites/villages/tier5_metropolis.png b/assets/sprites/villages/tier5_metropolis.png index add0798..48e9e15 100644 Binary files a/assets/sprites/villages/tier5_metropolis.png and b/assets/sprites/villages/tier5_metropolis.png differ diff --git a/assets/sprites/villages/totem_aggro.png b/assets/sprites/villages/totem_aggro.png new file mode 100644 index 0000000..1322f0c Binary files /dev/null and b/assets/sprites/villages/totem_aggro.png differ diff --git a/assets/sprites/villages/totem_coop.png b/assets/sprites/villages/totem_coop.png new file mode 100644 index 0000000..4338d7e Binary files /dev/null and b/assets/sprites/villages/totem_coop.png differ diff --git a/assets/sprites/villages/totem_innov.png b/assets/sprites/villages/totem_innov.png new file mode 100644 index 0000000..5d806d3 Binary files /dev/null and b/assets/sprites/villages/totem_innov.png differ diff --git a/assets/sprites/villages/villager_idle.png b/assets/sprites/villages/villager_idle.png index 7334a00..72a5bff 100644 Binary files a/assets/sprites/villages/villager_idle.png and b/assets/sprites/villages/villager_idle.png differ diff --git a/assets/sprites/villages/villager_walk1.png b/assets/sprites/villages/villager_walk1.png index cd5c80c..2fff974 100644 Binary files a/assets/sprites/villages/villager_walk1.png and b/assets/sprites/villages/villager_walk1.png differ diff --git a/assets/sprites/villages/villager_walk2.png b/assets/sprites/villages/villager_walk2.png index 8191025..69b1dcc 100644 Binary files a/assets/sprites/villages/villager_walk2.png and b/assets/sprites/villages/villager_walk2.png differ diff --git a/assets/sprites/villages/watchtower.png b/assets/sprites/villages/watchtower.png new file mode 100644 index 0000000..0fe7dea Binary files /dev/null and b/assets/sprites/villages/watchtower.png differ diff --git a/assets/sprites/villages/windmill.png b/assets/sprites/villages/windmill.png new file mode 100644 index 0000000..6034b3b Binary files /dev/null and b/assets/sprites/villages/windmill.png differ diff --git a/gh-pages/architecture_developer.md b/gh-pages/architecture_developer.md index 83c0957..9dd3b01 100644 --- a/gh-pages/architecture_developer.md +++ b/gh-pages/architecture_developer.md @@ -1,6 +1,6 @@ # System Architecture & Developer Guide -**Evolution** is built with **Godot Engine 4.7+** in strictly typed GDScript, following a modular architecture that cleanly separates **pure data models & thermodynamic rules (`src/core/`)**, **generational simulation & AI cognition (`src/engine/`)**, **2.5D isometric rendering & procedural pixel-art generation (`src/world/`)**, and **interactive field-journal UI (`src/ui/`)**. +**Evolution** is built with **Godot Engine 4.7+** in strictly typed GDScript, following a modular architecture that cleanly separates **pure data models & thermodynamic rules (`src/core/`)**, **generational simulation & AI cognition (`src/engine/`)**, **2.5D illustrated isometric rendering, GPU shaders & procedural high-DPI sprites (`src/world/`)**, and **interactive field-journal UI (`src/ui/`)**. --- @@ -13,30 +13,30 @@ flowchart TD end subgraph Core ["src/core/ — Pure Domain Primitives"] - Types["EvoTypes (types.gd)\nWorldState, Deme, HexCell, LineageNode,\nStrategyDef, TraitDef, DecisionDef, BiophysicalVector"] + Types["EvoTypes (types.gd)\nWorldState, Deme (salubrity, walled_union_id, 4 Tiers I–IV),\nHexCell (land_use, owner_deme_id, has_ruins), LineageNode,\nStrategyDef, TraitDef, DecisionDef, BiophysicalVector"] Thermo["EvoThermodynamics (thermodynamics.gd)\n6-Axis Conservation & Auto-Balancing"] - Catalog["EvoCatalog & EvoPresets\n5 Base Strategies, Traits, Decisions & 3 Presets"] - HexMath["HexMath (hex_math.gd)\nAxial Geometry, Spirals & Sector Offsets"] + Catalog["EvoCatalog & EvoPresets\n5 Base Strategies, Traits, Decisions & 3 169-Hex Presets"] + HexMath["HexMath (hex_math.gd)\nAxial Geometry (HEX_SIZE = 240.0), Spirals & Single-Deme Center"] I18n["EvoI18n & EvoUserSettings\nBilingual ES/EN Catalog, settings.json & user_catalog.json"] end subgraph Engine ["src/engine/ — Simulation & Cognitive AI"] Turn["EvoTurnEngine (turn_engine.gd)\n5-Stage Generational Orchestrator & Brushes"] - Env["EvoEnvPhase (env_phase.gd)\nSeasons, Glacial Cycles, Flooding & Regen"] + Env["EvoEnvPhase (env_phase.gd)\nSeasons, Glacial Cycles, 1-Deme Submergence Relocation & Regen"] Cog["EvoDecisionModel + EvoOllamaClient + EvoHeuristicScorer\nDual System 1 (d3:4b / tev1:4b), Signatures & Trait Synthesis"] - Biophys["EvoBiophysPhase + EvoEcologyInteraction + EvoDemographyMigration\nHawk-Dove, Symbiosis, K Degrade, Growth, Fission & Merging"] + Biophys["EvoBiophysPhase + EvoEcologyInteraction + EvoDemographyMigration\nUrban Foraging, Rural Hinterlands & Pillaging, Hawk-Dove, Symbiosis,\nSalubrity & Epidemics, Wars, Asymmetric Absorption & Walled Unions"] Mile["EvoMilestonePhase (milestone_phase.gd)\nSpeciation, Baldwin, Innovation & Macro-Outcomes"] end - subgraph World ["src/world/ — 2.5D Isometric Renderer & Procedural Sprites"] - Renderer["EvoWorldRenderer + EvoCameraController\n2.5D Projection, Depth Sorting & Dirty-State Cache"] - SubRender["PropFloraRenderer + RoadNetworkRenderer + WorldOverlayRenderer\n3-Tier Walls, Unified Plazas, 11 Road/Geopolitical Archetypes & Billboards"] - Sprites["EvoPixelArtGen + PixelCanvas + TerrainSprites + VillageUiSprites\n99 Procedural Pixel-Art Textures & Lineage Tunic Recoloring"] + subgraph World ["src/world/ — 2.5D Illustrated Renderer, Shaders & High-DPI Sprites"] + Renderer["EvoWorldRenderer + EvoCameraController + Shaders\nLocked-Yaw 2.5D Projection, Ocean/Terrain/Sway Shaders & Column Cache"] + SubRender["PropFloraRenderer + RoadNetworkRenderer + WorldOverlayRenderer\n4 Urban Tiers (I–IV), Rural Hinterlands, Hex-Edge Perimeter Walls,\nWalled Union Internal Wall Culling, 11 Road Archetypes & Billboards"] + Sprites["EvoPixelArtGen + PixelCanvas + TerrainSprites + VillageUiSprites\nv3_illustrated_hdpi High-DPI Sprites (256×224 Hexes, #23150c Outlines)"] end subgraph UI ["src/ui/ — Cozy Field-Journal HUD & Modals"] HUD["EvoMainHud + TopBar + InspectorPanel + HotbarDock + BannersToasts"] - Modals["EvoNotebookModal (Chronicle) + EvoTreeModal (Lineages) + EvoLabModal (Forge)"] + Modals["EvoNotebookModal (19 Filters) + EvoTreeModal (Lineages) + EvoLabModal (Forge)"] end Main --> Turn @@ -52,7 +52,7 @@ flowchart TD ```text ├── assets/ -│ └── sprites/ # Auto-generated & cached procedural pixel-art PNGs (terrain/, villages/, ui/) +│ └── sprites/ # Auto-generated & cached high-DPI procedural PNGs (v3_illustrated_hdpi: terrain/, villages/, ui/) ├── gh-pages/ # MkDocs documentation & shared application screenshots (.gdignore) ├── scenes/ │ └── main.tscn # Root scene entry point binding EvoMainController @@ -60,47 +60,51 @@ flowchart TD │ ├── main.gd # EvoMainController: turn loop, async LLM hot-swap, signal routing │ ├── core/ │ │ ├── catalog.gd # EvoCatalog: 5 base strategies, 6 bio traits, 5 cultural traits, 7 decisions -│ │ ├── hex_math.gd # HexMath: pointy-top axial math, distance, spirals, village sector offsets +│ │ ├── hex_math.gd # HexMath: pointy-top axial math (HEX_SIZE = 240.0), distance, spirals, single-deme center │ │ ├── i18n.gd # EvoI18n: bilingual (ES / EN) localization dictionary & strategy helpers -│ │ ├── presets.gd # EvoPresets: 1v1 strategy builders for Rift Valley, Darwin's Archipelago, Finite Island +│ │ ├── presets.gd # EvoPresets: 169-hex (map_radius = 7) 1v1 builders for Rift Valley, Darwin's Archipelago, Finite Island │ │ ├── thermodynamics.gd # EvoThermodynamics: BASE_BUDGET = 0.60 validation & auto-balancing -│ │ ├── types.gd # EvoTypes: serializable data models (StrategyDef, Deme, LineageNode, WorldState) +│ │ ├── types.gd # EvoTypes: serializable data models (StrategyDef, Deme, HexCell, LineageNode, WorldState) │ │ └── user_settings.gd # EvoUserSettings: ~/.config/Evolution/settings.json & user_catalog.json persistence │ ├── engine/ │ │ ├── turn_engine.gd # EvoTurnEngine: advance_generation() pipeline & God-mode brush actions -│ │ ├── env_phase.gd # EvoEnvPhase: seasonal & glacial cycles, coastal flooding, K/R regen +│ │ ├── env_phase.gd # EvoEnvPhase: seasonal & glacial cycles, 1-deme coastal submergence relocation, K/R regen │ │ ├── decision_model.gd # EvoDecisionModel: signature bucketing, 75/25 blending, 8-sig prefetch orchestration │ │ ├── biophys_phase.gd # EvoBiophysPhase: coordinates ecology & demography sub-phases │ │ ├── milestone_phase.gd # EvoMilestonePhase: Speciation, Baldwin, Innovation & Macro-Outcomes │ │ ├── biophys/ -│ │ │ ├── ecology_interaction.gd # EvoEcologyInteraction: Hawk-Dove, trade, pacts, vendettas, tribute, raids, walls -│ │ │ └── demography_migration.gd # EvoDemographyMigration: metabolism & upkeep, r/K growth, wars/sieges, synoecism +│ │ │ ├── ecology_interaction.gd # EvoEcologyInteraction: urban foraging, rural hinterland cultivation & pillaging, Hawk-Dove, trade, pacts, raids, walls +│ │ │ └── demography_migration.gd # EvoDemographyMigration: metabolism & upkeep, salubrity & epidemics, r/K growth, wars/sieges, dual synoecism │ │ └── cognitive/ │ │ ├── heuristic_scorer.gd # EvoHeuristicScorer: coarsened 10-part signatures, 6-gen memory, neighbor tags │ │ └── ollama_client.gd # EvoOllamaClient: WorkerThreadPool HTTP batching & model pull │ ├── ui/ │ │ ├── main_hud.gd # EvoMainHud: top-level UI controller & Ollama startup check │ │ ├── ui_style.gd # EvoUiStyle: parchment/wood theme tokens, StyleBoxes & helpers -│ │ ├── notebook_modal.gd # EvoNotebookModal: 18-filter Evolutionary Chronicle & hex jump +│ │ ├── notebook_modal.gd # EvoNotebookModal: 19-filter Evolutionary Chronicle (including Epidemics) & hex jump │ │ ├── tree_modal.gd # EvoTreeModal: Bézier phylogenetic tree & map lineage highlighter │ │ ├── lab_modal.gd # EvoLabModal: 4-tab Strategy, Trait & Decision Forge with live balance meter │ │ └── hud/ │ │ ├── hud_top_bar.gd # EvoHudTopBar: valley sign, 1v1 matchup bar, lenses, season portrait, settings -│ │ ├── hud_inspector_panel.gd # EvoHudInspectorPanel: 3-tab village/traits/habitat scroll inspector +│ │ ├── hud_inspector_panel.gd # EvoHudInspectorPanel: 3-tab village (Salubrity, Walled Union) / traits / habitat (Land Use, Ruins) inspector │ │ ├── hud_hotbar_dock.gd # EvoHudHotbarDock: 10-slot hotbar, radius toggle, valley legend duel counter │ │ └── hud_banners_toasts.gd # EvoHudBannersToasts: macro banner, chronicle toasts, model pull modal │ └── world/ -│ ├── world_renderer.gd # EvoWorldRenderer: 2.5D hex column extrusion, dirty-state cache, lenses -│ ├── camera_controller.gd# EvoCameraController: orbit yaw, tilt pitch, cursor-pivoted zoom & pan -│ ├── pixel_art_gen.gd # EvoPixelArtGen: 99-sprite procedural registry & dynamic villager recoloring +│ ├── world_renderer.gd # EvoWorldRenderer: 2.5D hex column extrusion, GPU shader backdrop, mipmap filtering, dirty-state cache +│ ├── camera_controller.gd# EvoCameraController: locked horizontal yaw (0.0°), tilt pitch (32°–68°), cursor-pivoted zoom & pan +│ ├── pixel_art_gen.gd # EvoPixelArtGen: v3_illustrated_hdpi procedural asset registry & dynamic villager recoloring │ ├── render/ -│ │ ├── prop_flora_renderer.gd # EvoPropFloraRenderer: plazas, 5 village tiers, 3-tier walls, twin pennants -│ │ ├── road_network_renderer.gd # EvoRoadNetworkRenderer: 11 road & geopolitical flow archetypes, bridges & decay -│ │ └── world_overlay_renderer.gd# EvoWorldOverlayRenderer: hex strategy rings, fortified billboards & side pills +│ │ ├── prop_flora_renderer.gd # EvoPropFloraRenderer: 4 urban tiers (I–IV), rural hinterlands (crops, pasture, ruins), twin pennants +│ │ ├── road_network_renderer.gd # EvoRoadNetworkRenderer: hex-edge perimeter walls, walled union internal wall culling, rural farm tracks & 11 road archetypes +│ │ └── world_overlay_renderer.gd# EvoWorldOverlayRenderer: hex strategy rings, rural territory crests, fortified billboards & salubrity dots +│ ├── shaders/ +│ │ ├── ocean_waves.gdshader # Animated multi-ring coastal foam, shallow turquoise shelves & deep ocean wave shimmer +│ │ ├── hex_terrain.gdshader # Per-hex biome saturation, elevation lighting & subtle macro-noise modulation +│ │ └── prop_sway_glow.gdshader # Wind-driven foliage sway & warm hearth/lantern luminescence │ └── sprites/ -│ ├── pixel_canvas.gd # EvoPixelCanvas: RGBA8 raster primitives & soft 1px outlining -│ ├── terrain_sprites.gd # EvoTerrainSprites: 7 hex biome tiles (224×192) & 17 flora sprites -│ └── village_ui_sprites.gd # EvoVillageUiSprites: 44 village/action/geopolitical sprites & 31 UI icons +│ ├── pixel_canvas.gd # EvoPixelCanvas: RGBA8 raster primitives & warm dark-brown (#23150c) ink outlining +│ ├── terrain_sprites.gd # EvoTerrainSprites: 7 hex biome tiles (256×224) & 17 high-DPI flora sprites (128×128–160×160) +│ └── village_ui_sprites.gd # EvoVillageUiSprites: high-DPI village/rural/wall/geopolitical sprites (128×128–160×160), 48×48 villagers & 64×64 UI icons └── tests/ ├── test_runner.gd # Headless unit, biophysical, integration & UI test suite (16 suites) └── test_visual_capture.gd # Automated screenshot generator for gh-pages/assets/img/ @@ -110,28 +114,37 @@ flowchart TD ## Core Data Models, Strategy Inheritance & User Catalog Persistence -### 1. Evolutionary Strategy, Fortification, Synoecism & Geopolitical Primitives (`EvoTypes`) -The 1v1 strategy duel, multi-tier defense system, vendetta memory, and city unification (synoecism) are modeled directly in `src/core/types.gd`: +### 1. Evolutionary Strategy, 4-Tier Urbanism, Rural Hinterlands, Salubrity & Dual Synoecism Primitives (`EvoTypes`) +The 1v1 strategy duel, single-deme-per-hex spatial exclusivity, 4-tier urban hierarchy, rural hinterland ownership, urban salubrity, and dual synoecism (Asymmetric Category Absorption vs. Continuous Multi-Hex Walled Unions) are modeled directly in `src/core/types.gd`: * **`EvoTypes.StrategyDef`**: * `id: String` — Unique identifier (e.g., `"strat-cooperation"`, `"strat-war"`, or `"strat-custom-*"`). * `name: String` & `description: String` — Localized name and philosophy for `"base"` strategies (`EvoI18n.strategy_text`) or user-defined strings for `"user"` strategies. - * `icon: String` (`"🛡️"` default) & `color: String` (`"#10B981"` default) — Visual emblem and hex color swatch rendered on HUD selectors, hex strategy rings, village billboards, plaza rings, and villager tunics. + * `icon: String` (`"🛡️"` default) & `color: String` (`"#10B981"` default) — Visual emblem and hex color swatch rendered on HUD selectors, hex strategy rings, rural territory crests, village billboards, plaza rings, and villager tunics. * `origin: String` — `"base"` for the 5 built-in archetypes or `"user"` / `"custom"` for strategies crafted in `EvoLabModal`. * `biological_trait_ids: Array[String]` & `cultural_trait_ids: Array[String]` — Bundled traits applied to founding demes and lineages. +* **`EvoTypes.HexCell`**: + * `deme_ids: Array[String]` — Enforces **1-Deme-Per-Hex Exclusivity (`cell.deme_ids.size() <= 1`)**. Every habitable hex supports at most one settlement. + * `land_use: String` (`""` | `"crops"` | `"pasture"`) & `owner_deme_id: String` — Tracks whether an uninhabited neighbor hex is cultivated as an agricultural field (`"crops"`) or livestock pasture (`"pasture"`) by an adjacent Tier II–IV settlement (`owner_deme_id`). + * `has_ruins: bool` — Marks scorched rural hinterlands pillaged by enemy raiders (`has_ruins = true`), zeroing their harvest yield until cleared and re-cultivated. * **`EvoTypes.WorldState`**: * `strategies_catalog: Dictionary` (`String -> StrategyDef`) — Holds the 5 base strategies from `EvoCatalog.create_base_strategies_catalog` merged with user-created strategies from `~/.config/Evolution/user_catalog.json`. * `strategy_a_id: String` (default `"strat-cooperation"`) & `strategy_b_id: String` (default `"strat-war"`) — The two competing strategies assigned to Founder A and Founder B in the active 1v1 matchup. * `active_pacts: Dictionary` (`String -> int`) — Maps canonical pair keys (`EvoTypes.make_pair_key(deme_a_id, deme_b_id)` = `"idA__idB"`) to diplomatic state: - * **`>= 2` (`EvoTypes.is_pact_active == true`)**: Formal active Alliance Pact (eligible for mutual caloric bonus, distress aid, shared defense, allied wall co-financing, and at `>= 3` / `>= 4` City Unification / Synoecism). + * **`>= 2` (`EvoTypes.is_pact_active == true`)**: Formal active Alliance Pact (eligible for mutual caloric bonus, distress aid, shared defense, allied wall co-financing, and at `>= 3` Dual Synoecism). * **`1`**: Cooperative progress toward a pact, or a 1-turn temporary truce established via **Tribute Appeasement (`tribute`)**. * **`< 0` (`EvoTypes.is_vendetta_active == true`)**: Active multi-turn **Vendetta / Grudge** (`-4` turns after betrayal, `-3` after conquest, `-2` after raid or repelled war; `EvoTypes.get_vendetta_turns` returns `abs(turns)`). Increments by `+1` each turn toward `0` and blocks pact formation while active. * `recent_extinction_hexes: Array[String]` — Tracks hex coordinates where demes collapsed during the current generation. * **`EvoTypes.Deme` & `EvoTypes.LineageNode`**: * Both `Deme` and `LineageNode` carry `strategy_id: String` (serialized in JSON as both `"strategyId"` and `"strategy_id"` for full schema compatibility). - * `Deme.defense_tier: int` (`0..3`) — Multi-tier fortification level (`0`: Unfortified, `1`: Palisade & Watchtower `+45%`, `2`: Stone Wall & Fort `+95%`, `3`: Castle / Citadel `+155%` & raid-immune). - * `Deme.merged_count: int` & `Deme.secondary_color: String` — Tracks how many allied clans have united into this settlement via **Synoecism (`merge`)** and stores the united partner's strategy emblem color for twin-pennant plaza rendering. - * `Deme.get_village_tier()` returns architectural tier `1..5` (`5` = Unified Metropolis when `merged_count >= 1` and (`population >= 220` or `defense_tier >= 3`), `4` = Citadel when `population >= 400` or `defense_tier >= 3`, `3` = Village when `population >= 150` or `defense_tier == 2`, `2` = Hamlet when `population >= 50` or `defense_tier == 1`, `1` = Camp otherwise). + * `Deme.defense_tier: int` (`0..3`) — Multi-tier fortification level (`0`: Unfortified, `1`: Palisade & Watchtower `+45%`, `2`: Stone Wall & Fort `+95%`, `3`: Castle / Citadel `+155%` & raid-immune), rendered as continuous hex-edge perimeter fortifications (`palisade_wall` / `stone_wall`) with corner towers (`watchtower` / `keep_tower`). + * `Deme.salubrity: float` (`[0.05, 1.0]`, default `1.0`) — Urban sanitation and public health index. Degrades with urban crowding and multi-hex union density, recovers via K-strategy (`+0.14`), Communal Conservation (`+0.20`), and Cooperate & Store (`+0.15`), dampens positive demographic growth when `< 0.65`, and triggers `"epidemic"` outbreaks (`22%` mortality) when `< 0.35` with `population >= 130`. + * `Deme.merged_count: int`, `Deme.secondary_color: String` & `Deme.walled_union_id: String` — Tracks **Asymmetric Category Absorption** (`merged_count`, `secondary_color`) and **Continuous Multi-Hex Walled Unions** (`walled_union_id`, shared by adjacent same-tier `II..IV` allied cities that pool combat defense and cull their shared internal wall edge). + * `Deme.get_village_tier()` returns the 4-tier urban category `1..4` (`get_village_roman()` returns `"I".."IV"`): + * **`4` (`IV · Metropolis`)**: `population >= 380`, or `population >= 280` with `defense_tier >= 2`, `merged_count >= 1`, or `walled_union_id != ""` (claims up to `4` rural hexes; `0.30x` intra-hex wild foraging). + * **`3` (`III · Town`)**: `population >= 240`, or `population >= 170` with `defense_tier >= 1`, `merged_count >= 1`, or `walled_union_id != ""` (claims up to `2` rural hexes; `0.55x` intra-hex wild foraging). + * **`2` (`II · Village`)**: `population >= 120` or `defense_tier == 1` (claims up to `1` rural hex; `1.00x` intra-hex wild foraging). + * **`1` (`I · Camp`)**: `population < 120` and `defense_tier == 0` (`0` rural hexes; `1.00x` intra-hex wild foraging). * `Deme.get_vital_status()` returns the unified 5-state classification (`"thriving"`, `"stable"`, `"declining"`, `"starving"`, `"extinct"`). * `LineageNode` also stores `biological_trait_ids: Array[String]` and `cultural_trait_ids: Array[String]` alongside `acquired_trait_ids`. @@ -142,8 +155,9 @@ To guarantee that every village on the map always retains its founding strategy Sets `deme.strategy_id = strategy.id`, `deme.color = strategy.color`, `deme.biological_trait_ids`, and (if `deme_has_cultural_capacity` is satisfied) `deme.cultural_trait_ids`, and mirrors `strategy_id`, `color`, and traits onto `state.lineage_tree[deme.lineage_id]`. 2. **Colonist Migration & Warband Conquest (`EvoDemographyMigration.run_demography_and_migration_phase`)**: When a parent deme undergoes population fission (`-m`) or conquers a rival hex (`-w`), the newly founded settlement explicitly inherits `daughter.strategy_id = deme.strategy_id`, `daughter.lineage_id = deme.lineage_id`, and `daughter.color = deme.color`. -3. **City Unification / Synoecism (`EvoDemographyMigration._merge_deme_into`)**: - When two allied settlements unite, the host metropolis preserves its primary `deme.strategy_id` and `deme.color` while recording the partner's color in `deme.secondary_color` and incrementing `deme.merged_count`. +3. **Dual Synoecism (`EvoDemographyMigration.run_synoecism_phase`)**: + * **Asymmetric Category Absorption (`tier_a != tier_b`)**: The higher-tier host city preserves its primary `deme.strategy_id` and `deme.color`, records the absorbed partner's color in `deme.secondary_color`, increments `deme.merged_count`, and converts the vacated hex into a `"crops"` or `"pasture"` hinterland owned by the host. + * **Continuous Multi-Hex Walled Union (`tier_a == tier_b >= 2`)**: Both same-tier cities remain on their own hexes with their respective `strategy_id`, share a common `walled_union_id`, cross-adopt `secondary_color`, and upgrade to at least `defense_tier = 1`. 4. **Allopatric Speciation (`EvoMilestonePhase.run_milestone_phase`)**: When an isolated colony (`isolation_turns >= 10`) speciates into a new child `LineageNode` (`new_node`), the new lineage node inherits **`new_node.strategy_id = deme.strategy_id`** while receiving a fresh species color from `SPECIATION_PALETTE`, and the speciated deme keeps its `deme.strategy_id`. @@ -152,46 +166,53 @@ To guarantee that every village on the map always retains its founding strategy | File Path | Managed Methods | Stored JSON Payload | | :--- | :--- | :--- | -| `~/.config/Evolution/settings.json` | `EvoUserSettings.load_settings()`
`EvoUserSettings.save_settings()` | `{ "primary_model": "d3:4b", "secondary_model": "tev1:4b", "context_length": 8192 }` | +| `~/.config/Evolution/settings.json` | `EvoUserSettings.load_settings()`
`EvoUserSettings.save_settings()` | `{ "primary_model": "d3:4b", "secondary_model": "tev1:4b", "context_length": 16384 }` | | `~/.config/Evolution/user_catalog.json` | `EvoUserSettings.save_user_catalog(state)`
`EvoUserSettings.load_user_catalog_into(state)` | `{ "strategies": [...], "traits": [...], "decisions": [...] }` — Automatically serializes all entries in `state.strategies_catalog`, `state.traits_catalog`, and `state.decisions_catalog` where `origin in ["user", "custom"]`, and reloads them into every new or reset `WorldState`. | --- -## 2.5D Isometric Rendering & Procedural Pixel-Art Pipeline +## 2.5D Illustrated Rendering, GPU Shaders & High-DPI Sprite Pipeline -### 1. 2.5D Projection & Painter's Algorithm Depth Sorting -`EvoWorldRenderer` and `EvoCameraController` project 2D pointy-top axial coordinates $(x, y)$ and vertical elevation $z_{\text{height}} \in [0, 96\text{ px}]$ into 2.5D screen space using camera yaw $\psi \in [0^\circ, 360^\circ)$ and pitch $\theta \in [28^\circ, 75^\circ]$: +### 1. Locked-Yaw 2.5D Projection & Painter's Algorithm Depth Sorting +`EvoWorldRenderer` and `EvoCameraController` (`HEX_SIZE = 240.0`) project 2D pointy-top axial coordinates $(x, y)$ and vertical elevation $z_{\text{height}} \in [0, 96\text{ px}]$ into 2.5D screen space with horizontal camera yaw locked at $\psi = 0^\circ$ (`yaw_deg = 0.0`) so custom isometric roof/wall shading never inverts, and tilt pitch $\theta \in [32^\circ, 68^\circ]$ (default $48^\circ$): -$$\mathbf{p}_{\text{screen}} = \begin{bmatrix} \cos\psi & -\sin\psi \\ \sin\psi \sin\theta & \cos\psi \sin\theta \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} + \begin{bmatrix} 0 \\ -z_{\text{height}} \cdot 1.18 \cos\theta \end{bmatrix}$$ +$$\mathbf{p}_{\text{screen}} = \begin{bmatrix} x \\ y \sin\theta - z_{\text{height}} \cdot 1.18 \cos\theta \end{bmatrix}$$ -* **10-Tier Elevation Quantization**: Terrestrial hexes quantize `cell.altitude` into $10$ discrete vertical tiers ($\text{tier} = \text{clamp}(\lceil 10 \cdot \text{altitude} \rceil, 1, 10)$), extruding 3D cliff walls with geological strata lines, deterministic rock cracks, and hanging vines. -* **Depth Sorting (`get_depth`)**: Hex columns and Y-sorted props render back-to-front via: - $$\text{depth}(x, y, z) = (x \sin\psi + y \cos\psi) + 0.02 \cdot z$$ -* **12-Tuple Dirty-State Column Cache (`_column_state_cache`)**: Each hex column caches `[yaw, pitch, eff_biome, top_z, active_lens, k_band, r_band, is_selected, is_brushed, inspect_brush, coast_dirs, diff_biome_dirs]`. Camera panning and zooming transform the parent `_world_layer` directly on the GPU without rebuilding column geometry. +* **10-Tier Elevation Quantization**: Terrestrial hexes quantize `cell.altitude` into $10$ discrete vertical tiers ($\text{tier} = \text{clamp}(\lceil 10 \cdot \text{altitude} \rceil, 1, 10)$), extruding 2.5D cliff skirts with geological strata lines, deterministic rock cracks, and hanging vines. +* **GPU Shader Pipeline (`src/world/shaders/`)**: + * **`ocean_waves.gdshader`**: Renders the animated ocean backdrop with multi-ring coastal foam, turquoise shallow shelves, and specular wave shimmer. + * **`hex_terrain.gdshader`**: Applies per-hex biome saturation, elevation lighting, and subtle macro-noise modulation across hex tiles. + * **`prop_sway_glow.gdshader`**: Drives gentle wind sway on trees/crops and warm hearth/lantern glow on village props. +* **Depth Sorting (`get_depth`) & Mipmap Filtering**: All sprites render with `CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS` for crisp scaling across zoom levels (`0.18`–`2.80`), sorted back-to-front via $\text{depth}(x, y, z) = y \cos\psi + 0.02 \cdot z$. +* **12-Tuple Dirty-State Column Cache (`_column_state_cache`)**: Each hex column caches `[yaw, pitch, eff_biome, top_z, active_lens, k_band, r_band, is_selected, is_brushed, inspect_brush, coast_dirs, diff_biome_dirs]`. Camera panning and zooming transform `_world_layer` directly on the GPU without rebuilding column geometry. -### 2. Procedural Pixel-Art Generator (`EvoPixelArtGen`) -At startup, `EvoPixelArtGen.ensure_all_assets()` procedurally rasterizes and caches **99 crisp pixel-art sprites** using `EvoPixelCanvas`: +### 2. High-DPI Procedural Illustrated Sprite Generator (`EvoPixelArtGen`) +At startup, `EvoPixelArtGen.ensure_all_assets()` (`ASSET_SCHEMA_VERSION = "v3_illustrated_hdpi"`) procedurally rasterizes and caches **high-DPI illustrated sprites with mipmaps** using `EvoPixelCanvas` and warm dark-brown `#23150c` ink outlines: -* **7 Hex Biome Tiles (`224×192` px)**: Hand-dithered textures for `tropical_forest`, `savanna`, `arid_steppe`, `tundra`, `high_mountain`, `coastal_shallows`, and `ocean`. -* **17 Flora & Ecological Degradation Props**: Healthy swaying trees/bushes (`tree_tropical`, `tree_savanna`, `tree_tundra`, `bush_berry`, `wildflowers`, `coastal_reeds`) that automatically transition to `tree_stump`, `soil_crack`, and `dry_scrub` when hex ecological health $K / K_{\max}$ drops below $85\%$ and $60\%$. -* **44 Village, Fortification, Action, Geopolitical & Road Sprites**: - * **5 Settlement Tiers**: `tier1_tent` + flickering `tier1_campfire`, `tier2_cottage` + `tier2_barn`, `tier3_townhall` + `tier3_house`, `tier4_citadel` + `tier4_tower` + `tier4_windmill`, and **`tier5_metropolis`** + **`prop_unity_monument`** for unified metropolises. - * **Multi-Tier Fortification & Siege Props**: **`prop_defense_palisade`** (Lv.1 timber watchtower & stakes), **`prop_defense_wall`** (Lv.2 crenellated stone wall), **`prop_defense_fortress`** (Lv.3 twin-turret castle keep), **`prop_tribute_chest`** (gold-trimmed tribute coffer), and **`prop_siege_engine`** (timber catapult/ram). - * **Dominant Decision & Geopolitical Action Structures (`act_*` & `prop_*`)**: Each village constructs visible $32 \times 32$ structures reflecting its current `last_dominant_decision_id` (`act_forage`, `act_overexploit`, `act_migrate`, `act_cooperate`, `act_exchange`, `act_dispute`, `act_innovate`, `act_cultivate`) and active geopolitical events (`act_war`, `act_raid`, `act_pact`, `act_betrayal`, `prop_war_banner`, `prop_raid_loot`, `prop_pact_totem`, `prop_betrayal_ruin`, `effect_battle_sparks`). - * **Lineage-Colored Villagers**: `get_villager_texture(pose, tunic_color)` dynamically recolors villager tunics (`villager_idle`, `villager_walk1`, `villager_walk2`) to match each lineage's phylogenetic color. -* **31 UI & Geopolitical Icons**: Includes hotbar tools, milestone emblems, and geopolitical icons (`icon_war`, `icon_raid`, `icon_pact`, `icon_betrayal`, **`icon_defense`**, **`icon_merge`**, **`icon_tribute`**, **`icon_siege`**, `icon_coop_link`, `icon_conflict_swords`, `icon_broken_link`). +* **7 High-DPI Hex Biome Tiles (`256×224` px)**: Hand-illustrated textures for `tropical_forest`, `savanna`, `arid_steppe`, `tundra`, `high_mountain`, `coastal_shallows`, and `ocean`. +* **17 High-DPI Flora & Ecological Degradation Props (`128×128`–`160×160` px)**: Healthy swaying trees/bushes (`tree_tropical`, `tree_savanna`, `tree_tundra`, `bush_berry`, `wildflowers`, `coastal_reeds`) that transition to `tree_stump`, `soil_crack`, and `dry_scrub` when hex ecological health $K / K_{\max}$ drops below $85\%$ and $60\%$. +* **High-DPI Settlement, Rural Hinterland, Perimeter Wall & Geopolitical Sprites (`128×128`–`160×160` px)**: + * **4 Urban Settlement Tiers (`I..IV`)**: `tier1_tent` + flickering `tier1_campfire` (`I · Camp`), `tier2_cottage` + `tier2_barn` (`II · Village`), `tier3_townhall` + `tier3_house` + `tier4_windmill` (`III · Town`), and `tier5_metropolis` + `tier4_citadel` + `prop_unity_monument` (`IV · Metropolis`). + * **Rural Hinterland & Ruin Sprites**: `crop_plot` + `tier4_windmill` + `scarecrow` + `hay_bale` for `"crops"` hexes, `pasture_corral` + `tier2_barn` + `grazing_sheep` + `grazing_cattle` + `water_trough` for `"pasture"` hexes, `covered_wagon` on rural farm tracks, and `prop_betrayal_ruin` + `soil_crack` + `barricade_spikes` for pillaged hinterlands (`has_ruins = true`). + * **Hex-Edge Perimeter Fortifications & Siege Props**: `palisade_wall` + `watchtower` (`defense_tier == 1`), `stone_wall` + `keep_tower` + `gatehouse` (`defense_tier >= 2`), `prop_tribute_chest`, and `prop_siege_engine`. + * **Dominant Decision & Geopolitical Action Structures (`act_*` & `prop_*`)**: Visible structures reflecting `last_dominant_decision_id` (`act_forage`, `act_overexploit`, `act_migrate`, `act_cooperate`, `act_exchange`, `act_dispute`, `act_innovate`, `act_cultivate`) and active geopolitical events (`act_war`, `act_raid`, `act_pact`, `act_betrayal`, `prop_war_banner`, `prop_raid_loot`, `prop_pact_totem`, `effect_battle_sparks`). + * **Lineage-Colored Villagers (`48×48` px)**: `get_villager_texture(pose, tunic_color)` dynamically recolors villager tunics (`villager_idle`, `villager_walk1`, `villager_walk2`) to match each lineage's phylogenetic color. +* **High-DPI UI & Geopolitical Icons (`64×64` px)**: Includes hotbar tools, milestone emblems, and geopolitical icons (`icon_war`, `icon_raid`, `icon_pact`, `icon_betrayal`, `icon_defense`, `icon_merge`, `icon_tribute`, `icon_siege`, `icon_coop_link`, `icon_conflict_swords`, `icon_broken_link`). -### 3. Organic Road & Geopolitical Flow Lifecycle (`EvoRoadNetworkRenderer`) -Inter-village relationships are rendered on `_network_layer` and `_anim_layer` across **11 visual road and geopolitical archetypes**: +### 3. Perimeter Walls, Rural Farm Tracks & Geopolitical Road Lifecycle (`EvoRoadNetworkRenderer`) +`EvoRoadNetworkRenderer` renders hex-edge perimeter fortifications, continuous multi-hex walled unions, rural farm tracks, and **11 inter-village road and geopolitical archetypes**: + +* **Hex-Edge Perimeter Walls & Walled Union Internal Wall Culling (`draw_settlement_perimeter_walls`)**: Draws a 6-sided perimeter wall (`palisade_wall` for Lv.1, crenellated `stone_wall` for Lv.2–3) around every fortified settlement (`defense_tier >= 1`) with a southern `gatehouse` and corner towers (`watchtower` / `keep_tower`). When two adjacent fortified cities share a non-empty `walled_union_id`, their shared internal hex edge (`dir_idx`) is **culled** so the two hexes form a single continuous multi-hex walled enclosure linked by a golden union boulevard. +* **Rural Hinterland Farm Tracks (`draw_rural_farm_track`)**: Connects each `"crops"` or `"pasture"` hex to its owning city (`owner_deme_id`) via a wheel-rutted earthen farm track with animated `covered_wagon`s (or charred ruts when `has_ruins == true`). | Road / Flow Archetype | Trigger Source | Visual Representation & Animated Traffic | | :--- | :--- | :--- | | **Alliance Pact Highway (`pact`)** | Formal active pact (`active_pacts >= 2`) or `"pact"` flow | Emerald-trimmed stone highway (`#10b981`) with `prop_pact_totem` pillars, glowing `road_lantern` posts, `icon_pact` badge, and twin bi-directional `caravan_cart` + allied travelers. | -| **City Unification Avenue (`merge`)** | Active `"merge"` flow (adjacent or cohabiting Synoecism) | Golden-bordered stone avenue (`#fbbf24`) with `prop_unity_monument` obelisk, `road_lantern` posts, `icon_merge` badge, and celebratory unification caravan traffic. | +| **City Unification / Walled Union Boulevard (`merge`)** | Active `"merge"` flow or shared `walled_union_id` | Golden-bordered stone avenue (`#fbbf24`) with `prop_unity_monument` obelisk, `road_lantern` posts, `icon_merge` badge, and celebratory unification caravan traffic across the open internal union boundary. | | **Tribute Caravan Route (`tribute`)** | Active `"tribute"` flow (18% reserve appeasement) | Amber-gold tribute road (`#f59e0b`) with `prop_tribute_chest` coffers, `icon_tribute` badge, and an animated `caravan_cart` + escort delivering tribute to the raider. | | **Fortified Siege Corridor (`siege`)** | Active `"siege"` flow (repelled invasion against `defense_tier >= 1`) | Scorched crimson siege corridor (`#dc2626`) with `prop_siege_engine` catapults, `barricade_spikes`, `effect_battle_sparks`, pulsing `icon_siege` badge, and assaulting warriors. | | **War of Conquest Corridor (`war`)** | Active `"war"` flow (territorial invasion or unfortified repelled assault) | Scorched crimson invasion corridor (`#ef4444`) with `prop_war_banner` standards, `barricade_spikes`, `effect_battle_sparks`, pulsing `icon_war` badge, and 3 marching warriors charging toward the target hex. | -| **Plunder Raid Trail (`raid`)** | Active `"raid"` flow (cross-border caloric plunder) | Jagged orange raid trail (`#f97316`) with `prop_raid_loot` chests at the victim plaza, `effect_battle_sparks`, `icon_raid` badge, and 2 fast-moving raiders + loot cart returning to the attacker. | +| **Plunder Raid Trail (`raid`)** | Active `"raid"` flow (cross-border caloric plunder or rural pillaging) | Jagged orange raid trail (`#f97316`) with `prop_raid_loot` chests, `effect_battle_sparks`, `icon_raid` badge, and 2 fast-moving raiders + loot cart returning to the attacker. | | **Betrayal Severed Road (`betrayal`)** | Active `"betrayal"` flow (broken formal pact & ambush) | Shattered crimson-purple road (`#dc2626` / `#a855f7`) split by `prop_betrayal_ruin` and `soil_crack`, `effect_battle_sparks`, `icon_betrayal` badge, and a fleeing plunder cart. | | **Collaboration Route (`collab`)** | Active `"exchange"` or `"cooperate"` flow on flat terrain | 26-segment quadratic Bézier cobblestone road with border stones, glowing `road_lantern`, green `icon_coop_link` badge, and an animated `caravan_cart` + 2 walking travelers. | | **Wooden Arch Bridge (`bridge`)** | Friendly route crossing `coastal_shallows`, submerged hexes, or cliff $\Delta\text{alt} \ge 0.20$ | 20-step arched timber deck over 3 support pilings with water ripples, handrails, and caravan traffic. | @@ -202,7 +223,7 @@ Inter-village relationships are rendered on `_network_layer` and `_anim_layer` a ## Automated Verification & Testing Suite (`tests/test_runner.gd`) -The repository includes a comprehensive headless test runner (`res://tests/test_runner.gd`) that executes **16 test suites** verifying every mathematical formula, turn phase, geopolitical interaction, fortification/synoecism mechanic, and UI component: +The repository includes a comprehensive headless test runner (`res://tests/test_runner.gd`) that executes **16 test suites** verifying every mathematical formula, turn phase, 1-deme-per-hex spatial invariant, rural hinterland/salubrity/dual-synoecism mechanic, and UI component: ```bash # Run the full automated test suite headlessly @@ -217,20 +238,20 @@ done | Suite # | Test Method | Verification Scope | | :---: | :--- | :--- | | **1** | `_test_thermodynamics` | Verifies `BASE_BUDGET = 0.60`, `evaluate_thermodynamics`, required cost $0.5 \cdot (A^+ - 0.60)$, auto-balancing into `metabolic_cost` / `ecological_impact`, and `free_sandbox_mode` bypass. | -| **2** | `_test_catalog_and_presets` | Verifies cell counts (`91`, `91`, `61`), balanced 1v1 starting demes (`2` per preset), base catalog sizes (`5` strategies, `11` traits, `7` decisions), `deme_has_cultural_capacity`, and `inject_trait_into_deme` mutual exclusion (`r` vs `K`). | +| **2** | `_test_catalog_and_presets` | Verifies `169`-hex cell counts (`map_radius = 7`) across all 3 presets, balanced 1v1 starting demes (`2` per preset), base catalog sizes (`5` strategies, `11` traits, `7` decisions), `deme_has_cultural_capacity`, and `inject_trait_into_deme` mutual exclusion (`r` vs `K`). | | **3** | `_test_strategies_duel_and_persistence` | Verifies the 5 base strategies, `compute_strategy_vector`, `apply_strategy_to_deme`, `strategy_id` inheritance across migration and speciation, `EvoLabModal` Strategy Builder UI, and `user_catalog.json` persistence. | | **4** | `_test_decision_model_and_normalization` | Verifies `normalize_decision_probabilities` ($\sum P(d) = 1.0$), $75\%/25\%$ environmental & geopolitical weight blending, and cached Ollama System 1 model evaluation. | -| **5** | `_test_hawk_dove_and_symbiosis` | Verifies Hawk-Hawk $-15\%$ population loss (floor `10`) and `"dispute"` flow generation, plus Symbiosis ($1.20\times$ intra-hex, up to $1.35\times$ inter-hex complementary trade) and horizontal cultural transmission of `cul-fire-processing`. | +| **5** | `_test_hawk_dove_and_symbiosis` | Verifies inter-hex Hawk-Hawk $-15\%$ population loss (floor `10`) and `"dispute"` flow generation, plus inter-hex Symbiosis (up to $1.35\times$ complementary trade) and horizontal cultural transmission of `cul-fire-processing`. | | **6** | `_test_tragedy_of_the_commons` | Runs `preset-finite-island` and verifies overexploitation degrades hex $K$ below $0.60 \cdot K_{\max}$ and logs `"tragedy_of_commons"`. | | **7** | `_test_baldwin_innovation_and_speciation` | Verifies Baldwin assimilation at `counter >= 8` (with decision-proportional vector synthesis), Cultural Innovation at `innovation_counter >= 1.0`, and Allopatric Speciation at `isolation_turns >= 10`. | -| **8** | `_test_kin_merge_fission_and_macro_outcome` | Verifies compatible same-lineage kin merging (Jaccard $< 0.25$), population fission migration (`-m`), and macro-outcome detection (`total_extinction`, `single_survivor`, `ecological_equilibrium`). | +| **8** | `_test_kin_merge_fission_and_macro_outcome` | Verifies 1-deme-per-hex spatial exclusivity (`cell.deme_ids.size() <= 1`), population fission migration (`-m`), and macro-outcome detection (`total_extinction`, `single_survivor`, `ecological_equilibrium`). | | **9** | `_test_interventions` | Verifies `apply_set_biome` (`⚡10`), `apply_adjust_altitude` (`⚡15`), `apply_adjust_carrying_capacity` (`⚡10`), and `apply_adjust_sea_level` (`⚡25`). | -| **10** | `_test_serialization_roundtrip` | Verifies `WorldState.to_dict()` $\to$ `JSON.stringify` $\to$ `WorldState.from_dict()` preserves generation, cells, demes (`defense_tier`, `merged_count`, `secondary_color`), `strategies_catalog`, `strategy_a_id`, `strategy_b_id`, `active_pacts`, and `user_catalog.json` persistence. | -| **11** | `_test_25_generations_all_presets` | Advances all 3 presets for 25 generations and asserts $\sum P(d) = 1.0$ for every surviving deme. | -| **12** | `_test_main_scene_and_ui_integration` | Instantiates `res://scenes/main.tscn`, verifies all 99 procedural sprites, dual System 1 model routing (`d3:4b` / `tev1:4b`), settings persistence (`num_ctx = 16384`), missing models modal, preset/language/lens switching, modals, road aging (`disused_connections`), and inspector village cycling. | +| **10** | `_test_serialization_roundtrip` | Verifies `WorldState.to_dict()` $\to$ `JSON.stringify` $\to$ `WorldState.from_dict()` preserves generation, cells (`land_use`, `owner_deme_id`, `has_ruins`), demes (`defense_tier`, `merged_count`, `secondary_color`, `salubrity`, `walled_union_id`), `strategies_catalog`, `strategy_a_id`, `strategy_b_id`, `active_pacts`, and `user_catalog.json` persistence. | +| **11** | `_test_25_generations_all_presets` | Advances all 3 `169`-hex presets for 25 generations and asserts $\sum P(d) = 1.0$ and `cell.deme_ids.size() <= 1` for every cell and surviving deme. | +| **12** | `_test_main_scene_and_ui_integration` | Instantiates `res://scenes/main.tscn`, verifies all procedural `v3_illustrated_hdpi` sprites, dual System 1 model routing (`d3:4b` / `tev1:4b`), settings persistence (`num_ctx = 16384`), missing models modal, preset/language/lens switching, modals, road aging (`disused_connections`), and inspector village cycling. | | **13** | `_test_nonblocking_async_decision_and_smooth_panning` | Asserts `check_ollama_status(true)` completes in $< 15\text{ ms}$ on the main thread and verifies late-game 16-deme async evaluation and camera panning. | -| **14** | `_test_emergent_geopolitics_and_visual_artifacts` | Verifies Alliance Pacts (`>= 2` turns, $+15\%$ bonus, distress aid, shared defense), Opportunistic Betrayals, Plunder Raids, Territorial Wars of Conquest (`-w`), geopolitical sprites/roads/overlays, Inspector Pacts readout, and Notebook geopolitical filters. | -| **15** | `_test_social_memory_defense_tribute_and_synoecism` | Verifies 6-generation cognitive memory digest, rich neighbor relational tags (`vendetta`, `ally`, `rival:martial`), Multi-Tier Defense upgrades (`defense_tier: 1..3`) & Castle raid immunity, Tribute Appeasement (`18%` reserves, zero casualties, truce), Fortified Sieges (`siege` flow & wall repair), Complementary Inter-Biome/Cross-Cultural Trade (`1.35x`), and City Unification / Synoecism (`merge`, Tier 5 Metropolis, LLM request reduction). | +| **14** | `_test_emergent_geopolitics_and_visual_artifacts` | Verifies Alliance Pacts (`>= 2` turns, $+15\%$ bonus, distress aid, shared defense), Opportunistic Betrayals, Plunder Raids & Rural Hinterland Pillaging (`has_ruins = true`), Territorial Wars of Conquest (defender retreat or in-place absorption), geopolitical sprites/roads/overlays, Inspector Pacts readout, and Notebook geopolitical filters. | +| **15** | `_test_social_memory_defense_tribute_and_synoecism` | Verifies 6-generation cognitive memory digest, rich neighbor relational tags (`vendetta`, `ally`, `rival:martial`), Multi-Tier Defense upgrades (`defense_tier: 1..3`) & Castle raid immunity, Tribute Appeasement (`18%` reserves, zero casualties, truce), Fortified Sieges (`siege` flow & wall repair), Complementary Inter-Biome/Cross-Cultural Trade (`1.35x`), Rural Hinterland cultivation (`crops` / `pasture`), Urban Salubrity (`salubrity`) & Epidemics (`"epidemic"`), and Dual Synoecism (Asymmetric Category Absorption vs. Continuous Multi-Hex Walled Unions `walled_union_id`). | | **16** | `_test_bugfix_regressions` | Verifies conserved loot energy transfers in raids/betrayals, unified vital status (`declining`), `altitude <= 0.85` passability & 3-hop peer connectivity, root-lineage hegemony check, and `_cache_epoch` thread safety. | --- diff --git a/gh-pages/assets/img/screenshot_darwin_archipelago.png b/gh-pages/assets/img/screenshot_darwin_archipelago.png index 4d1a9d9..347f795 100644 Binary files a/gh-pages/assets/img/screenshot_darwin_archipelago.png and b/gh-pages/assets/img/screenshot_darwin_archipelago.png differ diff --git a/gh-pages/assets/img/screenshot_finite_island.png b/gh-pages/assets/img/screenshot_finite_island.png index f0db72f..4f7ebae 100644 Binary files a/gh-pages/assets/img/screenshot_finite_island.png and b/gh-pages/assets/img/screenshot_finite_island.png differ diff --git a/gh-pages/assets/img/screenshot_lab_modal.png b/gh-pages/assets/img/screenshot_lab_modal.png index a323a99..9b18034 100644 Binary files a/gh-pages/assets/img/screenshot_lab_modal.png and b/gh-pages/assets/img/screenshot_lab_modal.png differ diff --git a/gh-pages/assets/img/screenshot_late_simulation.png b/gh-pages/assets/img/screenshot_late_simulation.png index ed129ec..3a091ad 100644 Binary files a/gh-pages/assets/img/screenshot_late_simulation.png and b/gh-pages/assets/img/screenshot_late_simulation.png differ diff --git a/gh-pages/assets/img/screenshot_lens_diplomacy.png b/gh-pages/assets/img/screenshot_lens_diplomacy.png new file mode 100644 index 0000000..76a42e5 Binary files /dev/null and b/gh-pages/assets/img/screenshot_lens_diplomacy.png differ diff --git a/gh-pages/assets/img/screenshot_lens_food.png b/gh-pages/assets/img/screenshot_lens_food.png new file mode 100644 index 0000000..e58d3c1 Binary files /dev/null and b/gh-pages/assets/img/screenshot_lens_food.png differ diff --git a/gh-pages/assets/img/screenshot_lens_territory.png b/gh-pages/assets/img/screenshot_lens_territory.png new file mode 100644 index 0000000..8a8c856 Binary files /dev/null and b/gh-pages/assets/img/screenshot_lens_territory.png differ diff --git a/gh-pages/assets/img/screenshot_notebook_modal.png b/gh-pages/assets/img/screenshot_notebook_modal.png index 86fb0ea..f9aaefa 100644 Binary files a/gh-pages/assets/img/screenshot_notebook_modal.png and b/gh-pages/assets/img/screenshot_notebook_modal.png differ diff --git a/gh-pages/assets/img/screenshot_rift_savanna.png b/gh-pages/assets/img/screenshot_rift_savanna.png index 3e95926..2e68b3c 100644 Binary files a/gh-pages/assets/img/screenshot_rift_savanna.png and b/gh-pages/assets/img/screenshot_rift_savanna.png differ diff --git a/gh-pages/assets/img/screenshot_settlement_tiers_hinterlands.png b/gh-pages/assets/img/screenshot_settlement_tiers_hinterlands.png new file mode 100644 index 0000000..8b15d20 Binary files /dev/null and b/gh-pages/assets/img/screenshot_settlement_tiers_hinterlands.png differ diff --git a/gh-pages/assets/img/screenshot_stats_chart.png b/gh-pages/assets/img/screenshot_stats_chart.png new file mode 100644 index 0000000..be7b6e1 Binary files /dev/null and b/gh-pages/assets/img/screenshot_stats_chart.png differ diff --git a/gh-pages/assets/img/screenshot_tree_modal.png b/gh-pages/assets/img/screenshot_tree_modal.png index c079fe1..de1fcfa 100644 Binary files a/gh-pages/assets/img/screenshot_tree_modal.png and b/gh-pages/assets/img/screenshot_tree_modal.png differ diff --git a/gh-pages/cognitive_engine.md b/gh-pages/cognitive_engine.md index 2478b34..7003d58 100644 --- a/gh-pages/cognitive_engine.md +++ b/gh-pages/cognitive_engine.md @@ -1,8 +1,8 @@ -# Cognitive Engine & Multi-Model Ollama AI +# Cognitive Engine & Payoff-Forecast Ollama Agency -**Evolution** features a non-blocking, dual-model **System 1 Cognitive Architecture** (`EvoDecisionModel`, `EvoOllamaClient`, `EvoHeuristicScorer`) that evaluates each subpopulation's ecological, energetic, historical, and geopolitical neighborhood every generation. +**Evolution** features a non-blocking, dual-model **System 1 Payoff-Forecast Agency Architecture** (`EvoDecisionModel`, `EvoOllamaClient`, `EvoActionMenu`, `EvoOutcomeForecast`, `EvoSituationReport`, `EvoActionPolicy`) that evaluates each settlement's concrete, target-bound action options every generation via local **Ollama System 1 decision models** (`d3:4b` and `tev1:4b` at `POST http://127.0.0.1:11434/v1/systemone`). -Every subpopulation's behavioral decisions are evaluated by local **Ollama System 1 decision models** (`d3:4b` and `tev1:4b`) and blended (**$75\%$ neural model probability / $25\%$ environmental & geopolitical pressure** via `blend_with_environmental_weights`) so that inherited strategy traits, historical memory of wars/betrayals/pacts, multi-tier fortifications, and rival borders dynamically shape behavior while keeping LLM strategic choices dominant. To guarantee smooth 60 FPS rendering and high cache hit rates, the cognitive engine combines **deterministic deme-to-model routing**, **coarsened 10-part ecological state signature caching**, **6-generation historical memory & rich neighbor relational tags**, **epoch-guarded thread safety (`_cache_epoch`)**, and **background worker thread batching with 8-signature prefetching**. +Instead of scoring abstract archetypes or blending LLM outputs with heuristic weights, every settlement constructs a feasible menu of **`6–14` concrete actions** where each option's `criterion` is its **quantified expected biophysical and social payoff** (`EvoOutcomeForecast.describe`), paired with a natural-language **Situation Report** (`EvoSituationReport.build`). The model scores the menu directly, and `EvoActionPolicy.sample` samples the settlement's action stochastically (`T = 0.65`) with `WorldState.rng`—with **zero heuristic blending** and **zero signature caching**. --- @@ -10,136 +10,79 @@ Every subpopulation's behavioral decisions are evaluated by local **Ollama Syste ```mermaid flowchart TD - Deme["Deme + Strategy Prior + 6-Gen Memory + 6-Hex Neighborhood"] --> Sig["1. Compute Coarsened 10-Part Signature\nmodel | season | biome | adequacy(0..2) | health(0..2) |\nvitality | competition(p/v/h/d/s) | traits | dominant_hash | neighbors"] - Sig --> CacheCheck{"Signature in\n_live_signature_cache?"} - CacheCheck -- "Cache Hit" --> Blend["Blend 75% Live Ollama P(d)\n+ 25% Env & Geopolitical Weights"] - CacheCheck -- "Cache Miss" --> Queue["Enqueue Signature in\n_pending_jobs_by_sig (up to 8 Prefetch Signatures)"] - Queue --> Worker["Background WorkerThreadPool\nBatch up to 4 Jobs Concurrently\nPOST 127.0.0.1:11434/v1/systemone"] - Worker --> HotSwap["Verify job_epoch == _cache_epoch\nIncrement _completed_revision & Hot-Swap P(d)"] + Deme["Settlement (Deme) + Strategy Doctrine + 8-Event Memory + Bilateral Relations"] --> Menu["1. EvoActionMenu.build(deme, state)\n6–14 Feasible Target-Bound Options\n(forage, intensify, fortify, innovate, settle, trade, aid,\noffer_pact, accept_pact, demand_tribute, pay_tribute,\nraid, betray, conquer, unite, accept_union)"] + Menu --> Forecast["2. EvoOutcomeForecast.describe(deme, opt, state)\nQuantified Expected Payoff per Option\n(Food harvest, trade bonus, win %, loot, casualties, trust/reputation)"] + Forecast --> Report["3. EvoSituationReport.build(deme, state)\nNatural-Language Briefing:\nDoctrine + Need/Reserves + Last Outcome + Memory + Neighbor Relations"] + Report --> Worker["4. Background WorkerThreadPool\nUp to 4 Concurrent HTTP Jobs\nPOST 127.0.0.1:11434/v1/systemone (num_ctx = 16384)"] + Worker --> Policy["5. EvoActionPolicy.sample(menu, raw_scores, rng)\nTemperature-Sharpened Policy (T = 0.65)\nAssigns deme.chosen_action, action_probabilities & action_forecasts"] ``` --- ## Dual-Model System 1 Routing (`d3:4b` & `tev1:4b`) -Rather than evaluating every deme with a single monolithic prompt, **Evolution** distributes subpopulations deterministically across two specialized System 1 decision models configured in `EvoUserSettings` (`~/.config/Evolution/settings.json`): +Subpopulations are distributed deterministically across two specialized System 1 decision models configured in `EvoUserSettings` (`~/.config/Evolution/settings.json`): -* **Primary Decision Model (`DEFAULT_PRIMARY_MODEL = "d3:4b"`)**: Tactical & instinctive ecological evaluator (assigned to Strategy A founders and their descendants). -* **Secondary Decision Model (`DEFAULT_SECONDARY_MODEL = "tev1:4b"`)**: Strategic & rival socio-ecological evaluator (assigned to Strategy B founders and their descendants). -* **Default Context Window (`DEFAULT_CONTEXT_LENGTH = 8192`)**: Configurable directly from the in-game Settings popup (`num_ctx`). +* **Primary Decision Model (`DEFAULT_PRIMARY_MODEL = "d3:4b"`)**: Assigned to Strategy A founders and their daughter settlements. +* **Secondary Decision Model (`DEFAULT_SECONDARY_MODEL = "tev1:4b"`)**: Assigned to Strategy B founders and their daughter settlements. +* **Default Context Window (`DEFAULT_CONTEXT_LENGTH = 16384`)**: Configurable directly from the in-game Settings popup (`num_ctx`). ### Deterministic Deme Assignment (`resolve_deme_decision_model_tag`) 1. **Lineage & Settlement Continuity**: - * Any daughter settlement budded via migration (`-m`) or founded via territorial conquest (`-w`) first strips its suffix so it inherits the exact model tag of its parent founder deme. + * Any daughter settlement budded via `settle` (`-m`) or founded via `conquer` (`-w`) strips its suffix so it inherits the exact model tag of its parent founder deme. 2. **Initial Preset Subpopulations**: * Assigned to **Primary (`d3:4b`)**: `deme-rift-west`, `deme-darwin-founder`, `deme-island-1`, `deme-island-3`. * Assigned to **Secondary (`tev1:4b`)**: `deme-rift-east`, `deme-darwin-rival`, `deme-island-2`. 3. **Other / Custom Demes**: - * Routed deterministically via a 31-bit polynomial rolling hash over `base_id`: - $$h(\text{id}) = \left(\sum_{i=0}^{n-1} \text{id}[i] \cdot 31^{n-1-i}\right) \ \& \ \text{0x7FFFFFFF}$$ - * Even hash ($h \bmod 2 == 0$) maps to `primary_model` (`d3:4b`); odd hash ($h \bmod 2 == 1$) maps to `secondary_model` (`tev1:4b`). + * Routed deterministically via a 31-bit polynomial rolling hash over `base_id`: even hash maps to `primary_model` (`d3:4b`); odd hash maps to `secondary_model` (`tev1:4b`). 4. **Graceful Single-Model Fallback**: If only one of the two configured decision models is installed in Ollama, `EvoOllamaClient._resolve_ollama_model_for_tag` automatically routes all live requests to the ready model. --- -## Coarsened 10-Part Ecological State Signature (`compute_deme_evaluation_signature`) - -Subpopulations experiencing equivalent strategic, ecological, and geopolitical conditions share a canonical, **coarsened 10-part pipe-delimited signature** (`heuristic_scorer.gd:565-672`), maximizing cross-turn and cross-deme LLM cache hit rates while still distinguishing genuine strategic regime shifts (such as new vendettas, nearby conquering hordes, or fortification upgrades): - -$$\text{sig} = \texttt{model} \mid \texttt{season} \mid \texttt{biome} \mid \texttt{adequacy} \mid \texttt{health} \mid \texttt{vitality} \mid \texttt{competition} \mid \texttt{traits} \mid \texttt{dominant} \mid \texttt{neighbors}$$ - -| Segment # | Component Key | Formula / Coarsened Bucket Definition | Purpose | -| :---: | :--- | :--- | :--- | -| **1** | `model_key` | `deme.decision_model_tag` (`d3:4b` or `tev1:4b`) | Separates cache entries by assigned cognitive model. | -| **2** | `season` | `"wet_warm"` or `"dry_cold"` | Captures seasonal regeneration multiplier ($\gamma = 1.20$ vs $0.75$). | -| **3** | `biome` | `current_cell.biome` | Captures local habitat class (`tropical_forest`, `savanna`, etc.). | -| **4** | `adequacy_bucket` | `0` if $< 0.85$ (scarcity), `1` if $\le 1.35$ (balanced), `2` if $> 1.35$ (surplus), where $\text{adequacy\_ratio} = \dfrac{R_{\text{cell}}}{\max(10.0,\ 0.48 \cdot P_{\text{cell\_total}})}$ | Coarsens local caloric abundance into 3 strategic tiers (`0..2`) to avoid cache invalidation from minor resource fluctuations. | -| **5** | `health_bucket` | `0` if $< 0.35$ (degraded), `1` if $\le 0.80$ (moderate), `2` if $> 0.80$ (healthy), based on $K_{\text{cell}} / \max(1.0, K_{\max})$ | Coarsens carrying capacity health $K / K_{\max}$ into 3 tiers (`0..2`). | -| **6** | `vitality` | `"starving"`, `"declining"`, `"stable"`, `"thriving"`, or `"extinct"` | Unified vital status based on `energy_reserves` and `last_energy_delta`. | -| **7** | `competition_key` | `"%d:%d:%s:%d:p%d:v%d:h%d:d%d:s%d"` encoding `[rival_count, kin_count, "viable" if pop >= 40 else "small", nearby_rival_flag, pact_count, vendetta_flag, horde_flag, defense_tier, surplus_flag]` | Captures local competition, fission viability, active Alliance Pacts (`p%d`), active Vendettas (`v%d`), nearby Conquering Horde threats (`h%d`), Multi-Tier Defense level (`d%d`), and rival wealth disparity (`s%d`). | -| **8** | `traits_key` | Sorted comma-joined `biological_trait_ids + cultural_trait_ids` with 5-axis biophysical vectors | Encodes exact genetic and cultural capabilities and custom Lab edits. | -| **9** | `dominant_key` | `"%x" % dec_hash` (rolling hash over eligible decisions and their 5-axis vectors) | Tracks catalog edits while omitting per-turn Baldwin/innovation counter increments so identical strategic states reuse cached LLM evaluations across turns. | -| **10** | `neighbor_key` | Top 3 passable neighbors ranked by $R / \max(10, P)$: `"%s:%s:%d" % [biome, occ_sig_tag, round(R / 150.0)]` where `occ_sig_tag` $\in \{\texttt{empty}, \texttt{kin}, \texttt{ally}, \texttt{vendetta}, \texttt{rival\_martial}, \texttt{rival}\}$ | Summarizes adjacent dispersal, alliance, vendetta, and martial threat topology. | - ---- - -## Live Ollama Inference Protocol, Historical Memory & Environmental Blending (`EvoOllamaClient` & `EvoHeuristicScorer`) - -### 1. Sliding 6-Generation Historical Memory & Rich Neighbor Context (`/v1/systemone`) -When querying Ollama (`POST http://127.0.0.1:11434/v1/systemone`), `EvoOllamaClient._build_system_one_payload` sends a structured JSON payload (`state: { briefing, strategy_traits_prior, geopolitics, memory }`, `decisions`, `model`) constructed by `EvoHeuristicScorer`: - -* **Sliding 6-Generation Memory Digest (`inspect_geopolitical_context`)**: - * Scans `state.chronicle` across the last **6 generations** (`entry.generation >= state.generation - 6`) for events involving the deme's `hex_id` or `deme.name`, extracting up to 3 recent chronological tokens: - * `raided@G` (plunder raid suffered or launched) - * `war@G` (territorial war of conquest or repelled invasion) - * `betrayal@G` (broken alliance pact ambush) - * `pact@G` (formalized alliance pact) - * `fortify@G` (Stone Wall or Castle upgrade) - * `merge@G` (city unification / synoecism) - * `tribute@G` (tribute appeasement paid or collected) - * `siege@G` (fortified siege repelled at the walls) - * Appends `vendettas=` when the deme holds active multi-turn grudges (`active_pacts < 0` via `EvoTypes.is_vendetta_active`) and `horde_nearby` when a rival martial population (`pop >= 55` with `cul-warrior-expansion`, a `-w` warband ID, or dominant `dec-territorial-dispute`) is detected within a **1–2 passable hex radius**. -* **Rich Neighbor Relational Tags (`_format_neighbor_occupant_label`)**: - * In the 4-line state briefing, each of the top 3 passable neighbor hexes formats its primary occupant as `:::def`: - * **Relational Tag (`rel_tag`)**: `kin` (same lineage), `ally` (`is_pact_active`), `vendetta` (`is_vendetta_active`), or `rival`. - * **Posture Tag (`posture_tag`)**: `martial` (if carrying `cul-warrior-expansion` or dominant `dec-territorial-dispute`) or the neighbor's dominant decision archetype (`forage`, `cooperate`, `exchange`, etc.). - * **Relative Strength Tag (`strength_tag`)**: `stronger` ($P_{\text{neighbor}} > 1.2 \cdot P_{\text{deme}}$), `weaker` ($P_{\text{neighbor}} < 0.8 \cdot P_{\text{deme}}$), or `equal`. - * **Defense Tier (`def<0..3>`)**: The neighbor settlement's current fortification tier (`def0` unfortified through `def3` Castle). -* **Compact 4-Line Briefing (`build_compact_system_one_state`)**: - ```text - Gen 8 (wet_warm) | Hex 2,-1 (arid_steppe): resources=260/335 (health=88% of Kmax=380), caloricAdequacy=3.87x, temp=+0.60, hum=0.30, alt=0.42 - Deme "Eastern Savanna Hominids": pop=140 (thriving), reserves=180, delta=+8, def=Lv2, merged=1 | CellCompetition: totalPop=140, cohabiting=0 (rivals=0, kin=0) - Traits: K-Strategy[cost:+0.20,extract:+0.25,eco:+0.15,mobility:+0.05,cohesion:+0.40]; Instinctive Sociability[cost:+0.20,extract:+0.20,eco:+0.05,mobility:+0.10,cohesion:+0.60] - lastDominant=dec-cooperate-store, baldwin=5/8, innovation=0.72/1.0, memory=fortify@G7,pact@G5;horde_nearby | Neighbors: 1,-1(savanna, res=260, pop=95, rival:martial:weaker:def1), 2,0(savanna, res=240, pop=110, ally:cooperate:equal:def2) - ``` -* **`strategy_traits_prior` (`build_top_strategy_prior`)**: Top 3 intrinsic baseline decision weights computed from the deme's biological and cultural traits (`EvoCatalog.compute_baseline_decision_weights`). -* **`geopolitics` (`build_geopolitics_summary`)**: Real-time diplomatic, military, and urban context (`active_pacts`, `active_vendettas`, `adjacent_rivals`, `defense_tier`, `merged_count`, `horde_threat`, `memory`, `neighbor_max_reserves`, `env_adequacy`). -* **`decisions`**: Eligible behavioral decisions and their 5-axis biophysical vectors. - -### 2. Environmental & Geopolitical Weight Blending (`blend_with_environmental_weights`) -To keep neural evaluations grounded in biophysical reality while preserving high responsiveness to the LLM's strategic reasoning, `EvoDecisionModel.blend_with_environmental_weights` combines **$75\%$ of the live Ollama model probability (`0.75 * m_prob`) with $25\%$ of the deterministic environmental & geopolitical weight (`0.25 * e_weight`)** computed by `compute_environmental_decision_weights`: - -$$\tilde{p}(d) = 0.75 \cdot P_{\text{Ollama}}(d) + 0.25 \cdot P_{\text{env}}(d)$$ - -where $P_{\text{env}}(d)$ modulates the deme's intrinsic `strategy_traits_prior` according to caloric adequacy ($R / 0.48 P_{\text{cell}}$), carrying-capacity health ($K / K_{\max}$), vital status (`starving`, `declining`, `stable`, `thriving`), active formal pacts (`pact_count`), active vendettas (`vendetta_count`), nearby conquering horde threats (`has_horde_threat`), and fortification status (`defense_tier`). - -### 3. Non-Blocking Worker Pool, Epoch-Guarded Cache & 8-Signature Batch Prefetching -* **Concurrent HTTP Batching**: `_run_ollama_worker` runs on Godot's `WorkerThreadPool`, popping up to **4 pending signatures per HTTP worker batch** and polling non-blocking `HTTPClient` connections concurrently (`EVAL_TIMEOUT_MS = 60000` ms, `keep_alive = "30m"`). -* **8-Signature Queue & Post-Turn Prefetching (`prefetch_for_state` & `sync_and_enqueue_batch`)**: At Stage 5 of every generation (and during turn synchronization), the engine deduplicates uncached deme signatures across the valley and enqueues up to **8 unique representative signatures** (`reps_by_sig.size() >= 8`) into `_pending_jobs_by_sig`. Furthermore, when two allied settlements unite via **Synoecism (`merge`)**, their consolidation into a single `Deme` instance directly reduces the number of active subpopulations and LLM requests per turn. -* **Epoch-Guarded Cache Invalidation (`_cache_epoch`)**: Calling `clear_caches()` (e.g., when switching scenarios or resetting the world) increments `_cache_epoch` under `_mutex`; in-flight worker batches verify `job_epoch == _cache_epoch` before storing results in `_live_signature_cache`, preventing stale responses from earlier runs from polluting a new simulation. -* **Live Intra-Turn Hot-Swap**: Whenever a background batch completes, `_completed_revision` increments. On the very next frame (`_process`), `EvoMainController` calls `EvoDecisionModel.apply_completed_live_results`, immediately updating deme decision bars, justifications, and 2.5D village action structures without stalling UI rendering. While any batch remains pending, automatic generational progression waits for the models to complete their evaluation. +## Concrete Targeted Action Menu (`EvoActionMenu`) + +Every turn, `EvoActionMenu.build(deme, state)` generates a compact menu (`MAX_MENU_OPTIONS = 14`) of physically and socially valid options (`{ id, type, target }`): + +| Category | Option ID Format | Action Type | Feasibility Conditions | +| :--- | :--- | :--- | :--- | +| **Domestic** | `forage` | `forage` | Always available. Sustainable wild and rural harvest (`+10%` conservation bonus). | +| **Domestic** | `intensify` | `intensify` | Available when `cell.current_resources > 10.0`. Overexploits land (`+35%` harvest) at the cost of `-7%` permanent carrying capacity $K$. | +| **Domestic** | `fortify` | `fortify` | Available when `defense_tier < 3` and `energy_reserves >= FORTIFY_COST_RESERVES[tier]`. Upgrades hex-edge walls (`Palisade` $\to$ `Stone Wall` $\to$ `Castle`). | +| **Domestic** | `innovate` | `innovate` | Available when the settlement possesses cultural capacity (`bio-encephalization` or `bio-social-instinct`) and `< 2` emergent cultural traits. | +| **Frontier** | `settle@` | `settle` | Up to 2 passable empty neighbor hexes when `population >= 80` and `energy_reserves >= 45.0`. Founds a daughter settlement (`-m`). | +| **Reactive Diplomacy** | `accept_pact@` | `accept_pact` | Neighbor proposed an alliance pact (`proposal_kind == "pact"`). Seals the alliance immediately. | +| **Reactive Diplomacy** | `accept_union@` | `accept_union` | Allied neighbor proposed unification (`proposal_kind == "union"`). Merges or forms a Walled Union immediately. | +| **Reactive Diplomacy** | `pay_tribute@` | `pay_tribute` | Neighbor demanded tribute (`proposal_kind == "tribute"` and `energy_reserves >= amount`). Pays tribute for a 2-turn truce; choosing any other action refuses the ultimatum and triggers an immediate punitive raid. | +| **Proactive Social** | `trade@` | `trade` | Available with non-feuding adjacent neighbors (`feud_turns == 0`). Unilateral trade earns `55%` of trade gain + `+0.12` trust + cultural diffusion; mutual trade earns `100%` of trade gain + `+0.20` trust. | +| **Proactive Social** | `aid@` | `aid` | Available when `energy_reserves >= 50.0` and neighbor has no active feud. Transfers surplus food (`+0.25` trust, `+0.10` reputation, and seals any open pact offer). | +| **Proactive Social** | `offer_pact@` | `offer_pact` | Available when `pact_turns == 0` and `feud_turns == 0`. Seals immediately with same-lineage kin or trusted neighbors (`trust >= 0.30`); otherwise opens a pact proposal. | +| **Proactive Social** | `unite@` | `unite` | Available between established allied towns (`pact_turns >= 3`, both `village_tier >= 2`, and neither already in a `walled_union_id`). | +| **Coercion & Conflict** | `demand_tribute@` | `demand_tribute` | Available against non-allied, non-kin neighbors with `energy_reserves >= 25.0` during peacetime (`truce_turns == 0`). | +| **Coercion & Conflict** | `raid@` | `raid` | Available against non-allied neighbors when `population >= 25` and `truce_turns == 0`. | +| **Coercion & Conflict** | `betray@` | `betray` | Available against an active pact ally (`pact_turns > 0`) when `population >= 30`. Launches a surprise attack (`+35%` combat bonus, halves wall defense) at the cost of `-0.50` global reputation and a 4-turn feud. | +| **Coercion & Conflict** | `conquer@` | `conquer` | Available against non-allied, non-kin neighbors when `population >= 65`, `energy_reserves >= 35.0`, and `truce_turns == 0`. | --- -## Decision Eligibility & Probability Normalization - -### Decision Eligibility Rule (`is_decision_eligible`) -A decision `dec` is eligible if `dec.required_trait_ids.is_empty()` or the deme possesses **at least one** trait in `dec.required_trait_ids`. Specifically: - -* **5 Base Decisions** (`dec-conservative-forage`, `dec-intensive-overexploit`, `dec-selective-migration`, `dec-cooperate-store`, `dec-territorial-dispute`) require no special traits (with `dec-territorial-dispute` filtered out when no rival lineage is present in the current or adjacent hexes). -* **`dec-exchange-symbiosis` and `dec-explore-innovate`** require cultural capacity (`bio-encephalization` OR `bio-social-instinct`). +## Quantified Outcome Forecasts (`EvoOutcomeForecast`) & Situation Report (`EvoSituationReport`) -### Probability Normalization (`normalize_decision_probabilities`) -Raw LLM probability scores returned by Ollama `/v1/systemone` are normalized via `EvoHeuristicScorer.normalize_decision_probabilities(raw_scores, eligible_ids)`: +When querying Ollama (`POST http://127.0.0.1:11434/v1/systemone`), `EvoOllamaClient._build_system_one_payload` sends: -1. Any non-positive, `NaN`, or infinite score for an eligible decision is clamped to $0.0$ (if all eligible scores are $\le 0$, uniform weights $1 / |\text{eligible}|$ are assigned). -2. The highest-scoring eligible decision $d_{\max}$ is identified as `dominant_decision_id`. -3. Every other eligible decision $d_i \neq d_{\max}$ is rounded to $4$ decimal places: - $$p_i = \text{snapped}\left(\frac{s_i}{\sum_{j \in \text{eligible}} s_j},\ 0.0001\right)$$ -4. The dominant decision receives the exact complement $p_{\max} = \text{snapped}\big(\max(0.0,\ 1.0 - \sum_{i \neq \max} p_i),\ 0.0001\big)$ (swapping with the runner-up if rounding caused the complement to dip below the runner-up), guaranteeing that $\sum_{d} P(d) = 1.0000$ and the dominant decision always holds the maximum probability. +1. **`state` (`EvoSituationReport.build(deme, state)`)**: + * **Settlement & Strategy Header**: Settlement name, urban tier (`Camp`, `Village`, `Town`, `Metropolis`), strategy name, and natural-language **Strategy Doctrine** (`strat.doctrine`). + * **Vital & Ecological Status**: Population, stored food, per-turn food need (`EvoPayoffs.food_need`), expected base harvest (`EvoPayoffs.expected_income`), local hex biome, wild food ratio, land carrying capacity health (`K / Kmax`), owned rural farm/pasture count, fortification status, global reputation, and innovation progress. + * **Causal Feedback (`Last turn`)**: Exact outcome of the settlement's previous action (`deme.last_outcome`) and net food delta (`last_energy_delta`). + * **Personal Episodic Memory (`Recent history`)**: Up to 5 recent causal entries from the settlement's 8-event sliding memory (`deme.memory`). + * **Frontier & Neighbor Relations**: Best open frontier hexes (`biome`, `food`, `capacity`) and each adjacent neighbor's population, stored food, walls, relation status (`kin`, `ally`, `feud`, `truce`, `neutral`), bilateral `trust` (`[-1, +1]`), last action toward this settlement, and any open proposal (`pact`, `union`, or `tribute` ultimatum). +2. **`decisions`**: Array of `{ id, criterion }` objects from `EvoActionMenu.build` and `EvoOutcomeForecast.describe`, where every `criterion` states the exact expected food harvest alongside action-specific payoffs, odds, casualties, and diplomatic consequences. --- -## Startup Ollama Verification, Blocking Offline Notice & Model Download +## Temperature-Sharpened Policy Sampling (`EvoActionPolicy`) -When **Evolution** starts, `EvoDecisionModel.check_ollama_status(true)` asynchronously queries `GET http://127.0.0.1:11434/api/tags` to verify connectivity and inspect installed models advertising the `"decision"` capability in their `capabilities` array (`EvoOllamaClient.filter_decision_models_from_tags`). +Raw option scores returned by `/v1/systemone` are converted into an action probability distribution and sampled via `EvoActionPolicy.sample(menu, raw_scores, rng)`: -* **Ollama Offline Blocking Popup (`show_ollama_offline_popup`)**: - * If Ollama is offline or unreachable (`http://127.0.0.1:11434`), **Evolution** displays a full-screen blocking warning popup (`_missing_models_modal`) notifying the user that Ollama is offline and required to run the simulation. - * While this popup is active, the simulation **cannot be started** (`Play` and `Step +1` controls are disabled and keyboard/map interactions are blocked). - * Closing the popup (`Close application` button or pressing Escape) cleanly shuts down worker threads and **closes the application** (`get_tree().quit()`). -* **Missing Models Popup (`show_missing_models_popup`)**: - * If Ollama is online but either `primary_model` (`d3:4b`) or `secondary_model` (`tev1:4b`) is missing locally, the blocking modal offers: - * **Automatic In-Game Download (`⬇️ Download automatically`)**: Streams `POST /api/pull` on a background thread with a real-time 0–100% progress bar and unlocks the simulation upon completion. - * **Close Application (`Close application` / Escape)**: Cancels any pending download and exits the application. -* **Custom Model Selection**: Open the **Settings Gear (`⚙️`)** in the top-right clock panel at any time to switch Primary and Secondary models among any installed decision-capable Ollama models, adjust `num_ctx` (default `8192`), and persist your configuration to `~/.config/Evolution/settings.json`. +1. Each menu option $i$'s raw score $s_i \ge 0$ is sharpened with temperature $T = 0.65$: + $$w_i = \left(\max(s_i, 0.0001)\right)^{1 / T}$$ +2. Normalized probabilities $p_i = w_i / \sum_j w_j$ are stored in `deme.action_probabilities` (with the highest-probability option recorded in `deme.last_dominant_decision_id` and `deme.last_confidence`). +3. A single concrete action (`deme.chosen_action = { id, type, target }`) is sampled from $p_i$ using `state.rng` (`RandomNumberGenerator`), enabling both decisive commitment to high-payoff options and genuine emergent variety across turns. diff --git a/gh-pages/index.md b/gh-pages/index.md index 577cbf5..d535edb 100644 --- a/gh-pages/index.md +++ b/gh-pages/index.md @@ -10,7 +10,7 @@ hide:

Evolution

- An interactive bio-cultural and evolutionary population simulator built with Godot 4.7. Configure 1v1 Evolutionary Strategy Duels (Strategy A ⚔️ Strategy B), forge custom strategies and traits, and shape climates, terrain altitude, sea levels, and carrying capacity ($K$) on a handcrafted 2.5D isometric hexagonal ecosystem—while local subpopulations (demes) autonomously adapt, migrate, cooperate, innovate, or diverge into new species. + An interactive bio-cultural and evolutionary population simulator built with Godot 4.7. Configure 1v1 Evolutionary Strategy Duels (Strategy A ⚔️ Strategy B), forge custom strategies and traits, and shape climates, terrain altitude, sea levels, and carrying capacity ($K$) across a 169-hex (map_radius = 7) high-DPI 2.5D illustrated hexagonal ecosystem powered by GPU shaders—while single-deme-per-hex subpopulations (demes) autonomously adapt, cultivate adjacent rural hinterlands (crops & pasture), manage urban salubrity, erect hex-edge perimeter walls, unite into continuous multi-hex walled conurbations (walled_union_id), innovate, or diverge into new species.

@@ -79,46 +79,51 @@ At Generation 0, every scenario sets up a head-to-head evolutionary experiment b | Strategy ID | Emblem & Name (EN / ES) | Hex Color | Included Traits (`biological_trait_ids` + `cultural_trait_ids`) | Evolutionary Philosophy | | :--- | :--- | :---: | :--- | :--- | -| `strat-cooperation` | 🤝 **Cooperative Alliance**
*Alianza Cooperativa* | `#10B981` | `bio-k-strategy`, `bio-social-instinct` + `cul-communal-conservation` | Fosters social cohesion, communal habitat conservation, and resource buffering. | +| `strat-cooperation` | 🤝 **Cooperative Alliance**
*Alianza Cooperativa* | `#10B981` | `bio-k-strategy`, `bio-social-instinct` + `cul-communal-conservation` | Fosters social cohesion, communal habitat conservation, urban sanitation, and resource buffering. | | `strat-expansion` | 🧭 **Expansionist Pioneers**
*Pioneros Expansionistas* | `#3B82F6` | `bio-r-strategy`, `bio-thermal-tolerance`, `bio-social-instinct` + `cul-fire-processing` | Fast reproduction, high mobility, and thermal tolerance to colonize new territories. | | `strat-trade` | ⚖️ **Merchant League**
*Liga Mercantil* | `#F59E0B` | `bio-k-strategy`, `bio-encephalization` + `cul-interdeme-barter` | Drives inter-village barter, commercial symbiosis, and horizontal cultural diffusion. | -| `strat-war` | ⚔️ **Conquering Horde**
*Horda Conquistadora* | `#EF4444` | `bio-r-strategy`, `bio-social-instinct` + `cul-warrior-expansion`, `cul-extraction-tools` | Aggressive martial expansion and intensive extraction to dominate rival territories. | +| `strat-war` | ⚔️ **Conquering Horde**
*Horda Conquistadora* | `#EF4444` | `bio-r-strategy`, `bio-social-instinct` + `cul-warrior-expansion`, `cul-extraction-tools` | Aggressive martial expansion, rural hinterland pillaging, and intensive extraction to dominate rival territories. | | `strat-innovation` | 🔬 **Innovator Technocrats**
*Tecnócratas Innovadores* | `#A855F7` | `bio-encephalization`, `bio-digestive-spec` + `cul-fire-processing`, `cul-extraction-tools` | High encephalization, thermal food processing, and specialized extraction toolkits. | --- ## Visual Tour & 1v1 Scenario Gallery -![The Rift Valley — Isometric Hexagonal Ecosystem & HUD](assets/img/screenshot_rift_savanna.png) -*The Rift Valley (`preset-rift-savanna`): Western Forest Hominids (`(-2, 1)`, Strategy A) vs. Eastern Savanna Hominids (`(2, -1)`, Strategy B) separated by a high-mountain tectonic ridge (`q = 0`).* +![The Rift Valley — 169-Hex 2.5D Illustrated Ecosystem & HUD](assets/img/screenshot_rift_savanna.png) +*The Rift Valley (`preset-rift-savanna`, `169` hexes, `map_radius = 7`): Western Forest Hominids (`(-2, 1)`, Strategy A) vs. Eastern Savanna Hominids (`(2, -1)`, Strategy B) separated by a high-mountain tectonic ridge (`q = 0`), with the Scroll Inspector displaying urban salubrity, stone wall fortifications, and rural hinterlands.* + +| Settlement Tiers I–III & Rural Hinterlands | Continuous Multi-Hex Walled Union (`Tier IV`) | +| :---: | :---: | +| ![Settlement Tiers I–III, Perimeter Walls & Cultivated Rural Hinterlands](assets/img/screenshot_settlement_tiers_hinterlands.png) | ![Continuous Multi-Hex Walled Union & Hinterlands](assets/img/screenshot_late_simulation.png) | +| *Close-up of 1-deme-per-hex progression: Tier I `Camp` (`-1,0`), Tier II `Village` (`0,0`, Lv.1 Palisade + pasture at `0,-1`), and Tier III `Town` (`1,0`, Lv.2 Stone Wall + golden wheat `crops` at `1,-1` & `pasture` at `2,0` linked by `covered_wagon` farm tracks)* | *Two allied Tier IV Metropolises (`(0,0)` & `(1,0)`) united in a Continuous Multi-Hex Walled Union (`walled_union_id`) with their shared internal wall edge culled, a central union boulevard, and 6 surrounding rural hinterlands* | | Darwin's Archipelago (`preset-darwin-archipelago`) | The Finite Island (`preset-finite-island`) | | :---: | :---: | | ![Darwin's Archipelago — Insular Dispersal & Land Bridges](assets/img/screenshot_darwin_archipelago.png) | ![The Finite Island — Ecological Health Lens & Valley Legend](assets/img/screenshot_finite_island.png) | -| *Symmetric 1v1 insular duel between Western Island Founders (`(-1, 0)`, Strategy A) and Eastern Island Rivals (`(1, 0)`, Strategy B) connected by tidal `coastal_shallows` land bridges (`0,-1`, `0,1`, `2,0`) that flood and emerge every 8 generations* | *1v1 resource stewardship duel between Northern Quarry Clan (`(-1, 0)`, Strategy A) and Southern Timber Clan (`(1, 0)`, Strategy B) on a bounded island viewed through the Ecological Health Lens ($K / K_{\max}$) with the Valley Legend open* | +| *169-hex volcanic archipelago with GPU ocean waves (`ocean_waves.gdshader`), multi-ring coastal foam, 2.5D island cliffs, and 7 tidal `coastal_shallows` land bridges that flood and emerge every 8 generations* | *169-hex bounded island duel between Northern Quarry Clan (`(-1, 0)`, Strategy A) and Southern Timber Clan (`(1, 0)`, Strategy B) viewed through the Ecological Health Lens ($K / K_{\max}$) with the Valley Legend open* | | Naturalist's Notebook (Chronicle) | Phylo-Cultural Lineage Tree | Strategy, Trait & Decision Forge (Lab) | | :---: | :---: | :---: | | ![Naturalist's Notebook Modal](assets/img/screenshot_notebook_modal.png) | ![Phylo-Cultural Lineage Tree Modal](assets/img/screenshot_tree_modal.png) | ![Trait & Decision Forge Modal](assets/img/screenshot_lab_modal.png) | -| *Chronological log of speciation, Baldwin assimilation, cultural innovation, symbiosis, and extinctions with 1-click hex jump* | *Interactive phylogenetic hierarchy tracking ancestral and diverged lineages, strategy emblems, traits, and active village counts* | *4-tab catalog editor (`🧬`, `🔥`, `⚖️`, `🛡️`) with live Thermodynamic Balance Meter, strategy builder, and `~/.config/Evolution/user_catalog.json` persistence* | +| *19-filter chronological log of urban epidemics, multi-hex walled unions, category absorptions, speciation, Baldwin assimilation, and wars with 1-click hex jump* | *Interactive phylogenetic hierarchy tracking ancestral and diverged lineages, strategy emblems, traits, and active settlement counts (`I–IV`)* | *4-tab catalog editor (`🛡️`, `🧬`, `🔥`, `⚖️`) with live Thermodynamic Balance Meter, strategy builder, and `~/.config/Evolution/user_catalog.json` persistence* | --- ## How the Generational Loop Works -Unlike traditional strategy games where players issue unit-level movement orders, **Evolution** strictly separates **behavioral intent** (probabilistic decision-making per deme) from **biophysical consequence** (deterministic laws of energy conservation, carrying capacity $K$, metabolic expenditure, and demography): +Unlike traditional strategy games where players issue unit-level movement orders, **Evolution** strictly separates **behavioral intent** (probabilistic decision-making per deme) from **biophysical consequence** (deterministic laws of energy conservation, carrying capacity $K$, rural hinterland cultivation, urban salubrity, and demography): ```mermaid flowchart LR Env["1. Environmental Phase\n• Flip Season (wet_warm / dry_cold)\n• Glacial Sea-Level Cycle (8 gen)\n• Flood / Emerge Coastal Hexes\n• Regenerate K & Biomass R"] --> Cog["2. Cognitive Phase\n• Coarsened 10-Part Signature\n• 6-Gen Memory & Neighbor Tags\n• System 1 Ollama (d3:4b / tev1:4b)\n• 75% LLM / 25% Env Blend"] - Cog --> Bio["3. Biophysical & Geopolitical Phase\n• Aggregate Effective Vector v_eff\n• Hawk-Dove, Trade (up to 1.35x) & Pacts\n• Vendettas, Tribute, Raids & 3-Tier Walls\n• Growth, Sieges, Wars & Synoecism (Merge)"] + Cog --> Bio["3. Biophysical & Geopolitical Phase\n• Urban Foraging & Rural Hinterlands (crops/pasture)\n• Inter-Hex Hawk-Dove, Trade (up to 1.35x) & Pacts\n• Raids, Rural Pillaging (ruins) & 3-Tier Walls\n• Salubrity & Epidemics, Wars, Dual Synoecism"] Bio --> Mile["4. Milestone Phase\n• Allopatric Speciation (Prio 0)\n• Baldwin Assimilation (Prio 1)\n• Cultural Innovation (Prio 2)\n• Macro-Outcome Evaluation"] Mile --> Prefetch["5. Async Prefetch\n• Pre-warm up to 8 Signatures\n• Non-Blocking Worker Pool"] ``` -1. **Environmental Phase (`EvoEnvPhase`)**: Advances the generation counter, alternates seasonal climate between `wet_warm` ($\gamma = 1.20$) and `dry_cold` ($\gamma = 0.75$), oscillates sea level every 8 generations in glacial scenarios, relocates or drowns submerged demes, and regenerates hex carrying capacity $K$ and resources $R$. +1. **Environmental Phase (`EvoEnvPhase`)**: Advances the generation counter, alternates seasonal climate between `wet_warm` ($\gamma = 1.20$) and `dry_cold` ($\gamma = 0.75$), oscillates sea level every 8 generations in glacial scenarios, relocates submerged demes to empty passable neighbor hexes (or drowns them into `has_ruins = true`), and regenerates hex carrying capacity $K$ and resources $R$. 2. **Cognitive Decision Phase (`EvoDecisionModel`)**: Evaluates each deme via `/v1/systemone` with its intrinsic strategy traits prior (`strategy_traits_prior`), a **6-generation historical memory digest** (`war`, `raided`, `betrayal`, `pact`, `fortify`, `merge`, `tribute`, `siege`), active vendetta count, nearby martial horde detection (1–2 hexes), and **rich neighbor relational labels** (`kin`, `ally`, `rival`, `vendetta` + posture + strength + defense tier)—routing rival factions deterministically between primary (`d3:4b`) and secondary (`tev1:4b`) Ollama System 1 models and blending neural predictions (**$75\%$**) with environmental and geopolitical weights (**$25\%$**). -3. **Biophysical & Geopolitical Consequence Phase (`EvoBiophysPhase`)**: Computes each deme's effective 6-axis vector $\mathbf{v}_{\text{eff}}$, resolves *Hawk-Dove* territorial conflicts, reciprocal symbiosis and **complementary inter-biome/cross-cultural trade** (up to $1.35\times$ caloric bonus and horizontal cultural transmission), **Alliance Pacts & Defensive Coalitions** ($+15\%$ mutual bonus, distress caloric aid, and up to $+1.10$ shared military defense), **Opportunistic Betrayals & Multi-Turn Vendettas** (`active_pacts < 0`), **Plunder Raids & Tribute Appeasement** ($18\%$ reserve tribute with zero casualties), **Multi-Tier Defense Fortifications** (`defense_tier: 0..3` from Palisade to Stone Wall to Castle with allied co-financing), habitat degradation (*Tragedy of the Commons* when $K < 0.60 \cdot K_{\max}$), metabolic expenditure & wall upkeep, demographic growth/starvation, **Territorial Wars & Fortified Sieges**, population fission, and **City Unification / Synoecism** (`merge` into a **Tier 5 Unified Metropolis**, expanding $K$ and reducing LLM inference calls per turn). +3. **Biophysical, Urban & Geopolitical Consequence Phase (`EvoBiophysPhase`)**: Computes each deme's effective 6-axis vector $\mathbf{v}_{\text{eff}}$, applies urban wild-foraging multipliers (`0.55` at Tier III, `0.30` at Tier IV), claims and harvests **adjacent rural hinterlands (`crops` and `pasture`, up to `1`/`2`/`4` hexes for Tier II/III/IV)**, resolves inter-hex *Hawk-Dove* conflicts, reciprocal symbiosis and **complementary inter-biome/cross-cultural trade** (up to $1.35\times$), **Alliance Pacts & Defensive Coalitions**, **Opportunistic Betrayals & Multi-Turn Vendettas**, **Plunder Raids (with rural hinterland pillaging into `has_ruins = true`) & Tribute Appeasement**, **Hex-Edge Perimeter Fortifications** (`defense_tier: 0..3`), habitat degradation (*Tragedy of the Commons*), metabolic expenditure & wall upkeep, **Urban Salubrity (`salubrity`) & Epidemic Outbreaks (`"epidemic"`)**, demographic growth, **Territorial Wars (with 1-deme-per-hex retreat/absorption & pooled union defense) & Fortified Sieges**, empty-hex colonist budding, and **Dual Synoecism** (*Asymmetric Category Absorption* converting vacated hexes to rural hinterlands, and *Continuous Multi-Hex Walled Unions* `walled_union_id` culling shared internal walls). 4. **Milestone & Macro-Outcome Phase (`EvoMilestonePhase`)**: Synthesizes thermodynamically balanced emergent traits for *Allopatric Speciation*, *The Baldwin Effect*, or *Cumulative Cultural Innovation*, logs entries to the *Naturalist's Notebook*, and checks global macro-outcomes (*Total Extinction*, *Evolutionary Hegemony*, or *Ecological Equilibrium*). --- @@ -159,7 +164,7 @@ flowchart LR === "Regenerate Screenshots" ```bash - # Capture all 7 English documentation screenshots into gh-pages/assets/img/ + # Capture all documentation screenshots into gh-pages/assets/img/ godot --path . --script res://tests/test_visual_capture.gd ``` @@ -169,7 +174,7 @@ flowchart LR | Input / Shortcut | Slot / Control | Cost (⚡) | Effect in Simulation | | :--- | :--- | :---: | :--- | -| 1 / **Left Click** | **Naturalist's Loupe (`inspect`)** | `0` | Select a village or hexagon to inspect its demography, relations, traits, and 6-axis profile in the Scroll Inspector. | +| 1 / **Left Click** | **Naturalist's Loupe (`inspect`)** | `0` | Select a settlement or hexagon to inspect its demography, salubrity, rural hinterlands, walled union, relations, traits, and 6-axis profile in the Scroll Inspector. | | 2 | **Jungle Seeds (`tropical_forest`)** | `10` | Transform target hexes into `tropical_forest` ($K_{\max} = 600$, `regen = 0.28`, $T = 0.45$, $H = 0.85$). | | 3 | **Savanna Seeds (`savanna`)** | `10` | Transform target hexes into `savanna` ($K_{\max} = 420$, `regen = 0.18`, $T = 0.50$, $H = 0.40$). | | 4 | **Desert Dust (`arid_steppe`)** | `10` | Transform target hexes into `arid_steppe` ($K_{\max} = 280$, `regen = 0.12`, $T = 0.65$, $H = 0.20$). | @@ -180,16 +185,16 @@ flowchart LR | 9 | **Drought Sickle (`deplete`)** | `10` | Reduce hex carrying capacity $K$ and available biomass $R$ by $-25\%$ (inducing ecological stress). | | 0 | **Tidal Conch (`sea_level`)** | `25` / `10` | Open the Tidal Sub-Options Bar: adjust global `sea_level` by $\pm 5\%$ (`⚡25`) or paint `coastal_shallows`, `ocean`, and `high_mountain` (`⚡10`). | | Space | **Play / Pause** | — | Toggle automatic generational progression (`1x` = `1.50s/gen` or `2x` = `0.30s/gen`). | -| WASD / **Middle Drag** | **Pan Camera** | — | Smoothly pan across the 2.5D isometric hexagonal valley (`820 px/s`). | -| QE / RF / **Right Drag** | **Orbit & Tilt Camera** | — | Orbit yaw ($0^\circ\text{–}360^\circ$) and tilt pitch ($28^\circ\text{–}75^\circ$) around the valley. | -| Mouse Wheel / +- | **Zoom Camera** | — | Cursor-pivoted zoom between `0.28x` macro overview and `3.00x` settlement close-up. | +| WASD / **Middle Drag** | **Pan Camera** | — | Smoothly pan across the `169`-hex 2.5D isometric hexagonal valley (`820 px/s`, `MAX_PAN_RADIUS = 2800.0`). | +| RF / **Right Drag** | **Tilt Camera Pitch** | — | Tilt vertical pitch ($32^\circ\text{–}68^\circ$, default $48^\circ$) with horizontal yaw locked to $0^\circ$ so 2.5D illustrated facades remain oriented toward the viewer. | +| Mouse Wheel / +- | **Zoom Camera** | — | Cursor-pivoted zoom between `0.18x` macro archipelago overview and `2.80x` settlement close-up. | | Escape | **Close / Reset** | — | Close active modal/popup window or return active brush to `Naturalist's Loupe` (1). | ---

Documentation

- Deep-dive into worlds, 6-axis thermodynamics, turn mechanics, cognitive AI & architecture + Deep-dive into 169-hex worlds, 6-axis thermodynamics, urban hinterlands, dual synoecism, cognitive AI & 2.5D shader architecture
@@ -200,7 +205,7 @@ flowchart LR Read →

Worlds, Biomes & Presets

-

Complete breakdown of axial hex geometry, the 7 biomes, seasonal & glacial cycles, and the 3 scientific scenarios (Rift Valley, Darwin's Archipelago, Finite Island).

+

Complete breakdown of 169-hex axial geometry (map_radius = 7), 1-deme-per-hex exclusivity, the 7 biomes, glacial cycles, and the 3 scientific scenarios.

@@ -210,7 +215,7 @@ flowchart LR Read →

6-Axis Thermodynamics & Catalog

-

Mathematical laws of the 6-axis biophysical vector, auto-balancing equations, and complete reference tables for all biological traits, cultural traditions, and decisions.

+

Mathematical laws of the 6-axis biophysical vector, auto-balancing equations, and complete reference tables for all strategies, biological traits, cultural traditions, and decisions.

@@ -220,7 +225,7 @@ flowchart LR Read →

Generational Loop & Dynamics

-

Step-by-step equations for every turn phase: Hawk-Dove game theory, symbiosis, $K$ degradation, demography, fission migration, Baldwin assimilation, and speciation.

+

Step-by-step equations for rural hinterlands (crops/pasture), pillaging (has_ruins), urban salubrity & epidemics, 3-tier hex walls, and dual synoecism (walled_union_id).

@@ -230,7 +235,7 @@ flowchart LR Read →

Cognitive Engine & Ollama AI

-

System 1 multi-model routing (d3:4b & tev1:4b), 10-part state signatures, compact 4-line state briefings, worker pools, and startup verification.

+

System 1 multi-model routing (d3:4b & tev1:4b), 10-part state signatures, 6-generation historical memory, compact 4-line state briefings, and worker pools.

@@ -240,7 +245,7 @@ flowchart LR Read →

HUD, Modals & God-Mode Tools

-

Master the Scroll Inspector, Map Lenses, Valley Legend, Naturalist's Notebook, Phylo-Cultural Lineage Tree, Trait & Decision Forge, and God-mode brushes.

+

Master the Scroll Inspector (salubrity, walled unions & rural land-use), Map Lenses, Valley Legend, 19-filter Naturalist's Notebook, Lineage Tree, and Forge.

@@ -250,7 +255,7 @@ flowchart LR Read →

System Architecture & Developer Guide

-

2.5D isometric hex rendering pipeline, procedural pixel-art generators, organic road network lifecycle, i18n, automated test suite, and CI/CD workflows.

+

2.5D high-DPI illustrated rendering pipeline, GPU shaders, procedural 256x224 / 128x128–160x160 sprite cache, hex-edge perimeter walls, and automated test suite.

diff --git a/gh-pages/thermodynamics_catalog.md b/gh-pages/thermodynamics_catalog.md index ab670c1..7775623 100644 --- a/gh-pages/thermodynamics_catalog.md +++ b/gh-pages/thermodynamics_catalog.md @@ -105,7 +105,7 @@ Biological traits represent genetically heritable life-history strategies, physi | Trait ID | Name (EN / ES) | Cost | Extract | Eco | Mobility | Cohesion | Threshold | Exclusive With | Enables Culture | Biophysical & Evolutionary Role | | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :--- | | `bio-r-strategy` | **r-Strategy (Prolific)**
*Estrategia r (Prolífica)* | `-0.15` | `+0.20` | `-0.20` | `+0.25` | `-0.15` | `0.05` | `bio-k-strategy` | No | Boosts demographic growth rate via a density-dependent multiplier (**$\times 1.28$** when crowding $< 0.55$, **$\times 1.16$** when $< 0.65$, **$\times 1.05$** when $\ge 0.65$) and lowers metabolic cost, accelerating colonization at the expense of habitat impact. | -| `bio-k-strategy` | **K-Strategy (Longevity & Parental Care)**
*Estrategia K (Longevidad y Cuidado Parental)* | `+0.20` | `+0.25` | `+0.15` | `+0.05` | `+0.40` | `0.30` | `bio-r-strategy` | No | Moderates demographic swings ($\times 0.88$ growth/decline multiplier), improves ecological stewardship (`+0.15`), strengthens social cohesion (`+0.40`), and grants a **$15\%$ cost discount** on Multi-Tier Defense fortifications. | +| `bio-k-strategy` | **K-Strategy (Longevity & Parental Care)**
*Estrategia K (Longevidad y Cuidado Parental)* | `+0.20` | `+0.25` | `+0.15` | `+0.05` | `+0.40` | `0.30` | `bio-r-strategy` | No | Moderates demographic swings ($\times 0.88$ growth/decline multiplier), improves ecological stewardship (`+0.15`), strengthens social cohesion (`+0.40`), bolsters urban sanitation (**`+0.14` `salubrity`**), and grants a **$15\%$ cost discount** on Multi-Tier Defense fortifications. | | `bio-digestive-spec` | **Omnivorous Digestive Specialization**
*Especialización Digestiva Omnívora* | `+0.10` | `+0.45` | `+0.05` | `+0.15` | `0.00` | `0.10` | *None* | No | Enzymatic plasticity that substantially increases caloric extraction (`+0.45`) across diverse flora and fauna with minimal metabolic overhead (`+0.10`). | | `bio-thermal-tolerance` | **Thermal & Hydric Tolerance**
*Tolerancia Térmica e Hídrica* | `+0.15` | `+0.20` | `+0.05` | `+0.45` | `+0.10` | `0.10` | *None* | No | High spatial mobility (`+0.45`) that unlocks high-altitude mountain passes and mitigates thermal metabolic penalties in Tundra and Arid Steppe biomes. | | `bio-encephalization` | **Encephalization (Costly Neural Tissue)**
*Encefalización (Tejido Neural Costoso)* | `+0.45` | `+0.35` | `0.00` | `+0.20` | `+0.45` | `0.60` | *None* | **Yes** | Steep resting metabolic cost (`+0.45`) for neocortical expansion; unlocks cumulative cultural traditions, `dec-exchange-symbiosis`, and tool innovation. | @@ -124,7 +124,7 @@ Cultural traditions require at least one biological trait with `enables_cultural | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | | `cul-fire-processing` | **Fire & Thermal Food Processing**
*Fuego y Procesado Térmico de Alimentos* | `-0.10` | `+0.35` | `-0.25` | `+0.10` | `+0.15` | `0.40` | Externalizes digestion through cooking, lowering `metabolic_cost` (`-0.10`) and raising caloric extraction (`+0.35`) at the cost of fuelwood depletion (`-0.25` eco). | | `cul-extraction-tools` | **Specialized Extraction Tools**
*Herramientas de Extracción Especializadas* | `+0.25` | `+0.65` | `-0.25` | `+0.10` | `+0.20` | `0.50` | Cumulative lithic/organic toolkit that massively amplifies extraction (`+0.65`) and triggers the `tech_overexploit` behavioral branch unless paired with conservation norms. | -| `cul-communal-conservation` | **Communal Conservation Norms**
*Normas de Conservación Comunal* | `+0.15` | `+0.25` | `+0.55` | `0.00` | `+0.50` | `0.55` | Institutionalized harvesting quotas and taboos (`+0.55` `ecological_impact`, `+0.50` `social_cohesion`) that prevent the *Tragedy of the Commons*, actively rebuild $K$, and grant a **$15\%$ cost discount** on Multi-Tier Defense fortifications. | +| `cul-communal-conservation` | **Communal Conservation Norms**
*Normas de Conservación Comunal* | `+0.15` | `+0.25` | `+0.55` | `0.00` | `+0.50` | `0.55` | Institutionalized harvesting quotas and sanitation norms (`+0.55` `ecological_impact`, `+0.50` `social_cohesion`) that prevent the *Tragedy of the Commons*, actively rebuild $K$, boost urban sanitation (**`+0.20` `salubrity`** against epidemics), and grant a **$15\%$ cost discount** on Multi-Tier Defense fortifications. | | `cul-interdeme-barter` | **Inter-Deme Barter Networks**
*Redes de Trueque Inter-Deme* | `+0.35` | `+0.40` | `+0.15` | `+0.30` | `+0.50` | `0.60` | Diplomatic and trade protocols that raise extraction (`+0.40`), mobility (`+0.30`), and social cohesion (`+0.50`), accelerating horizontal cultural diffusion and complementary inter-biome trade surpluses (up to $1.35\times$). | | `cul-warrior-expansion` | **Warrior / Expansionist Tradition**
*Tradición Guerrera / Expansionista* | `+0.45` | `+0.30` | `-0.35` | `+0.45` | `+0.60` | `0.45` | High-cohesion (`+0.60`), high-mobility (`+0.45`) militarized organization that drives territorial wars, plunder raids, and Hawk-Dove dominance at severe metabolic (`+0.45`) and ecological (`-0.35`) cost. | @@ -139,9 +139,9 @@ Every generation, the cognitive engine assigns a normalized probability distribu | `dec-conservative-forage` | `forage` | **Conservative Foraging**
*Forrajeo Conservador* | `+0.10` | `+0.35` | `+0.10` | `0.00` | `+0.05` | `0.05` | *None* | Default low-risk gathering within local regeneration limits; does not increment Baldwin counters. | | `dec-intensive-overexploit` | `overexploit` | **Intensive Overexploitation**
*Sobreexplotación Intensiva* | `+0.25` | `+0.85` | `-0.65` | `0.00` | `-0.10` | `0.10` | *None* | Maximizes immediate caloric intake (`+0.85`) while severely degrading habitat carrying capacity $K$ (`-0.65`); contributes to raid and betrayal propensity. | | `dec-selective-migration` | `migrate` | **Selective Migration**
*Migración Selectiva* | `+0.30` | `+0.15` | `+0.15` | `+0.75` | `+0.05` | `0.10` | *None* | High spatial mobility (`+0.75`) that boosts migration reach ($+0.25 \cdot P(\text{"migrate"})$) and drives population fission or martial territorial invasion. | -| `dec-cooperate-store` | `cooperate` | **Cooperation & Storage**
*Cooperación y Almacenamiento* | `+0.25` | `+0.45` | `+0.15` | `-0.10` | `+0.65` | `0.35` | *None* | Collective resource pooling and food preservation; contributes $0.35 \cdot P(\text{"cooperate"})$ to cultural innovation, fosters **Alliance Pacts**, **Defensive Coalitions**, **Multi-Tier Fortifications** (`defense_tier` 1–3), and **City Unification (Synoecism)**. | -| `dec-exchange-symbiosis` | `exchange` | **Exchange / Symbiosis**
*Intercambio / Simbiosis* | `+0.35` | `+0.50` | `+0.20` | `+0.20` | `+0.60` | `0.45` | `bio-encephalization` OR `bio-social-instinct` | Unlocks **$1.15\times\text{–}1.35\times$ complementary trade surplus** ($+12\%$ inter-biome, $+8\%$ cross-cultural; $1.20\times$ intra-hex), horizontal cultural trait transmission, **Tribute Appeasement**, and formal **Alliance Pacts** / **Synoecism**. | -| `dec-territorial-dispute` | `dispute` | **Territorial Dispute**
*Disputa Territorial* | `+0.55` | `+0.40` | `-0.30` | `+0.25` | `+0.55` | `0.20` | *None* | Agonistic **Hawk** strategy ($P(\text{"dispute"}) \ge 0.25$); seizes Dove biomass shares, triggers **Plunder Raids** and **Wars of Conquest**, or causes Hawk-Hawk clashes ($-15\%$ pop, $\times 1.25$ metabolic cost). | +| `dec-cooperate-store` | `cooperate` | **Cooperation & Storage**
*Cooperación y Almacenamiento* | `+0.25` | `+0.45` | `+0.15` | `-0.10` | `+0.65` | `0.35` | *None* | Collective resource pooling and food preservation; contributes $0.35 \cdot P(\text{"cooperate"})$ to cultural innovation, fosters **Alliance Pacts**, **Defensive Coalitions**, **Hex-Edge Fortifications** (`defense_tier` 1–3), **Asymmetric Category Absorption**, and **Continuous Multi-Hex Walled Unions (`walled_union_id`)**. | +| `dec-exchange-symbiosis` | `exchange` | **Exchange / Symbiosis**
*Intercambio / Simbiosis* | `+0.35` | `+0.50` | `+0.20` | `+0.20` | `+0.60` | `0.45` | `bio-encephalization` OR `bio-social-instinct` | Unlocks **$1.15\times\text{–}1.35\times$ complementary trade surplus** ($+12\%$ inter-biome, $+8\%$ cross-cultural), horizontal cultural trait transmission, **Tribute Appeasement**, and formal **Alliance Pacts** / **Dual Synoecism**. | +| `dec-territorial-dispute` | `dispute` | **Territorial Dispute**
*Disputa Territorial* | `+0.55` | `+0.40` | `-0.30` | `+0.25` | `+0.55` | `0.20` | *None* | Agonistic **Hawk** strategy ($P(\text{"dispute"}) \ge 0.25$); seizes Dove biomass shares, triggers **Plunder Raids (with rural hinterland pillaging into `has_ruins`)** and **Wars of Conquest**, or causes Hawk-Hawk clashes ($-15\%$ pop, $\times 1.25$ metabolic cost). | | `dec-explore-innovate` | `innovate` | **Exploration / Innovation**
*Exploración / Innovación* | `+0.50` | `+0.15` | `0.00` | `+0.40` | `+0.35` | `0.65` | *None* | High-cost (`+0.50`) experimentation that sacrifices immediate foraging yield (`+0.15`) to rapidly advance `innovation_counter`. | --- diff --git a/gh-pages/turn_iterations_mechanics.md b/gh-pages/turn_iterations_mechanics.md index a581b5f..8144429 100644 --- a/gh-pages/turn_iterations_mechanics.md +++ b/gh-pages/turn_iterations_mechanics.md @@ -1,6 +1,6 @@ # Generational Loop & Biophysical Mechanics -At the heart of **Evolution** is `EvoTurnEngine.advance_generation(state, lang)`, which advances the simulation by one discrete generation (half-year seasonal step: `year = generation / 2 + 1`). Every generation executes a strictly ordered **5-stage pipeline** that translates probabilistic deme intentions into deterministic ecological, demographic, and phylogenetic outcomes. +At the heart of **Evolution** is `EvoTurnEngine.advance_generation(state, lang)`, which advances the simulation by one discrete generation (half-year seasonal step: `year = generation / 2 + 1`). Every generation executes a strictly ordered **5-stage pipeline** under the **1-Deme-Per-Hex Exclusivity invariant (`cell.deme_ids.size() <= 1`)**, resolving each settlement's concrete chosen action (`deme.chosen_action`) through deterministic ecological, diplomatic, military, and demographic laws (`EvoPayoffs`). --- @@ -11,251 +11,84 @@ sequenceDiagram participant Main as EvoMainController participant Turn as EvoTurnEngine participant Env as 1. EvoEnvPhase - participant Cog as 2. EvoDecisionModel - participant Bio as 3. EvoBiophysPhase - participant Mile as 4. EvoMilestonePhase - participant Pre as 5. Async Prefetch + participant Bio as 2. EvoBiophysPhase + participant Mile as 3. EvoMilestonePhase + participant Cog as 4. EvoDecisionModel Main->>Turn: advance_generation(world_state, lang) Turn->>Env: run_environmental_phase(state, lang) Note over Env: Increment gen, flip season,
glacial sea cycle, flood/emerge,
regenerate K & R, +15 mana - Turn->>Cog: evaluate_all_demes(state, lang) - Note over Cog: Compute coarsened 10-part signatures,
6-gen memory & neighbor tags,
sync Ollama System 1 (75/25 blend) Turn->>Bio: run_biophysical_phase(state, lang) - Note over Bio: 3A. Ecology, Game Theory, Diplomacy & Walls:
Hawk-Dove, Extraction, Trade (up to 1.35x), K Degrade,
Pacts, Coalitions, Vendettas, Tribute, Raids & Fortification
3B. Demography, Sieges, Wars & Synoecism:
Metabolism & Upkeep, Growth, Wars/Sieges, Fission, Synoecism + Note over Bio: 2A. EvoRuralHinterland.sync_claims + EvoRelations.tick
2B. EvoEconomyPhase: Harvest, Hinterlands, Fortify, Innovate, K Degrade
2C. EvoDiplomacyPhase: Trade, Aid, Pacts, Tribute & Ultimatums
2D. EvoConflictPhase: Betrayals, Plunder Raids & Wars of Conquest
2E. EvoSettlementPhase: Unification, Settling, Salubrity, Growth & Extinction Turn->>Mile: run_milestone_phase(state, lang) Note over Mile: Synthesize top milestone trait
(Speciation > Baldwin > Innovation),
check Macro-Outcomes - Turn->>Pre: prefetch_for_state(state) - Note over Pre: Pre-warm up to 8 next-turn
signatures on background worker + Turn->>Cog: evaluate_all_demes + sync_and_enqueue_batch + Note over Cog: Build next turn's action menu & forecasts,
enqueue per-settlement /v1/systemone jobs Turn-->>Main: Return { generation, new_entries, has_milestone, has_macro_outcome } ``` --- -## Stage 1 & 2: Environment & Cognitive Scoring +## Stage 1: Environmental Phase (`EvoEnvPhase`) -1. **Environmental Phase (`EvoEnvPhase.run_environmental_phase`)**: - * Increments `state.generation += 1`, clears `state.recent_extinction_hexes`, and toggles `state.season` between `"wet_warm"` ($\gamma_{\text{season}} = 1.20$) and `"dry_cold"` ($\gamma_{\text{season}} = 0.75$). - * Archives active `"exchange"`, `"cooperate"`, and `"pact"` flows from the previous turn into `state.disused_connections` (incrementing `age += 1` on older severed links) before clearing `state.active_flows`. - * Applies glacial sea-level cycles (every 8 generations), coastal flooding/emergence, submerged deme relocation (`altitude <= 0.85` and `altitude - 0.50 <= v_eff.spatial_mobility`, with a $10\%$ transit population loss and `"migration"` flow) or drowning (`"extinction"` if no passable non-ocean refuge neighbor exists), passive $K$ recovery ($+5\%$ normal or $+0.5\%$ after a Tragedy of the Commons), and seasonal biomass $R$ regeneration. - * Regenerates player Selective Pressure ("Mana") points: `state.selective_pressure_points += state.selective_pressure_regen_per_turn` (`+15` per generation). -2. **Cognitive Phase (`EvoDecisionModel.evaluate_all_demes`)**: - * Evaluates every surviving deme via the Dual-Model System 1 Ollama engine (`/v1/systemone`) with its 6-generation historical memory digest and rich neighbor relational labels, blended (**$75\%$ live LLM / $25\%$ environmental & geopolitical pressures**), and assigns a normalized probability distribution $P(d)$ across all eligible behavioral decisions (`last_decision_probabilities`) along with `last_dominant_decision_id`, `last_confidence`, and `last_justification`. +1. Increments `state.generation += 1`, clears `state.recent_extinction_hexes`, and toggles `state.season` between `"wet_warm"` ($\gamma_{\text{season}} = 1.20$) and `"dry_cold"` ($\gamma_{\text{season}} = 0.75$). +2. Ages existing severed roads in `state.disused_connections` (`age += 1`, pruning links with `age > 4`) and clears `state.active_flows`. +3. Applies glacial sea-level cycles (every 8 generations), coastal flooding/emergence, submerged settlement relocation to adjacent empty passable hexes (`10%` transit loss, `20%` reserve penalty, `"migration"` flow) or drowning (`"extinction"`), passive carrying capacity $K$ recovery (`+5%` normal or `+0.5%` after a Tragedy of the Commons), and seasonal biomass $R$ regeneration. +4. Regenerates player Selective Pressure ("Mana") points (`+15` per generation). --- -## Stage 3A: Ecology, Extraction, Diplomacy, Raids & Fortifications (`EvoEcologyInteraction`) - -Once every deme has its probability distribution $P(d)$ and effective 6-axis vector $\mathbf{v}_{\text{eff}}$, `EvoEcologyInteraction.run_resource_and_symbiosis_phase` resolves intra-hex and inter-hex ecological, diplomatic, and defensive interactions in nine sequential steps: - -### Step 1: Initial Caloric Demand ($D_i$) -Each resident deme $i$ in a hexagon requests biomass proportional to its population $P_i$ and effective `extraction_efficiency`: - -$$D_i = \max\Big(0.0,\ P_i \times 0.50 \times \big(1.0 + 0.60 \cdot v_{\text{eff}, i}.\text{extraction\_efficiency}\big)\Big)$$ - -### Step 2: Archetype Classification -Within each hex, cohabiting demes are classified by summing their decision probabilities per archetype (`get_archetype_prob`): - -* **Hawk**: $P(\text{"dispute"}) \ge 0.25$ -* **Dove**: $P(\text{"dispute"}) < 0.25$ -* **Exchanger**: $P(\text{"exchange"}) \ge 0.20$ - -### Step 3: Hawk-Dove Territorial Conflict -When **2 or more demes of different lineages cohabit the same hex** (`resident_demes.size() >= 2` and `has_rival_lineages`): - -```mermaid -flowchart TD - Cohabit["Cohabiting Demes of >= 2 Lineages in Hex"] --> CountHawks{"How many Hawks\nP('dispute') >= 0.25?"} - CountHawks -- "Hawks >= 2 across >= 2 lineages\n(Hawk vs. Hawk)" --> HH["Escalated Territorial Skirmish\n• Each Hawk loses 15% pop (floor 10)\n• Each Hawk incurs ×1.25 metabolic penalty\n• Recompute D_hawk & spawn 'dispute' flow (0.75)"] - CountHawks -- "Hawks == 1 & Doves >= 1\n(Hawk vs. Dove)" --> HD["Asymmetric Resource Capture\n• M_capture = 0.25 · (1 + max(0, S_hawk − S̄_dove))\n• Hawk seizes B_actual = min(Σ D_dove, D_hawk · M_capture)\n• Doves lose demand proportionally & spawn 'dispute' flow (0.65)"] - CountHawks -- "Hawks == 0\n(Dove vs. Dove)" --> DD["Peaceful Co-Foraging\n• Proportional sharing of available R\n• Eligible for Intra-Hex Symbiosis (+20%)"] -``` - -* **Case A — Hawk vs. Hawk (`hawks.size() >= 2` and `has_rival_hawks`)**: - * Every Hawk suffers an immediate **$15\%$ demographic casualty loss** (with a minimum floor of $10$ individuals so a single skirmish does not instantly wipe out a viable settlement): - $$P_{\text{hawk}} \leftarrow \max\big(10,\ \lfloor 0.85 \cdot P_{\text{hawk}} \rfloor\big)$$ - * Every Hawk is penalized with a **$\times 1.25$ metabolic expenditure multiplier** (`hawk_penalty_by_deme[hawk.id] = 1.25`) for the remainder of the turn. - * Caloric demand $D_{\text{hawk}}$ is recomputed at the reduced population, and a `"dispute"` flow (`intensity = 0.75`) is created. -* **Case B — Hawk vs. Dove (`hawks.size() == 1` and `doves.size() >= 1`)**: - * The lone Hawk intimidates cohabiting Doves and captures a share of their caloric demand, scaled by the difference between the Hawk's `social_cohesion` and the Doves' mean cohesion $\bar{S}_{\text{dove}}$: - $$M_{\text{capture}} = 0.25 \times \Big(1.0 + \max\big(0.0,\ v_{\text{eff, hawk}}.\text{social\_cohesion} - \bar{S}_{\text{dove}}\big)\Big)$$ - $$B_{\text{actual}} = \min\left(\sum_{d \in \text{doves}} D_d,\ D_{\text{hawk}} \times M_{\text{capture}}\right)$$ - * The Hawk's demand increases by $+B_{\text{actual}}$, while each Dove's demand decreases proportionally by $B_{\text{actual}} \cdot (D_d / \sum D_{\text{doves}})$. - -### Step 4: Biomass Extraction ($E_i$) -Total cell demand $D_{\text{total}} = \sum_i D_i$ is harvested against available hex resources $R_{\text{cell}}$: - -$$E_{\text{total}} = \min(R_{\text{cell}},\ D_{\text{total}}), \qquad E_i = \begin{cases} E_{\text{total}} \cdot \dfrac{D_i}{D_{\text{total}}} & \text{if } D_{\text{total}} > 0 \\ 0.0 & \text{otherwise} \end{cases}$$ - -$$R_{\text{cell}} \leftarrow \max\big(0.0,\ \text{snapped}(R_{\text{cell}} - E_{\text{total}},\ 0.01)\big)$$ - -### Step 5: Reciprocal Symbiosis, Complementary Inter-Biome/Cross-Cultural Trade & Horizontal Transmission -Symbiosis operates both **intra-hex** (among cohabiting Exchangers with $P(\text{"exchange"}) \ge 0.20$) and **inter-hex** (between an Exchanger and any Exchanger in an adjacent passable neighbor hex with `altitude <= 0.85`): - -1. **Complementary Caloric Trade Surplus (`1.15x` to `1.35x`)**: - * **Intra-Hex Symbiosis**: Each cohabiting Exchanger receives a one-time **$+20\%$ boost** ($E_i \leftarrow E_i \times 1.20$). - * **Inter-Hex Complementary Trade**: When trading across neighboring hexes, the caloric multiplier rewards ecological and cultural complementarity: - $$M_{\text{trade}} = 1.15 + \begin{cases} 0.12 & \text{if } \text{biome}_A \neq \text{biome}_B \\ 0.00 & \text{otherwise} \end{cases} + \begin{cases} 0.08 & \text{if } \text{cultural\_trait\_ids}_A \neq \text{cultural\_trait\_ids}_B \\ 0.00 & \text{otherwise} \end{cases}$$ - scaling extracted energy up to **$1.35\times$ (`+35%`)** when exchanging across distinct biomes ($+12\%$) and distinct cultural repertoires ($+8\%$). -2. **Active Trade Road Flow**: Spawns an `"exchange"` `HexEdgeFlow` (`intensity = 0.60` intra-hex, `0.55` inter-hex) rendered as a cobblestone trade road or wooden bridge with caravan carts. -3. **Horizontal Cultural Diffusion**: For every ordered pair `(donor, receiver)` of interacting Exchangers: - * If `receiver` has biological cultural capacity (`enables_cultural_transmission == true`, i.e., `bio-encephalization` or `bio-social-instinct`), - * And for any cultural tradition `tid` in `donor.cultural_trait_ids` not yet known by `receiver`: - $$v_{\text{eff, receiver}}.\text{social\_cohesion} \ge \text{trait.vector.transmission\_threshold}$$ - * Then `receiver` immediately adopts `tid` into `receiver.cultural_trait_ids`, recomputes its effective vector $\mathbf{v}_{\text{eff}}$, and records a `"symbiosis"` entry in the Chronicle. - -### Step 6: Carrying Capacity ($K$) Degradation, Tragedy of the Commons & Niche Construction -After extraction, the resident demes' combined ecological impact $\Sigma_{\text{eco}} = \sum_{i} v_{\text{eff}, i}.\text{ecological\_impact}$ modifies the hexagon's structural carrying capacity $K$: - -* **Overexploitation & Habitat Degradation**: - Triggered when extraction exceeds natural seasonal regeneration ($E_{\text{total}} > K_{\text{cell}} \cdot r_{\text{regen}}$ or $R_{\text{cell}} \le 0$ with $D_{\text{total}} > K_{\text{cell}} \cdot r_{\text{regen}}$) **AND** either $\Sigma_{\text{eco}} < -0.15$ or ($\exists i: P_i(\text{"overexploit"}) \ge 0.30 \land \Sigma_{\text{eco}} < -0.10$): - $$K_{\text{degraded}} = \max\Big(10.0,\ \text{snapped}\big(K_{\text{cell}} \times (1.0 + 0.18 \cdot \Sigma_{\text{eco}}),\ 0.01\big)\Big)$$ - * **Tragedy of the Commons Milestone**: The first time a hexagon's degraded carrying capacity drops below **$60\%$ of $K_{\max}$**: - $$K_{\text{degraded}} < 0.60 \times K_{\max}$$ - a `"tragedy_of_commons"` Chronicle entry is logged, and the hex's recovery rate on the following turn drops tenfold (from $5.0\%$ to $0.5\%$). -* **Positive Ecological Niche Construction**: - Conversely, if degradation did not trigger, no resident deme is overexploiting ($P(\text{"overexploit"}) < 0.30$), and $\Sigma_{\text{eco}} > +0.10$ (e.g., demes practicing `cul-communal-conservation`), the population actively enriches its habitat: - $$K_{\text{cell}} \leftarrow \text{clamp}\Big(\text{snapped}\big(K_{\text{cell}} \times (1.0 + 0.05 \cdot \Sigma_{\text{eco}}),\ 0.01\big),\ 10.0,\ K_{\max}\Big)$$ - -### Step 7: Alliance Pacts, Defensive Coalitions, Opportunistic Betrayals & Vendettas (`_run_pacts_and_betrayals`) -Next, `EvoEcologyInteraction` evaluates all cohabiting and adjacent passable (`altitude <= 0.85`, non-ocean) deme pairs `pair_key = "idA__idB"` tracked in `state.active_pacts`: - -* **Multi-Turn Vendettas / Grudges (`active_pacts < 0`, `EvoTypes.is_vendetta_active`)**: - * Negative values in `state.active_pacts[pair_key]` represent active hostility memory (set to `-4` turns after a betrayal, `-3` after a territorial conquest, and `-2` after a raid or repelled war). - * Each turn, a negative vendetta counter increments by `+1` toward `0` (deleting the entry once it reaches `0`). While `prev_turns < 0`, the pair cannot form an Alliance Pact and appears as `vendetta` in cognitive neighbor briefings and the HUD Inspector. -* **Opportunistic Betrayal (`_execute_betrayal`)**: - If a formal pact already exists (`prev_turns >= 2`) and one partner is under severe demographic or ecological stress (`energy_reserves < 42.0`, `last_energy_delta <= -6.0`, `R < 0.25 * P`, or hex crowding $> 0.72 \cdot K$) **AND** exhibits opportunistic or aggressive tendencies ($P(\text{"overexploit"}) + P(\text{"dispute"}) \ge 0.24$, `cul-warrior-expansion`, `strat-war`, or `social_cohesion < 0.35`)—and is not deterred by a heavily fortified partner (`victim.defense_tier >= 2` deters betrayal unless betrayer hostility exceeds cooperation $+ 0.15$)—the stressed partner breaks the pact in a surprise ambush: - 1. **Conserved Caloric Plunder**: Plunders $\text{loot}_{\text{desired}} = \text{clamp}(0.30 \cdot E_{\text{reserves, victim}} + 0.25 \cdot E_{\text{victim}},\ 10.0,\ 45.0)$ directly from the victim's `energy_reserves` and current-turn extracted energy $E_{\text{victim}}$, adding the exact stolen sum to the betrayer's reserves (capped at `220.0`). - 2. **Ambush Casualties**: Inflicts $\max(3,\ \text{round}(0.14 \cdot P_{\text{victim}}))$ casualties on the victim (floor `5`). - 3. **Deep Vendetta & Chronicle**: Sets `state.active_pacts[pair_key] = -4` (a 4-turn blood vendetta), records a severed link in `state.disused_connections`, spawns a crimson `"betrayal"` flow (`intensity = 1.0`), and logs a `"betrayal"` Chronicle entry. -* **Alliance Pact Formation, Defensive Coalitions & Mutual Aid**: - * **Defensive Coalition Threshold**: Normally, pact formation requires $P(\text{"cooperate"}) + P(\text{"exchange"}) \ge 0.30$, $P(\text{"dispute"}) < 0.22$, and no `cul-warrior-expansion`. However, when either partner faces an adjacent non-allied warrior threat (`_has_adjacent_warrior_threat`), the required cooperation threshold drops to **`0.22`** (and two strongly cooperative demes $\ge 0.36$ can form a defensive pact even if carrying `cul-warrior-expansion`), incrementing `state.active_pacts[pair_key]` by $+1$. - * **Formalization (`next_turns == 2`)**: When a pair reaches `2` consecutive cooperative turns (`EvoTypes.is_pact_active == true`), a `"pact"` Chronicle entry is recorded. - * **$+15\%$ Mutual Caloric Bonus, Distress Aid & Shared Defense (`next_turns >= 2`)**: Each ally that did not already receive the symbiosis bonus receives a **$+15\%$ boost** to its extracted energy ($E_i \leftarrow E_i \times 1.15$). If one partner is in caloric distress while the other holds surplus reserves ($E_{\text{reserves}} > 60.0$), the surplus ally transfers $\min(14.0,\ (E_{\text{reserves}} - 50.0) \times 0.25)$ energy to the distressed partner. Spawns an emerald `"pact"` highway flow (`intensity = 0.80`) and provides up to **$+1.10$ shared military defense** (`compute_pact_defense_bonus`, where each cohabiting/adjacent ally contributes $0.25 \cdot \text{clamp}(P_{\text{ally}} / \max(20, P_{\text{victim}}), 0.4, 1.5) + 0.08 \cdot \text{ally.defense\_tier}$) against raids and invasions. - -### Step 8: Cross-Border Plunder Raids & Tribute Appeasement (`_run_raids`) -Non-allied, non-close-kin cohabiting or adjacent demes (`altitude <= 0.85`) with raiding capability (`cul-warrior-expansion`, `strat-war`, $P(\text{"dispute"}) \ge 0.20$, or $P(\text{"overexploit"}) \ge 0.30$) and $P(\text{"dispute"}) + P(\text{"overexploit"}) \ge 0.28$ under environmental pressure attempt **Plunder Raids** (`_execute_raid_if_successful`) against targets with `defense_tier < 3` (**Tier 3 Castles are completely immune to minor plunder raids**): - -* **Force Comparison**: The attacker's `raider_score` (scaled by population, $P(\text{"dispute"})$, `social_cohesion`, `spatial_mobility`, and `+0.35` for `cul-warrior-expansion`) is compared against the target's `defense_score`, which adds `DEFENSE_TIER_BONUS[defense_tier]` (`+0.45` at Lv.1, `+0.95` at Lv.2, `+1.55` at Lv.3) and `compute_pact_defense_bonus` (up to `+1.10`). -* **Tribute / Appeasement (`tribute`)**: - If an under-fortified cooperative or mercantile settlement (`defense_tier < 2`, `energy_reserves >= 55.0`, and either $P(\text{"exchange"}) \ge 0.28$ or $(P(\text{"cooperate"}) + P(\text{"exchange"}) \ge 0.65 \land P(\text{"dispute"}) < 0.10)$) faces a superior raider (`raider_score > defense_score * 0.92`), it deters the raid through diplomatic tribute: - 1. **18% Reserve Tribute**: Transfers $\text{tribute} = \text{clamp}(0.18 \cdot E_{\text{reserves, target}},\ 10.0,\ 28.0)$ directly from the target's reserves to the raider (capped at `240.0`). - 2. **Zero Casualties & Temporary Truce**: Neither population suffers casualties, `state.active_pacts[pair_key]` is set to `1` (establishing a temporary truce and clearing any prior vendetta), a golden `"tribute"` caravan flow (`intensity = 0.75`) is spawned, and a `"tribute"` Chronicle entry is logged. -* **Successful Plunder Raid (`raider_score > defense_score * 0.75`)**: - 1. **Fortification Loot & Casualty Mitigation**: Perimeter defenses scale down both plundered calories (`loot_shield_mult`: `0.75x` at Lv.1 Palisade, `0.40x` at Lv.2 Stone Wall) and defender casualties (`cas_shield_mult`: `0.75x` at Lv.1, `0.50x` at Lv.2). - 2. **Conserved Loot Transfer**: Plunders $\text{loot}_{\text{desired}} = \text{clamp}\big((0.28 \cdot E_{\text{reserves, victim}} + 0.24 \cdot E_{\text{victim}}) \cdot \text{force\_ratio} \cdot \text{loot\_shield\_mult},\ 6.0,\ 42.0\big)$ from the victim's `energy_reserves` and extracted energy $E_{\text{victim}}$ and transfers the exact stolen calories to the raider (`<= 240.0`). - 3. **Skirmish Casualties, Vendetta & Visuals**: Raider loses $2.5\%$ population (`max 1`), defender loses $4.0\% \times \text{cas\_shield\_mult}$ (`max 1`), `state.active_pacts[pair_key]` is set to $\le -2$ (vendetta), an orange `"raid"` flow is spawned, and a `"raid"` Chronicle entry is logged. - -### Step 9: Multi-Tier Defense Fortification (`_run_defense_fortification_phase`) -Settlements with `defense_tier < 3` that experience security pressure (facing an adjacent rival, an active vendetta, or practicing `dec-cooperate-store` / `cul-communal-conservation` / `cul-symbolic-storage` / `bio-k-strategy` with $P(\text{"cooperate"}) + P(\text{"dispute"}) \ge 0.20$) can upgrade their perimeter fortifications by one tier per turn: - -| Tier | Fortification Name | Defense Bonus | Raid Protection | Requirements (`pop` / `reserves` / `hex R`) | Base Cost (`reserves` + `hex R`) | Upkeep / Turn | -| :---: | :--- | :---: | :--- | :---: | :---: | :---: | -| **Lv. 1** | **Palisade & Watchtower** | **`+0.45` (`+45%`)** | `-25%` raid loot & casualties (`0.75x`) | `pop >= 38`, `res >= 58`, `R >= 35` | `25.0` reserves + `15.0` hex $R$ | `1.5` energy | -| **Lv. 2** | **Stone Wall & Fort** | **`+0.95` (`+95%`)** | `-60%` raid loot (`0.40x`), `-50%` casualties (`0.50x`), betrayal deterrence | `pop >= 65`, `res >= 92`, `R >= 55` | `45.0` reserves + `25.0` hex $R$ | `3.0` energy | -| **Lv. 3** | **Castle / Fortified Citadel** | **`+1.55` (`+155%`)** | **Immune to minor plunder raids**, heavy siege attrition to attackers | `pop >= 100`, `res >= 130`, `R >= 75` | `65.0` reserves + `35.0` hex $R$ | `5.0` energy | - -* **15% Conservation & Storage Discount (`* 0.85`)**: Demes carrying `cul-communal-conservation`, `cul-symbolic-storage`, or `bio-k-strategy` receive a **$15\%$ reduction** on required reserves, reserve cost, and hex biomass cost. -* **Allied Co-Financing**: If a deme has at least `req_res - 35.0` reserves, cohabiting or adjacent Pact allies holding `energy_reserves >= 80.0` automatically co-finance the shortfall (contributing up to $\min(30.0,\ E_{\text{reserves, ally}} - 55.0)$ per ally). Upgrading to Tier 2 or Tier 3 logs a `"fortify"` Chronicle entry. +## Stage 2: Biophysical & Social Resolution (`EvoBiophysPhase`) + +### 2A. Rural Hinterland Sync & Relation Aging (`EvoRuralHinterland.sync_claims` & `EvoRelations.tick`) +* **Tier Hinterland Quotas (`RURAL_QUOTA = [0, 0, 1, 2, 4]`)**: Tier II–IV settlements automatically claim up to `1`, `2`, or `4` adjacent uninhabited passable hexes (`altitude <= 0.85`), converting arid/tundra hexes into `"pasture"` (`pasture_pen` + `granary`) and fertile hexes into `"crops"` (`crop_field` + `windmill`). +* **Relation & Ultimatum Lifecycle (`EvoRelations.tick`)**: + * If a neighbor issued a `demand_tribute` ultimatum on the previous turn and the target settlement did **not** choose `pay_tribute` this turn, `EvoRelations._refuse_tribute` immediately resolves a **punitive enforcement raid** against the refuser. + * Increments active alliance pacts (`pact_turns += 1`), decrements active feuds (`feud_turns -= 1`) and truces (`truce_turns -= 1`), decays bilateral trust slightly toward `0`, and transitions any inactive friendly road from the previous turn into `state.disused_connections` (`age = 1`). + +### 2B. Domestic Economy (`EvoEconomyPhase.resolve`) +1. **Wild + Rural Harvest (`EvoPayoffs.expected_income`)**: + * Each settlement harvests wild food from its host hex (scaled by extraction efficiency, urban foraging factor `[1.0, 1.0, 1.0, 0.55, 0.30]`, `+10%` if `forage`, or `+35%` if `intensify`), plus `min(R_rural, 0.35 * pop) * 0.70` from each owned rural hinterland hex, multiplied by **`1.12x` (`+12%`)** if the settlement has at least one active alliance pact. +2. **Carrying Capacity ($K$) Degradation vs. Conservation**: + * Choosing `intensify` permanently degrades host hex carrying capacity $K$ by **`-7%`** (`INTENSIFY_K_DROP = 0.07`). Dropping below **$60\%$ of $K_{\max}$** triggers a **Tragedy of the Commons (`"tragedy_of_commons"`)**, cutting natural $K$ recovery tenfold. + * Choosing `forage` with positive ecological impact (`cul-communal-conservation`) gently enriches $K$ toward $K_{\max}$. +3. **Perimeter Fortifications (`fortify`) & Innovation (`innovate`)**: + * **`fortify`**: Spends `[22, 40, 60]` food reserves (with a `15%` discount for `cul-communal-conservation` or `bio-k-strategy`) to upgrade `defense_tier` (`Lv.1 Palisade` `+35%` defense / `30%` loot shield $\to$ `Lv.2 Stone Wall` `+75%` defense / `60%` loot shield $\to$ `Lv.3 Castle` `+130%` defense / `80%` loot shield). + * **`innovate`**: Advances `innovation_counter` by `EvoPayoffs.innovation_step(deme, state)` (`0.38` base, up to `0.58` with `bio-encephalization` and `cul-fire-processing`). + +### 2C. Diplomacy & Exchange (`EvoDiplomacyPhase.resolve`) +* **Trade (`trade`)**: + * **Mutual Trade** (both neighbors target each other with `trade`): Both earn `100%` of `EvoPayoffs.trade_gain` (`18%` base + `10%` complementary biome + `7%` distinct culture, up to `+35%` of harvest), `+0.20` bilateral trust, and bilateral horizontal cultural diffusion (`"symbiosis"`). + * **Unilateral Trade Caravan**: The initiator earns `55%` of `trade_gain` (`EvoPayoffs.unreciprocated_trade_gain`), its partner earns `25%`, bilateral trust rises by `+0.12`, and cultural knowledge diffuses across the border. +* **Surplus Aid (`aid`)**: Transfers `clamp(0.18 * reserves, 10, 32)` food to the target neighbor, boosting bilateral trust by `+0.25`, global reputation by `+0.10`, and automatically sealing any pending alliance offer. +* **Alliance Pacts (`offer_pact`, `accept_pact`)**: + * `accept_pact` (or mutual `offer_pact`, or `offer_pact` toward same-lineage kin or a neighbor with `trust >= 0.30`) immediately seals a formal alliance (`pact_turns = 1`, `trust +0.25`, `"pact"` flow and Chronicle entry). Otherwise, `offer_pact` registers an open proposal (`proposal_kind = "pact"`) for the target's next turn. +* **Tribute Coercion (`demand_tribute`, `pay_tribute`)**: + * `demand_tribute` issues an explicit ultimatum (`proposal_kind = "tribute"`, `proposal_amount = clamp(0.22 * target.reserves, 10, 38)`). + * Choosing `pay_tribute` transfers the demanded food with zero casualties and buys a **2-turn truce (`truce_turns = 2`)**. + +### 2D. Conflict, Betrayal & Conquest (`EvoConflictPhase.resolve`) +1. **Opportunistic Betrayal (`betray`)**: + * Breaks an active alliance (`pact_turns = 0`, `feud_turns = 4`), inflicts **`-0.50` global reputation** and `-0.70` bilateral trust, and launches a surprise attack (`+35%` combat power, halves defender wall bonus, `+25%` loot bonus). +2. **Plunder Raids (`raid`) & Hinterland Pillaging (`has_ruins = true`)**: + * Resolves stochastic combat via `EvoPayoffs.raid_odds(att, def, state, false)`. On attacker victory, steals reserves (mitigated by defender `WALL_LOOT_SHIELD`), inflicts casualties, and **pillages one owned rural hinterland hex** (`+18.0` extra food, resets `owner_deme_id = ""`, `land_use = "wild"`, and marks `has_ruins = true`). On defender victory, logs a repelled `"siege"`. +3. **Wars of Conquest (`conquer`)**: + * Dispatches `45%` of the attacker's population (`warband`) to seize the target hex (`EvoPayoffs.conquest_odds`). + * **Attacker Victory**: Captures `50%` of defender reserves and seizes the hex. If the surviving defender (`population >= 10`) has an adjacent empty passable hex, it **retreats** there; otherwise it is **absorbed in place** (`1/3` assimilated), preserving `cell.deme_ids.size() <= 1`. A new warband settlement (`-w`) occupies the conquered hex and inherits adjacent rural hinterlands up to its tier quota. + +### 2E. Unification, Frontier Settling & Demography (`EvoSettlementPhase.resolve`) +1. **Unification (`unite` / `accept_union`)**: + * When two allied towns (`pact_turns >= 3`, both `tier >= 2`) unite via `accept_union` or mutual `unite`: + * **Same-Tier Cities (`tier_a == tier_b >= 2`)**: Form a **Continuous Multi-Hex Walled Union (`walled_union_id = "union--"`, max 3 hexes)**, upgrading `defense_tier >= 1`, culling their shared internal perimeter wall, linking plazas via a golden `"merge"` boulevard, and pooling `+55%` population and `+25%` wall bonus in combat. + * **Different-Tier Towns (`tier_a != tier_b`)**: The lower-tier settlement merges into the higher-tier host city (**Asymmetric Category Absorption**), expanding host $K$ by `+35` and converting the vacated hex into a cultivated rural hinterland (`"crops"` or `"pasture"`). +2. **Frontier Colonization (`settle`)**: + * Sends `35%` of the parent settlement's population (`min 24`, requiring `population >= 80`) and proportional food reserves to found a new daughter camp (`-m`) on the chosen empty passable hex. +3. **Metabolism, Urban Salubrity (`salubrity`), Growth & Epidemics (`"epidemic"`)**: + * Deducts metabolic food need (`EvoPayoffs.food_need`, including wall upkeep `[0, 1.5, 3.0, 5.0]`), updates `salubrity`, applies demographic growth/decline (`r/K` life-history modifiers), triggers **Urban Epidemics (`22%` mortality)** when `salubrity < 0.35` and `population >= 130`, and records any extinction (`population < 10`, leaving stone ruins `has_ruins = true`). --- -## Stage 3B: Demography, Upkeep, Territorial Wars, Sieges & Synoecism (`EvoDemographyMigration`) - -### Step 1: Metabolic Expenditure, Fortification Upkeep & Food Storage Buffering -Each deme expends energy $C_{\text{metabolic}}$ based on its population $P_i$, effective `metabolic_cost`, ambient thermal extremity $|T_{\text{cell}}|$ (mitigated by positive `spatial_mobility`), and any Hawk-Hawk combat penalty ($\text{hawk\_mult} \in \{1.0, 1.25\}$), plus per-turn **fortification maintenance upkeep** $C_{\text{upkeep}} \in \{0.0, 1.5, 3.0, 5.0\}$ for `defense_tier` `0..3`: - -$$C_{\text{metabolic}} = P_i \times 0.42 \times \Big(1.0 + 0.55 \cdot v_{\text{eff}}.\text{metabolic\_cost} + 0.20 \cdot |T_{\text{cell}}| \cdot \max\big(0.0,\ 1.0 - \max(0.0, v_{\text{eff}}.\text{spatial\_mobility})\big)\Big) \times \text{hawk\_mult}$$ - -The raw net energy balance is $\Delta E = E_i - C_{\text{metabolic}} - C_{\text{upkeep}}$. If a fortified settlement exhausts its reserves ($E_{\text{reserves}, t+1} \le 0.0$) under severe starvation ($\Delta E < -8.0$), its unmaintained walls degrade by `defense_tier -= 1`. If the deme runs a caloric deficit ($\Delta E < 0.0$) but holds substantial stored reserves ($E_{\text{reserves}, t} > 0.30 \cdot P_i$), up to **$50\%$ of its prior reserves** buffers the deficit for demographic calculations: - -$$\Delta E_{\text{eff}} = \begin{cases} \Delta E + \min(-\Delta E,\ 0.50 \cdot E_{\text{reserves}, t}) & \text{if } \Delta E < 0.0 \;\land\; E_{\text{reserves}, t} > 0.30 \cdot P_i \\ \Delta E & \text{otherwise} \end{cases}$$ - -$$E_{\text{reserves}, t+1} = \max\big(0.0,\ \text{snapped}(E_{\text{reserves}, t} + \Delta E,\ 0.01)\big)$$ - -### Step 2: Demographic Growth & $r/K$ Selection Modifiers -The base per-turn demographic growth rate $g_0$ is bounded between $-45\%$ and $+35\%$: - -$$g_0 = \text{clamp}\left(\frac{\Delta E_{\text{eff}}}{\max(10.0,\ P_i)} \times 0.45,\ -0.45,\ +0.35\right)$$ - -Life-history traits scale $g_0$ before re-clamping to $[-0.45, +0.35]$: - -* **`bio-r-strategy` (Prolific, Density-Dependent on Crowding $P_{\text{hex}} / K$)**: - $$m_r = \begin{cases} 1.28 & \text{if } P_{\text{hex}} / K < 0.55 \\ 1.16 & \text{if } 0.55 \le P_{\text{hex}} / K < 0.65 \\ 1.05 & \text{if } P_{\text{hex}} / K \ge 0.65 \end{cases}, \qquad g = \text{clamp}(g_0 \times m_r,\ -0.45,\ +0.35)$$ -* **`bio-k-strategy` (Longevity & Parental Care)**: $g = \text{clamp}(g_0 \times 0.88,\ -0.45,\ +0.35)$ - -$$P_{t+1} = \begin{cases} P_i - 1 & \text{if } \Delta E_{\text{eff}} < 0.0 \;\land\; \text{round}\big(P_i \cdot (1.0 + g)\big) \ge P_i \\ \text{round}\big(P_i \cdot (1.0 + g)\big) & \text{otherwise} \end{cases}$$ - -### Step 3: Baldwin & Cultural Innovation Counter Accumulation -* **Baldwin Counters (`baldwin_counters[dec_id]`)**: Every non-foraging decision (`archetype != "forage"`) with probability $P(\text{dec\_id}) \ge 0.35$ increments its integer counter by $+1$. -* **Cultural Innovation Counter (`innovation_counter`)**: If the deme has cultural capacity (`bio-encephalization` or `bio-social-instinct`), its innovation counter advances by: - $$\Delta I = \big(P(\text{"innovate"}) + 0.35 \cdot P(\text{"cooperate"})\big) \times 0.45$$ - $$\text{innovation\_counter}_{t+1} = \min\big(2.0,\ \text{snapped}(\text{innovation\_counter}_t + \Delta I,\ 0.0001)\big)$$ - -### Step 4: Territorial Wars of Conquest, Fortified Sieges & Population Fission -When a deme with $P_i \ge 20$ evaluates expansion into neighboring hexes, passability (`is_hex_passable_for_deme`) requires **`ncell.biome != "ocean"`**, **`ncell.altitude <= 0.85`** (impassable mountain peaks above `0.85` block traversal), and `ncell.altitude - 0.50 <= mig_mobility`, where: - -$$\text{mig\_mobility} = \text{clamp}\big(v_{\text{eff}}.\text{spatial\_mobility} + 0.25 \cdot P(\text{"migrate"}),\ -1.5,\ +1.5\big)$$ - -(elevated to at least `0.35` during emergency scarcity, on `coastal_shallows`, or for martial expanders). - -1. **Territorial Wars of Conquest & Fortified Sieges (`_execute_territorial_invasion`)**: - * **Trigger**: A martial expander (`cul-warrior-expansion`, `strat-war`, or $P(\text{"dispute"}) \ge 0.28$) with $P_i \ge 32$ and $P(\text{"dispute"}) + P(\text{"migrate"}) \ge 0.30$ (with hex crowding $\ge 0.28$ or $P(\text{"dispute"}) \ge 0.26$) prioritizes invading adjacent hexes occupied by non-allied rival demes (`_pick_rival_target_in_hex`). - * **Warband, Flanking & Fortification Force Resolution**: The attacker commits a warband of $\text{warband\_pop} = \text{clamp}(\text{round}(0.48 \cdot P_i),\ 12,\ P_i - 14)$ warriors. Combat strength `_compute_combat_forces` weighs: - * **Attacker Force (`att_force`)**: Warband population, `social_cohesion`, `spatial_mobility`, $P(\text{"dispute"})$, `cul-warrior-expansion`, and a **Flanking Bonus** (`+0.28` per allied/same-lineage deme with `pop >= 35` adjacent to the target hex, up to `+0.85`). - * **Defender Force (`def_force`)**: Defender population, `social_cohesion`, high-ground altitude, `DEFENSE_TIER_BONUS[defender.defense_tier]` (`+0.45` Lv.1, `+0.95` Lv.2, `+1.55` Lv.3), and `compute_pact_defense_bonus` (up to `+1.10` from adjacent pact allies). - * **Attacker Victory (`att_force >= def_force`)**: The warband seizes $38\%$ of the defender's `energy_reserves`, inflicts combat casualties on both sides, forces the surviving defender to retreat to an adjacent passable hex with `defense_tier = 0` (or eliminates it if trapped), establishes a conquered settlement (`-w`) on the target hex inheriting `strategy_id`, `lineage_id`, and **captured fortifications (`max(0, defender.defense_tier - 1)`)**, sets a `-3` vendetta, spawns a red `"war"` invasion flow (`intensity = 1.0`), and logs a `"war"` victory Chronicle entry. - * **Fortified Siege / Invasion Repelled (`att_force < def_force`)**: - * **Heavy Siege Attrition**: The attacking warband suffers $(42\% + 6\% \cdot \text{def\_tier})$ casualties and retreats back to the attacker (`-15.0` energy reserves), while the defender suffers reduced casualties $(16\% - 2\% \cdot \text{def\_tier})$. - * **Wall Repair vs. Siege Degradation**: If `defender.defense_tier >= 1`, an allied or unified defender holding `energy_reserves >= 10.0` spends `10.0` energy to repair and preserve its fortifications; otherwise, an isolated defender against a massive warband (`warband_pop >= 55%` of `defender.population`) suffers `defense_tier -= 1` wall damage. - * **Chronicle & Siege Flow**: Strengthens the defender's formal pacts (`+1`), sets a `-2` vendetta with the attacker, spawns a crimson `"siege"` flow (`intensity = 0.90`, with siege engine props) when `def_tier >= 1` (or `"war"` when unfortified), and logs a `"siege"` (or `"war"`) Chronicle entry. -2. **Peaceful Population Fission (Budding Migration)**: - * **Migrant Cohort Size**: $\text{migrants} = \lfloor P_i \times \max(P(\text{"migrate"}),\ 0.28 \text{ if emergency/shallows}) \times 0.45 \rfloor$. - * **Trigger**: Requires $\text{migrants} \ge 10$, remaining parent population $P_i - \text{migrants} \ge 10$, and $P(\text{"migrate"}) \ge 0.15$, `dec-selective-migration`, `"coastal_shallows"`, or emergency scarcity ($R_{\text{cell}} < 0.20 \cdot P_i \land E_{\text{reserves}} < 0.35 \cdot P_i$). - * **Fission Mechanics & Strategy Identity Inheritance**: The parent deme sends `migrants` and a proportional share of `energy_reserves` (minus a **$15\%$ transit energy loss**) into the top-ranked passable neighbor hex (`Score = R / (deme_ids.size() + 1)`) as a daughter deme (`-m`), inheriting `daughter.lineage_id = deme.lineage_id` and **`daughter.strategy_id = deme.strategy_id`**, and spawning a `"migration"` flow. - -### Step 5: Extinction & Demographic Bottleneck Cultural Loss -* **Minimum Viable Population Extinction**: Any deme whose population falls below **$10$ individuals** (`deme.population < 10`) goes extinct. It is removed from the world, spawns `ruins` on its hex, appends the hex to `state.recent_extinction_hexes`, logs an `"extinction"` Chronicle entry, and—if no living demes of its `lineage_id` remain—marks `extinct_generation = state.generation` on its `LineageNode`. -* **Cultural Loss (Tasmanian Effect / Demographic Bottleneck)**: If a surviving non-newly-budded deme with cultural traditions suffers either: - 1. **Acute Collapse**: Losing $\ge 50\%$ of its turn-start population ($P_{\text{current}} \le 0.50 \cdot P_{\text{initial}}$), or - 2. **Sub-Critical Bottleneck**: Dropping below **$50$ individuals** ($P_{\text{current}} < 50$), - the deme loses its most complex cultural tradition (the trait in `cultural_trait_ids` with the highest `transmission_threshold`) and logs a `"cultural_loss"` Chronicle entry. - -### Step 6: Compatible Kin Merging, City Unification (Synoecism) & Isolation Tracking -* **Compatible Kin Merging (Intra-Hex)**: If two demes of the **same `lineage_id`** cohabit the same hex and their Jaccard trait divergence is $< 0.25$, they fuse into a single consolidated settlement (summing `population` and `energy_reserves`, taking the union of traits, and computing population-weighted averages of `innovation_counter` and `baldwin_counters`). -* **City Unification / Synoecism (`merge`, Tier 5 Unified Metropolis)**: - Long-standing allied settlements—even across **different lineages and founding strategies**—can unite into a single multi-lineage city (`_merge_deme_into(..., is_synoecism = true)` and `_run_adjacent_synoecism`): - 1. **Cohabiting Synoecism**: Two demes sharing the same hex with a deep formal alliance (`active_pacts >= 3`) unite immediately. - 2. **Adjacent-Hex Synoecism**: Two passable neighboring settlements (`distance == 1`) with `active_pacts >= 4` (or **`active_pacts >= 3` when either settlement is fortified `defense_tier >= 1` or faces an adjacent rival threat**), both having $P(\text{"cooperate"}) + P(\text{"exchange"}) \ge 0.30$ and combined population $\le 1.25 \times \max(K_{\max, 1}, K_{\max, 2})$, unite into the stronger/better-fortified host hexagon. - 3. **Urban & Cognitive Effects**: - * Combines `population` and `energy_reserves` (capped at `280.0`), merges compatible biological and cultural traits, transfers all active pacts, increments `deme.merged_count += 1 + joiner.merged_count`, and records the partner's strategy emblem color in `deme.secondary_color` (rendered as a twin-pennant flagpole on the unified plaza). - * Elevates shared fortifications (`defense_tier = min(3, max(2, combined_tier + 1))` when `population >= 90`) and expands the host hexagon's carrying capacity $K$ (`min(K_max * 1.35, K + 35.0)`). - * Elevates the settlement to **Architectural Tier 5 (`V · Metropolis`)** when `merged_count >= 1` and (`population >= 220` or `defense_tier >= 3`), spawns a golden `"merge"` unification avenue flow (`intensity = 0.85`), and logs a `"merge"` Chronicle entry. - * **Direct LLM Request Reduction**: Because two subpopulations consolidate into a single `Deme` instance, synoecism directly reduces the number of active LLM inference evaluations per generation—without counting as a competitive extinction for `single_survivor` hegemony. -* **Geographic Isolation Counter (`isolation_turns`)**: Reset to `0` if the deme is on its lineage's `founder_hex_id`, merged with kin or allies this turn, cohabits with another living deme of the same `lineage_id`, or is connected within **3 passable hex hops** (`_is_connected_to_founder`: `altitude <= 0.85` and `altitude - 0.50 <= v_eff.spatial_mobility`) to `founder_hex_id` **or to any hex occupied by a living peer deme of the same `lineage_id`**. Otherwise, `deme.isolation_turns += 1`. - ---- - -## Stage 4: Evolutionary Milestones, Speciation Strategy Inheritance & Macro-Outcomes (`EvoMilestonePhase`) - -At the end of each generation, `EvoMilestonePhase.run_milestone_phase` collects all eligible evolutionary milestone candidates across all demes, sorts them by strict priority (`0` $\to$ `1` $\to$ `2`), and synthesizes at most **one emergent trait per turn globally**: - -| Priority | Milestone Type | Chronicle `event_type` | Exact Trigger Conditions | Evolutionary Consequence & Strategy Inheritance | -| :---: | :--- | :--- | :--- | :--- | -| **0 (Highest)** | **Allopatric Speciation** | `allopatric_speciation` | 1. `isolation_turns >= 10`
2. `< 3` emergent biological traits
3. Separated from `founder_hex_id`
4. Distinct biome (`cell.biome != founder_cell.biome`), novel bio trait, or Jaccard trait divergence $\ge 0.30$ | Synthesizes `Allopatric Divergence ()` (`bio`), resets `isolation_turns = 0`, creates a new child `LineageNode` in `lineage_tree` with **`new_node.strategy_id = deme.strategy_id`** (plus `biological_trait_ids`, `cultural_trait_ids`, and `acquired_trait_ids`), assigns a new species color from `SPECIATION_PALETTE`, and updates `deme.lineage_id`. Even after speciating into a new lineage, the village retains its founding `strategy_id`. | -| **1** | **The Baldwin Effect**
*(Genetic Assimilation)* | `baldwin_trait` | 1. `< 3` emergent biological traits
2. Any non-foraging decision `dec_id` has `baldwin_counters[dec_id] >= 8` AND ($P(\text{dec\_id}) \ge 0.35$ or is dominant) | Records `baldwin_decision_id = dec_id`, synthesizes `Adaptive Homeostasis ()` (`bio`) with a 6-axis vector derived proportionally from the assimilated decision `dec_id`'s vector, appends it to `biological_trait_ids`, and resets `baldwin_counters[dec_id] = 0`. | -| **2** | **Cumulative Cultural Innovation** | `cultural_innovation` | 1. `innovation_counter >= 1.0`
2. Has cultural capacity (`enables_cultural_transmission`)
3. `< 2` existing emergent cultural traits
4. Non-negative energy balance ($\Delta E \ge 0.0$) | Synthesizes `Cooperative Ecological Tradition ()` (`cul`), appends it to `cultural_trait_ids`, and decrements `innovation_counter -= 1.0`. | - -### Strategy Lineage Continuity (`Deme.strategy_id` & `LineageNode.strategy_id`) -Across the entire generational lifecycle, every subpopulation and phylogenetic node maintains an unbroken link to its founding **Evolutionary Strategy** (`EvoTypes.StrategyDef` in `state.strategies_catalog`): - -1. **Generation 0 Initialization (`EvoCatalog.apply_strategy_to_deme`)**: Assigns `state.strategy_a_id` to Founder A (`deme.strategy_id`, `lin.strategy_id`) and `state.strategy_b_id` to Founder B. -2. **Colonist Migration & Warband Conquest (`EvoDemographyMigration`)**: Every daughter deme budded via population fission (`-m`) or founded via territorial conquest (`-w`) inherits `daughter.strategy_id = deme.strategy_id`. -3. **Allopatric Speciation (`EvoMilestonePhase`)**: When an isolated colony diverges into a new species (`new_node := EvoTypes.LineageNode.new()`), `new_node.strategy_id = deme.strategy_id` is preserved alongside `deme.strategy_id`, allowing the HUD Valley Legend and Scroll Inspector to track which founding strategy (`Strategy A` vs. `Strategy B`) is winning the macro-evolutionary duel even after multiple speciation events. - -### Global Macro-Outcomes -Finally, `EvoMilestonePhase` evaluates whether the ecosystem has reached one of three overarching macro-outcomes (displayed in the top-center golden banner and optionally auto-pausing playback): +## Stage 3 & 4: Milestones (`EvoMilestonePhase`) & Next-Turn Agency (`EvoDecisionModel`) -1. **Total Extinction (`total_extinction`)**: `state.demes.is_empty()` — all subpopulations have perished across the valley. -2. **Evolutionary Hegemony (`single_survivor`)**: After prior competitive or environmental extinctions eliminated rival root lineages (`_get_root_lineage_id`), a single founding root lineage (including its speciated daughter lineages, or a single non-migrant deme at `generation >= 15`) remains as the sole survivor of the ecosystem. -3. **Ecological & Demographic Equilibrium (`ecological_equilibrium`)**: Reached when `state.generation >= 30` and the ecosystem completes **8 consecutive quiet generations** (`state.quiet_turns >= 8` — 4 full seasonal cycles) with zero migrations, zero chronicle crises/milestones, an unchanged set of living demes, and $\le 5\%$ per-turn population fluctuation across every deme. +* **Stage 3 (`EvoMilestonePhase.run_milestone_phase`)**: Synthesizes at most one emergent evolutionary trait per turn globally (**Allopatric Speciation** $\to$ **Baldwin Effect** $\to$ **Cultural Innovation**) and checks global macro-outcomes (`total_extinction`, `single_survivor`, `ecological_equilibrium`). +* **Stage 4 (`EvoDecisionModel.evaluate_all_demes` & `sync_and_enqueue_batch`)**: Applies completed live Ollama `/v1/systemone` results for every surviving settlement and enqueues the next turn's per-settlement action menus on the background worker pool. diff --git a/gh-pages/ui_tools_controls.md b/gh-pages/ui_tools_controls.md index 286984e..79e3d6a 100644 --- a/gh-pages/ui_tools_controls.md +++ b/gh-pages/ui_tools_controls.md @@ -1,100 +1,101 @@ # HUD, Analytical Modals & God-Mode Tools -**Evolution** presents its complex biophysical and phylogenetic simulation through a warm, handcrafted pixel-art user interface (`EvoMainHud`, `EvoUiStyle`) inspired by classic naturalist field journals and cozy tabletop dioramas. +**Evolution** presents its complex biophysical, urban-rural, and phylogenetic simulation through a warm, handcrafted field-journal user interface (`EvoMainHud`, `EvoUiStyle`) paired with a high-DPI 2.5D illustrated isometric world. -![The Rift Valley — Isometric Hexagonal Ecosystem & HUD](assets/img/screenshot_rift_savanna.png) +![The Rift Valley — 169-Hex 2.5D Illustrated Ecosystem & HUD](assets/img/screenshot_rift_savanna.png) --- ## HUD Layout & Panels (`EvoMainHud`) -The main interface is organized into four non-intrusive peripheral docks that leave the central 2.5D hexagonal valley unobstructed: +The main interface is organized into four non-intrusive peripheral docks that leave the central `169`-hex 2.5D hexagonal valley unobstructed: ```mermaid flowchart TD subgraph HUD ["EvoMainHud (CanvasLayer)"] TopLeft["1. Top-Left Valley Sign, Matchup Bar & Medallions\n• Scenario Preset Selector + JSON Save/Load\n• Pre-Play 1v1 Matchup: [Strategy A] ⚔️ [Strategy B] [🔄]\n• Modal Medallions: Journal, Lineages, Forge\n• Map Lenses (Biome, Food, Health) + Valley Legend"] TopRight["2. Top-Right Season Clock & Mana Panel\n• Live 72×76 Season & Tide Pixel Portrait\n• Year / Gen Counter + Play / Pause / +1 / Speed\n• Selective Pressure (⚡ Mana) Bar + Settings Gear"] - RightInsp["3. Right-Hand Scroll Inspector\n• Village Cycler (◀ / ▶) + Lineage Highlighter\n• Tab 1: Strategy Badge, Vitals, Decisions & Relations\n• Tab 2: Milestone Meters & Trait Medallions\n• Tab 3: Hex Habitat Climate, R & K Health"] + RightInsp["3. Right-Hand Scroll Inspector\n• Village Cycler (◀ / ▶) + Lineage Highlighter\n• Tab 1: Strategy, Tier I–IV, Salubrity, Walls & Walled Union\n• Tab 2: Milestone Meters & Trait Medallions\n• Tab 3: Hex Climate, Rural Land Use, Ruins, R & K Health"] BottomDock["4. Bottom 10-Slot Hotbar & Toast Stack\n• Slots 1–0 (Inspect, Biomes, Altitude, K, Sea)\n• Brush Radius Toggle: ◯ 1x (1 hex) vs ◎ 7x (7 hexes)\n• Floating Tidal Bar + Bottom-Left Chronicle Toasts"] end ``` ### 1. Top-Left Valley Sign, Pre-Play Strategy Matchup, Lenses & Valley Legend (`EvoHudTopBar`) * **Row 1 — Scenario & State Persistence**: - * **Preset Dropdown**: Switch immediately between *The Rift and the Savanna* (`preset-rift-savanna`), *Darwin's Archipelago* (`preset-darwin-archipelago`), and *The Finite Island* (`preset-finite-island`). - * **Export (`💾`) & Import (`📂`) World State**: Serializes or restores the complete `WorldState` (cells, demes, custom strategies/traits/decisions, `strategy_a_id`, `strategy_b_id`, lineage tree, and chronicle history) to/from `user://evolution_world_state.json`. + * **Preset Dropdown**: Switch immediately between *The Rift Valley* (`preset-rift-savanna`), *Darwin's Archipelago* (`preset-darwin-archipelago`), and *The Finite Island* (`preset-finite-island`). + * **Export (`💾`) & Import (`📂`) World State**: Serializes or restores the complete `WorldState` (cells with `land_use`, `owner_deme_id`, and `has_ruins`, demes with `salubrity` and `walled_union_id`, custom strategies/traits/decisions, `strategy_a_id`, `strategy_b_id`, lineage tree, and chronicle history) to/from `user://evolution_world_state.json`. * **Row 2 — Pre-Play 1v1 Strategy Matchup Selector (`[Strategy A] ⚔️ [Strategy B] [🔄]`)**: * **Strategy A & Strategy B Dropdowns**: Choose any of the **5 Base Archetypal Strategies** (`strat-cooperation` 🤝, `strat-expansion` 🧭, `strat-trade` ⚖️, `strat-war` ⚔️, `strat-innovation` 🔬) or any custom user-forged strategy (`origin = "user"` loaded from `~/.config/Evolution/user_catalog.json`) for Slot A (`state.strategy_a_id`) and Slot B (`state.strategy_b_id`). * **Live Generation 0 Reconfiguration**: While the simulation is at **Generation 0 (`state.generation == 0`)**, changing either dropdown immediately applies the chosen strategy to the corresponding founding lineage and deme via `EvoCatalog.apply_strategy_to_deme` and re-evaluates initial decision probabilities. - * **Reset Duel Button (`🔄` — `hud_matchup_reset`)**: Resets the active preset back to Generation 0 at any time with the currently selected `Strategy A ⚔️ Strategy B` matchup (`Reset duel at Generation 0 with the selected strategies`). + * **Reset Duel Button (`🔄` — `hud_matchup_reset`)**: Resets the active preset back to Generation 0 at any time with the currently selected `Strategy A ⚔️ Strategy B` matchup. * **Row 3 — Analytical Modal Medallions**: * **`📖 Journal / Diario`**: Opens the *Naturalist's Notebook* (`EvoNotebookModal`). * **`🌳 Lineages / Linajes`**: Opens the *Phylo-Cultural Lineage Tree* (`EvoTreeModal`). * **`⚗️ Forge / Forja`**: Opens the *Strategy, Trait & Decision Forge* (`EvoLabModal`). * **Row 4 — Map Lenses & Valley Legend**: - * **`🌿 Biome / Bioma`**: Renders authentic biome pixel textures, subtly shifting toward ochre/brown when a hexagon's carrying capacity $K$ is degraded. + * **`🌿 Biome / Bioma`**: Renders high-DPI `256x224` illustrated biome hex tiles (`hex_terrain.gdshader`), subtly shifting toward ochre/brown when a hexagon's carrying capacity $K$ is degraded. * **`🌾 Food / Alimento`**: Visualizes current biomass ratio $R / K_{\max}$ across 50 quantized bands along a barren-brown (`#8c583a`) $\to$ amber (`#eab308`) $\to$ lush-green (`#65f06c`) gradient. * **`💚 Health / Salud`**: Visualizes structural habitat integrity $K / K_{\max}$: * **Healthy ($\ge 85\%$ $K_{\max}$)**: Emerald mint (`#6ee7b7`). * **Stressed ($60\%\text{–}84\%$ $K_{\max}$)**: Amber (`#fbbf24`) with a dashed orange perimeter border (`#f59e0b`). * **Collapsed ($< 60\%$ $K_{\max}$ — Tragedy of the Commons)**: Coral red (`#f87171`) with a thick dashed crimson border (`#ef4444`). - * **`🗺️ Legend / Leyenda`**: Toggles the floating *Valley Legend* card detailing the live **Strategy Duel Counter (`legend_strategies`)** (showing each active strategy's color swatch, emblem icon, name, living village count, and total population), all 7 biome swatches, the **5 architectural village tiers (`I–V`: Camp, Hamlet, Village, Citadel, Unified Metropolis)**, the 3 inter-village path symbols (`🛣️`, `⚔️`, `🧭`), and the **7-entry Geopolitical Legend (`legend_geopolitics`)** (`🤝 Pact / Alliance`, `🏛️ Merge / Unification`, `🪙 Tribute / Appeasement`, `⚔️ War / Invasion`, `🏰 Siege / Defense`, `🔥 Raid / Plunder`, and `🗡️ Betrayal`). + * **`🗺️ Legend / Leyenda`**: Toggles the floating *Valley Legend* card detailing the live **Strategy Duel Counter (`legend_strategies`)** (showing each active strategy's color swatch, emblem icon, name, living settlement count, and total population), all 7 biome swatches, the **4 urban settlement categories (`I–IV`: `I · Camp`, `II · Village`, `III · Town`, `IV · Metropolis`)**, the 3 inter-settlement path symbols (`🛣️`, `⚔️`, `🧭`), and the **Geopolitical Legend (`legend_geopolitics`)** (`🤝 Pact / Alliance`, `🏛️ City Unification`, `🪙 Tribute / Truce`, `⚔️ War / Invasion`, `🏰 Active Siege`, `🔥 Raid / Plunder`, and `🗡️ Betrayal`). ### 2. Top-Right Season Clock, Playback & Settings Popup * **Dynamic Season & Tide Portrait (`SeasonTidePortrait`)**: A custom-drawn $72 \times 76$ pixel-art vignette displaying a radiant sun over green hills during `wet_warm` ($\gamma = 1.20$), a crescent moon with falling snow over slate hills during `dry_cold` ($\gamma = 0.75$), and a live water-level gauge (`🌊 %d%%`) tracking global `sea_level`. * **Time & Playback Controls**: - * Displays `📅 Year X · Gen Y` (`year = generation / 2 + 1`) and the active season name. + * Displays `📅 Yr X · Gen Y` (`year = generation / 2 + 1`) and the active season name. * **`▶ Play` / `⏸ Pause`** (Space), **`⏭ +1`** (single-generation step), and **`1x` (`1.50s/gen`) / `2x` (`0.30s/gen`)** speed toggle. * **Selective Pressure ("Mana") Bar**: Tracks available intervention points (`⚡ 30 pts` initial, `+15/gen` passive regeneration). * **Settings, Cognitive Engine & Camera Popup (`⚙️`)**: - * **Cognitive Engines Diagnostic Card**: Live count of villages assigned to Primary (`d3:4b`) and Secondary (`tev1:4b`) System 1 models, active `num_ctx`, and mean confidence `%`. + * **Cognitive Engines Diagnostic Card**: Live count of settlements assigned to Primary (`d3:4b`) and Secondary (`tev1:4b`) System 1 models, active `num_ctx`, and mean confidence `%`. * **Model & Context Configuration**: Dropdown selectors for Primary and Secondary Ollama decision models, free-text `num_ctx` input (persisted to `~/.config/Evolution/settings.json`), and a manual model refresh button (`🔄`). * **Simulation Rules & Language**: Switch UI language (`ES` / `EN`), toggle `Auto-pause on milestones` (`auto_pause_on_milestone`), `Unlimited Mana` (`unlimited_pressure_mode`), and `Free Sandbox` (`free_sandbox_mode` — bypasses thermodynamic cost enforcement). - * **Camera Presets**: One-click pitch presets (`32°`, `48°`, `68°`), yaw orbit buttons (`↺ -18°`, `↻ +18°`), and `🎯 Reset`. + * **Camera Presets**: One-click pitch presets (`32°`, `48°`, `68°`), zoom controls, and `🎯 Reset` (with horizontal yaw locked at `0.0°` so 2.5D illustrated building facades and drop shadows remain consistently oriented). --- -## Visual Strategy, Fortification & Geopolitical Identification on the 2.5D Map & HUD - -Every village's founding **Evolutionary Strategy** (`deme.strategy_id`), **Multi-Tier Defense Level** (`defense_tier: 0..3`), **City Unification Status** (`merged_count > 0`, Tier 5 Unified Metropolis), and live **Geopolitical Status** (`war`, `siege`, `raid`, `tribute`, `pact`, `merge`, `betrayal`) are immediately recognizable across the 2.5D isometric valley and HUD without opening a modal: - -1. **Hex Strategy Perimeter Rings (`EvoWorldOverlayRenderer._draw_hex_strategy_ring`)**: Every inhabited hexagon displays a crisp perimeter ring tinted in the resident strategy's color (`Strategy A` vs. `Strategy B`), making territorial borders and conquests immediately legible from macro zoom. -2. **Floating Village Billboards, Fortified Shield Borders & Geopolitical Side Pills (`EvoWorldOverlayRenderer.draw_village_billboard`)**: - * **Fortified Strategy Shield**: Each settlement displays an overhead dark-wood pill featuring a strategy-colored emblem shield (`A · ...` / `B · ...`, or `A★` / `B★` when unified via Synoecism) whose border reflects its `defense_tier` (**bronze `#b45309`** for Lv.1 Palisade, **silver `#cbd5e1`** for Lv.2 Stone Wall, and **gold `#fbbf24`** for Lv.3 Castle). - * **8 Geopolitical Status Side Pills**: Displays contextual side pills alongside the billboard whenever the village is fortified (`icon_defense`: `Lv1`, `Lv2`, `Lv3`), unified (`icon_merge`), or actively engaged in a siege (`icon_siege`), war (`icon_war`), betrayal (`icon_betrayal`), raid (`icon_raid`), tribute (`icon_tribute`), or pact (`icon_pact`). -3. **2.5D Perimeter Fortification Rings, Unified Metropolis Plazas & Geopolitical Props (`EvoPropFloraRenderer`)**: - * **Perimeter Fortification Rings (`def_tier 1..3`)**: - * **Lv. 1 Palisade**: Timber ring with **22 sharpened wooden stakes** and `prop_defense_palisade` watchtower. - * **Lv. 2 Stone Wall**: Double-rimmed mortar wall with **20 crenellated stone merlons** and `prop_defense_wall` gatehouse. - * **Lv. 3 Castle / Citadel**: Thick fortified wall with **20 crenellated merlons**, **4 corner bastions** topped with strategy-colored roofs and golden finials, and `prop_defense_fortress` keep—plus a pulsing crimson-orange siege ring and `prop_siege_engine` when `in_siege`. - * **Unified Metropolis Plazas (`merged_count > 0`, Tier 5)**: Expanded stone plinth (`54.5 * sf`), golden inner ring, `tier5_metropolis` grand dome + `prop_unity_monument` obelisk, and **twin-pennant flagpoles** flying both the primary strategy pennant and the united partner's `secondary_color` pennant. - * **Contextual Action & Geopolitical Props**: Surrounds the plaza with `act_war`, `act_raid`, `act_pact`, `act_betrayal`, `prop_war_banner`, `prop_raid_loot`, `prop_tribute_chest`, `prop_siege_engine`, `prop_pact_totem`, and `prop_betrayal_ruin`. -4. **Plaza Banners & Villager Tunics (`EvoPixelArtGen.get_villager_texture`)**: Orbiting villagers and road travelers dynamically recolor their pixel-art tunics (`villager_idle`, `villager_walk1`, `villager_walk2`) and settlement banners to match `deme.color` (inherited from the founding `StrategyDef.color`, or diverged from `SPECIATION_PALETTE` upon allopatric speciation while retaining `deme.strategy_id`). -5. **Inspector Strategy, Defense, Unification & Relations Readouts (`EvoHudInspectorPanel`)**: The Right-Hand Scroll Inspector features a prominent full-width **Strategy Banner (`🛡️ Strategy: ...`)**, live **`🏰 Defense`** and **`🏛️ Unification`** rows, and a dedicated **`🤝 Pacts & Alliances`** / **`🛣️ Relations & Paths`** summary. -6. **Valley Legend Strategy Duel Counter & 7-Flow Geopolitical Legend (`EvoHudHotbarDock`)**: Opening `🗺️ Legend` displays a live head-to-head breakdown of the active **Strategy Duel** (tallying active settlements and total population per competing strategy) together with the 5 architectural tiers (`I–V`) and the 7 geopolitical flow archetypes (`Pact`, `Merge`, `Tribute`, `War`, `Siege`, `Raid`, `Betrayal`). +## Visual Strategy, Urban Tier, Hinterland & Walled Union Identification on the 2.5D Map + +![Settlement Tiers I–III, Perimeter Walls & Cultivated Rural Hinterlands](assets/img/screenshot_settlement_tiers_hinterlands.png) + +Every settlement's founding **Evolutionary Strategy** (`deme.strategy_id`), **4-Tier Urban Category (`I..IV`)**, **Urban Salubrity (`salubrity`)**, **Hex-Edge Perimeter Fortifications** (`defense_tier: 0..3`), **Rural Hinterlands (`crops` and `pasture`)**, and **Continuous Multi-Hex Walled Union (`walled_union_id`)** are immediately recognizable across the 2.5D isometric valley: + +1. **Hex Strategy Perimeter Rings & Rural Territory Crests (`EvoWorldOverlayRenderer`)**: Every inhabited or cultivated hexagon displays a crisp perimeter ring tinted in its owner strategy's color (`Strategy A` vs. `Strategy B`), and cultivated rural hinterlands (`crops` or `pasture`) display a compact owner crest pill (` A/B · (q,r)`) so territorial control is clear at a glance. +2. **Floating Settlement Billboards with Roman Tier (`I..IV`), Salubrity Dot & Fortified Shield (`EvoWorldOverlayRenderer.draw_village_billboard`)**: + * **Fortified Strategy Shield & Roman Category**: Each settlement displays an overhead dark-wood billboard featuring a strategy-colored emblem shield (`A · ...` / `B · ...`, or `A★` / `B★` when unified) whose border reflects its `defense_tier` (**bronze `#b45309`** for Lv.1 Palisade, **silver `#cbd5e1`** for Lv.2 Stone Wall, and **gold `#fbbf24`** for Lv.3 Castle), alongside its Roman urban category and headcount (`I · 42 pop`, `II · 135 pop`, `III · 250 pop`, `IV · 390 pop`). + * **Urban Salubrity Indicator Dot**: Whenever `deme.salubrity < 0.75` or `deme.get_village_tier() >= 3`, a color-coded salubrity status dot renders beside the population label (**emerald** $\ge 75\%$, **amber** $40\%\text{–}74\%$, **crimson** $< 40\%$ epidemic risk). + * **Geopolitical Side Pills**: Displays contextual side pills alongside the billboard whenever the settlement is fortified (`icon_defense`: `Lv1`, `Lv2`, `Lv3`), unified (`icon_merge`), or actively engaged in a siege (`icon_siege`), war (`icon_war`), betrayal (`icon_betrayal`), raid (`icon_raid`), tribute (`icon_tribute`), or pact (`icon_pact`). +3. **2.5D Hex-Edge Perimeter Walls & Continuous Multi-Hex Walled Unions (`EvoRoadNetworkRenderer.draw_settlement_perimeter_walls`)**: + * **Single-Hex Perimeter Walls (`wall_r = 0.78`)**: Fortified and Tier III–IV settlements erect 2.5D perimeter walls along their 6 hex edges—timber palisades (`palisade_wall`) at Lv.1, crenellated stone walls (`stone_wall`) at Lv.2, and thick bastion-reinforced stone curtain walls with corner towers at Lv.3. + * **Shared Edge Culling in Multi-Hex Walled Unions (`walled_union_id != ""`)**: When two or three adjacent same-tier settlements form a **Continuous Multi-Hex Walled Union**, `draw_settlement_perimeter_walls` **automatically culls the internal wall along their shared hex edge**, uniting their outer perimeters into a single continuous multi-hex fortress connected by a paved central union boulevard (`draw_merge_avenue`). +4. **Full-Hex 4-Tier Architecture, Rural Hinterlands & Persistent Ruins (`EvoPropFloraRenderer`)**: + * **4-Tier Urban Plazas**: Distributes `128x128`–`160x160` `#23150c` ink-outlined isometric buildings across the full hex (`hut_thatch` + `campfire` at Tier I; `house_clay` + `granary` at Tier II; `house_stone` + `temple_totem` + `windmill` at Tier III; `citadel_keep` + `cathedral` + `watchtower` at Tier IV). + * **Rural Hinterland Improvements (`land_use = "crops" | "pasture"`)**: Suppresses interior wild trees on claimed rural hexes to place golden wheat fields (`crop_field`) and `windmill`s on `"crops"` hexes or fenced livestock corrals (`pasture_pen`) and `granary` barns on `"pasture"` hexes, linked to the parent city by rural farm tracks (`draw_rural_farm_track`) with animated `covered_wagon`s. + * **Persistent Stone Ruins (`has_ruins == true`)**: Any hex where a settlement went extinct or a rural hinterland was pillaged during a raid displays weathered stone ruins (`ruins_stone`) until re-colonized or re-cultivated. --- ## Right-Hand Scroll Inspector (`EvoHudInspectorPanel`) -The collapsible parchment Scroll Inspector on the right side of the screen provides deep, zero-information-loss telemetry on any selected village or hexagon: +The collapsible parchment Scroll Inspector on the right side of the screen provides deep, zero-information-loss telemetry on any selected settlement or hexagon: * **Inspector Header**: - * **Village Cycler (`◀` / `▶` & Dropdown)**: Lists all living demes sorted by population descending (` [i/N] (q,r)`), allowing instant cycling across every settlement in the valley (and automatically focusing the camera on the chosen village). - * **Model Badge & Lineage Map Highlight (`✨ Lineage`)**: Shows the deme's assigned System 1 model (`d3:4b` or `tev1:4b`), confidence `%`, axial coordinates, and a toggle button that dims unrelated villages on the 2.5D map while projecting a pulsing vertical light beam over all settlements of the active lineage. + * **Settlement Cycler (`◀` / `▶` & Dropdown)**: Lists all living demes sorted by population descending (` (q,r) — pop.`), allowing instant cycling across every settlement in the valley. + * **Model Badge & Lineage Map Highlight (`✨ Lineage`)**: Shows the deme's assigned System 1 model (`d3:4b` or `tev1:4b`), confidence `%`, axial coordinates, and a toggle button that dims unrelated settlements on the 2.5D map while highlighting all settlements of the active lineage. === "Tab 1: 🧠 Village (`Aldea`)" * **Prominent Strategy Banner & Vitals Card**: - * **Strategy Banner (`_insp_strategy_banner`) & Lineage**: Full-width colored banner displaying the village's founding **Evolutionary Strategy** (`🛡️ Strategy: `), species lineage name, headcount, and architectural tier (`I · Camp` $<50$, `II · Hamlet` $50\text{–}149$ or Lv.1 Palisade, `III · Village` $150\text{–}399$ or Lv.2 Stone Wall, `IV · Citadel` $400+$ or Lv.3 Castle, `V · Metropolis` when unified via Synoecism with $P \ge 220$ or Lv.3 Castle). + * **Strategy Banner & Lineage**: Full-width colored banner displaying the settlement's founding **Evolutionary Strategy** (`🛡️ Strategy: `), species lineage name, headcount, and 4-tier urban category (`I · Camp`, `II · Village`, `III · Town`, `IV · Metropolis`). * **Caloric Reserves (`🍯`) & Turn Energy Delta (`⚡`)**: Progress bar tracking stored `energy_reserves`, net caloric balance $\Delta E$, and unified vital status badge (`💚 Thriving`, `💙 Stable`, `💛 Declining`, `❤️‍🩹 Starving`). - * **Fortification Level (`🏰 Defense`)**: Displays `Lv.0 — Unfortified`, `Lv.1 — Palisade (+45% def)`, `Lv.2 — Stone Wall (+95% def)`, or `Lv.3 — Castle (+155% def, raid-immune)`. - * **City Unification (`🏛️ Unification`)**: Displayed when `deme.merged_count > 0`, showing `Unified Metropolis (N clans)` (`N = merged_count + 1`). + * **Urban Salubrity (`🏥 Salubrity`)**: Displays `deme.salubrity` as a percentage (`0%–100%`), reflecting urban crowding, multi-hex union density, and sanitation traits (`cul-communal-conservation`, `bio-k-strategy`). + * **Fortification Level (`🏰 Defense`)**: Displays `Lv.0 Unfortified`, `Lv.1 Palisade (+45%)`, `Lv.2 Stone Wall (+95%)`, or `Lv.3 Castle (+155%)`. + * **City Unification (`🏛️ Unification`) & Walled Union (`🧱 Walled Union`)**: Displays `Unified City (N peoples)` when `deme.merged_count > 0` (Asymmetric Category Absorption) and `Active ()` when the city belongs to a **Continuous Multi-Hex Walled Union**. * **Dominant Intention (`🧠`)**: Displays the top 3 behavioral decisions from `last_decision_probabilities` as horizontal probability bars, marking the dominant intention with a gold star (`★`). * **Pacts & Alliances (`🤝`) & Geopolitical Relations (`🛣️`)**: * **Pacts Summary**: Lists all active formal Alliance Pacts (`>= 2` consecutive cooperative turns) and partner names. - * **Relations & Paths**: Exhaustive listing of active **Alliance Pacts (`🤝`)**, **Multi-Turn Vendettas (`🩸`)**, **Fortified Sieges (`🏰`)**, **Tributes (`🪙`)**, **Wars of Conquest (`⚔️`)**, **Plunder Raids (`🔥`)**, **Pact Betrayals (`🗡️`)**, cooperation/trade/unification routes (`🛣️`), territorial disputes (`⚔️`), migratory expeditions (`🧭`), severed/disused roads (`🚧` with turn age), cohabiting demes (`🤝`), and neighboring villages (`🏘️`). + * **Relations & Paths**: Exhaustive listing of active **Alliance Pacts (`🤝`)**, **Multi-Turn Vendettas (`🩸`)**, **Fortified Sieges (`🏰`)**, **Tributes (`🪙`)**, **Wars of Conquest (`⚔️`)**, **Plunder Raids (`🔥`)**, **Pact Betrayals (`🗡️`)**, cooperation/trade/union routes (`🛣️`), territorial disputes (`⚔️`), migratory expeditions (`🧭`), severed/disused roads (`🚧` with turn age), and neighboring settlements (`🏘️`). * **Naturalist's Note (`💬`)**: Contextual behavioral justification generated by the cognitive engine. === "Tab 2: 🧬 Traits (`Rasgos`)" @@ -107,8 +108,8 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi === "Tab 3: 🌿 Habitat (`Hábitat`)" - * Automatically activates when left-clicking an uninhabited hex in `Inspect` mode. - * Displays the biome icon, localized biome name, axial coordinates `(q, r)`, **Temperature (`🌡️`)**, **Humidity (`💧`)**, **Altitude (`⛰️`)**, **Available Biomass (`🌾 R / K`)**, and color-coded **Ecological Health (`💚 K / Kmax`)**. + * Automatically activates when left-clicking an uninhabited or rural hinterland hex in `Inspect` mode. + * Displays the biome icon, localized biome name, axial coordinates `(q, r)`, **Temperature (`🌡️`)**, **Humidity (`💧`)**, **Altitude (`⛰️`)**, **Rural Land Use (`🚜 Land use: Crop fields / Pasturelands · `)** when `cell.land_use in ["crops", "pasture"]`, **Ruins Status (`🏚️ Status: Ruins`)** when `cell.has_ruins == true`, **Available Biomass (`🌾 R / K`)**, and color-coded **Ecological Health (`💚 K / Kmax`)**. --- @@ -118,7 +119,7 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi ![Naturalist's Notebook — Evolutionary Chronicle Modal](assets/img/screenshot_notebook_modal.png) -* **18 Category Filters**: Filter historical events by `All (all)`, `Wars (war)`, `Sieges (siege)`, `Plunder Raids (raid)`, `Tributes (tribute)`, `Alliance Pacts (pact)`, `Unifications (merge)`, `Fortifications (fortify)`, `Betrayals (betrayal)`, `Baldwin Effect (baldwin_trait)`, `Cultural Innovation (cultural_innovation)`, `Allopatric Speciation (allopatric_speciation)`, `Symbiosis (symbiosis)`, `Tragedy of the Commons (tragedy_of_commons)`, `Cultural Loss (cultural_loss)`, `Extinction (extinction)`, `Evolutionary Hegemony (single_survivor)`, or `Dynamic Equilibrium (ecological_equilibrium)`. +* **19 Category Filters (`FILTER_KEYS`)**: Filter historical events by `All (all)`, `War (war)`, `Siege (siege)`, `Raid (raid)`, `Tribute (tribute)`, `Pacts (pact)`, `Union (merge)`, `Defense (fortify)`, `Betrayal (betrayal)`, `Baldwin Effect (baldwin_trait)`, `Cultural Innovation (cultural_innovation)`, `Allopatric Speciation (allopatric_speciation)`, `Symbiosis (symbiosis)`, `Tragedy of the Commons (tragedy_of_commons)`, `Cultural Loss (cultural_loss)`, `Extinction (extinction)`, **`Epidemics (epidemic)`**, `Evolutionary Hegemony (single_survivor)`, or `Ecological Equilibrium (ecological_equilibrium)`. * **Causal Event Cards**: Displays up to 100 reverse-chronological entries color-coded by event type, including the generation badge (`G%d`), hex coordinate, headline, and full causal narrative. * **One-Click Map Jump (`📍 View on map`)**: Clicking any Chronicle entry closes the modal, selects the target hexagon and deme, and smoothly pans the 2.5D camera directly to the site of the milestone. @@ -129,8 +130,8 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi ![Phylo-Cultural Lineage Tree Modal](assets/img/screenshot_tree_modal.png) * **Hierarchical Phylogenetic Canvas**: Automatically lays out root and child `LineageNode` cards left-to-right by evolutionary depth and connects parent-child divergences with cubic Bézier curves. Supports click-and-drag panning across large evolutionary trees. -* **Live Species & Strategy Telemetry**: Each card displays the lineage's color emblem, inherited `strategy_id` badge, species name, founding generation (`G%d`), divergence mechanism (*Initial*, *Allopatric Speciation*, *Baldwin Assimilation*, *Cultural Schism*), founder hex, acquired trait count, and live status (`● N living villages` in green or `✕ Extinct at G%d` in red). -* **Map Lineage Highlighting (`✨ Highlight Lineage on Map`)**: Selecting any lineage node populates the right-hand detail sidebar (showing all acquired traits and active villages with their population and model tags) and lets you project pulsing lineage light beams across the 2.5D world map. +* **Live Species & Strategy Telemetry**: Each card displays the lineage's color emblem, inherited `strategy_id` badge, species name, founding generation (`G%d`), divergence mechanism (*Ancestral*, *Allopatric Speciation*, *Baldwin Assimilation*, *Cultural Schism*), founder hex, acquired trait count, and live status (`● N living villages` in green or `✕ Extinct at G%d` in red). +* **Map Lineage Highlighting (`✨ Highlight Lineage on Map`)**: Selecting any lineage node populates the right-hand detail sidebar (showing all acquired traits and active settlements `[I..IV · pop · model · (q,r)]`) and lets you project pulsing lineage highlights across the 2.5D world map. --- @@ -139,18 +140,18 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi ![Trait & Decision Forge — Evolutionary Laboratory Modal](assets/img/screenshot_lab_modal.png) * **4 Catalog Tabs**: - 1. **`🧬 Biological Traits` (`biological`)**: Inspect, modify, or craft heritable biological adaptations (`bio-*`). - 2. **`🔥 Cultural Traditions` (`cultural`)**: Inspect, modify, or craft socially transmitted traditions (`cul-*`). - 3. **`⚖️ Behavioral Decisions` (`decision`)**: Inspect, modify, or craft collective behavioral archetypes (`dec-*`). - 4. **`🛡️ Strategies` (`lab_tab_strat`)**: Create, edit, and preview custom **Evolutionary Strategies** (`EvoTypes.StrategyDef`) for 1v1 duels. + 1. **`🛡️ Strategies` (`strategy`)**: Create, edit, and preview custom **Evolutionary Strategies** (`EvoTypes.StrategyDef`) for 1v1 duels. + 2. **`🧬 Biological Traits` (`biological`)**: Inspect, modify, or craft heritable biological adaptations (`bio-*`). + 3. **`🔥 Cultural Traditions` (`cultural`)**: Inspect, modify, or craft socially transmitted traditions (`cul-*`). + 4. **`⚖️ Behavioral Decisions` (`decision`)**: Inspect, modify, or craft collective behavioral archetypes (`dec-*`). * **`🛡️ Strategies` Builder & Duel Slot Assignment**: - * **Emblem (`lab_strat_icon`) & Color (`lab_strat_color`) Pickers**: Choose a visual shield emblem (e.g., `🤝`, `🧭`, `⚖️`, `⚔️`, `🔬`, `🛡️`, `🌿`, `👑`) and hex color swatch for the strategy. + * **Emblem (`lab_strat_icon`) & Color (`lab_strat_color`) Pickers**: Choose a visual shield emblem (e.g., `🤝`, `🧭`, `⚖️`, `⚔️`, `🔬`, `🛡️`, `🌿`, `🔥`, `👑`, `⚡`) and hex color swatch for the strategy. * **Compatible Trait Checkboxes (`lab_strat_traits`)**: Select any combination of biological (`bio-*`) and cultural (`cul-*`) traits from the catalog. The builder automatically enforces mutual exclusion between `bio-r-strategy` and `bio-k-strategy` and requires at least one cultural-capacity biological trait (`bio-encephalization` or `bio-social-instinct`) before cultural traditions can be active. - * **Combined Biophysical Footprint Preview (`lab_strat_vector`)**: Live 6-axis readout computed via `EvoCatalog.compute_strategy_vector(strategy, state.traits_catalog)` showing the strategy's aggregate `metabolic_cost`, `extraction_efficiency`, `ecological_impact`, `spatial_mobility`, `social_cohesion`, and `transmission_threshold`. - * **Save & Assign to Duel (`💾 Save Strategy`, `Set as Strategy A`, `Set as Strategy B`)**: Saves the custom strategy (`strat-custom-*`, `origin = "user"`) into `state.strategies_catalog`, persists all user-created strategies, traits, and decisions to **`~/.config/Evolution/user_catalog.json`** via `EvoUserSettings.save_user_catalog(state)`, and lets you assign the strategy directly to **Slot A (`lab_use_strat_a`)** or **Slot B (`lab_use_strat_b`)**. + * **Combined Biophysical Footprint Preview (`lab_strat_vector`)**: Live 6-axis readout computed via `EvoCatalog.compute_strategy_vector(strategy, state.traits_catalog)`. + * **Save & Assign to Duel (`💾 Save Strategy`, `Set as Strategy A`, `Set as Strategy B`)**: Saves the custom strategy (`strat-custom-*`, `origin = "user"`) into `state.strategies_catalog` and persists all user-created strategies, traits, and decisions to **`~/.config/Evolution/user_catalog.json`**. * **Interactive 6-Axis Vector Sliders & Live Thermodynamic Balance Meter** (for Traits & Decisions): * Fine-tune `metabolic_cost`, `extraction_efficiency`, `ecological_impact`, `spatial_mobility`, `social_cohesion` ($[-1.0, +1.0]$), and `transmission_threshold` ($[0.0, +1.0]$). - * Continuously computes positive advantages $A^+$, actual cost $C_{\text{curr}}$, and required cost $C_{\text{req}} = \max(0, 0.5 \cdot (A^+ - 0.60))$, with one-click **`⚖️ Auto-Balance Vector`**, **`💾 Save to Catalog`** (persisted to `~/.config/Evolution/user_catalog.json`), and **`💉 Inject into Selected Village`**. + * Continuously computes positive advantages $A^+$, actual cost $C_{\text{curr}}$, and required cost $C_{\text{req}} = \max(0, 0.5 \cdot (A^+ - 0.60))$, with one-click **`⚖️ Auto-Balance Vector`**, **`💾 Save to Catalog`**, and **`💉 Inject into Selected Village`**. --- @@ -160,13 +161,13 @@ The bottom dock provides **10 hotbar slots** (1–9, | Slot & Key | Tool Name (EN / ES) | Internal ID & Sub-Mode | Cost | Exact Biophysical Effect on Target Hexagons | | :---: | :--- | :--- | :---: | :--- | -| **1** (1) | **Naturalist's Loupe**
*Lupa del Naturalista* | `inspect` | `0` | Selects villages or hexes for inspection; left-drag pans the camera. | -| **2** (2) | **Jungle Seeds**
*Semillas de Selva* | `set_biome`
`tropical_forest` | `⚡10` | Converts hexes to `tropical_forest` ($T=+0.45$, $H=0.85$, alt clamped to $[0.28, 0.65]$ and $\ge \text{sea\_level}+0.05$, restores terrestrial $K_{\max} \ge 220\text{–}300$, $K \ge 220$, $R \ge 180$). | +| **1** (1) | **Naturalist's Loupe**
*Lupa del Naturalista* | `inspect` | `0` | Selects settlements or hexes for inspection; left-drag pans the camera. | +| **2** (2) | **Jungle Seeds**
*Semillas de Selva* | `set_biome`
`tropical_forest` | `⚡10` | Converts hexes to `tropical_forest` ($T=+0.45$, $H=0.85$, alt clamped to $[0.28, 0.65]$ and $\ge \text{sea\_level}+0.05$, restores terrestrial $K$ and $R$ minimums). | | **3** (3) | **Savanna Seeds**
*Semillas de Sabana* | `set_biome`
`savanna` | `⚡10` | Converts hexes to `savanna` ($T=+0.50$, $H=0.40$, alt $[0.28, 0.65]$, restores terrestrial $K$ and $R$ minimums). | | **4** (4) | **Desert Dust**
*Polvo del Desierto* | `set_biome`
`arid_steppe` | `⚡10` | Converts hexes to `arid_steppe` ($T=+0.65$, $H=0.20$, alt $[0.30, 0.70]$, induces arid cooperation/migration pressure). | | **5** (5) | **Boreal Crystal**
*Cristal Boreal* | `set_biome`
`tundra` | `⚡10` | Converts hexes to sub-zero `tundra` ($T=-0.55$, $H=0.35$, alt $[0.35, 0.74]$, imposes thermal metabolic stress). | | **6** (6) | **Peak Chisel**
*Cincel de Cumbre* | `adjust_altitude`
`raise` (`+0.18`) | `⚡15` | Raises hex `altitude` by $+0.18$ (clamped $[0, 1]$). Emerges `ocean` hexes ($\ge \text{sea\_level}$) into `coastal_shallows` ($<0.25$) or `savanna`; uplifts hexes $\ge 0.78$ into `high_mountain` ($T \le -0.30$). | -| **7** (7) | **Valley Pickaxe**
*Pico de Valle* | `adjust_altitude`
`lower` (`-0.18`) | `⚡15` | Lowers hex `altitude` by $-0.18$. Submerges hexes below `sea_level` into `ocean` (displacing or drowning resident demes); lowers `high_mountain` ($<0.78$) into passable `savanna` or `arid_steppe`. | +| **7** (7) | **Valley Pickaxe**
*Pico de Valle* | `adjust_altitude`
`lower` (`-0.18`) | `⚡15` | Lowers hex `altitude` by $-0.18$. Submerges hexes below `sea_level` into `ocean` (displacing or drowning resident demes and revalidating walled unions); lowers `high_mountain` ($<0.78$) into passable `savanna` or `arid_steppe`. | | **8** (8) | **Golden Watering Can**
*Regadera Dorada* | `adjust_carrying_capacity`
`enrich` (`+25%`) | `⚡10` | Multiplies hex carrying capacity $K$ by $\times 1.25$ (up to $K_{\max}$) and boosts current biomass $R$ by $\ge +25\%$. | | **9** (9) | **Drought Sickle**
*Hoz de Sequía* | `adjust_carrying_capacity`
`deplete` (`-25%`) | `⚡10` | Multiplies hex carrying capacity $K$ and current biomass $R$ by $\times 0.75$ (clamped $\ge 10.0$). | | **10** (0) | **Tidal Conch**
*Caracola de Mareas* | `sea_level` + Sub-Bar | `⚡25` / `⚡10` | Opens the **Tidal Sub-Options Bar**: adjust global `sea_level` by **$+5\%$ (`+0.05`)** or **$-5\%$ (`-0.05`)** (`⚡25`, clamped $[0.0, 0.50]$), or equip brush sub-modes for `coastal_shallows`, `ocean`, and `high_mountain` (`⚡10`). | diff --git a/gh-pages/worlds_biomes.md b/gh-pages/worlds_biomes.md index d09de9f..f60c584 100644 --- a/gh-pages/worlds_biomes.md +++ b/gh-pages/worlds_biomes.md @@ -1,59 +1,57 @@ # Worlds, Biomes & Scientific Presets -**Evolution** models spatial ecology across an axial hexagonal grid (`HexMath`, `EvoTypes.HexCell`) where elevation, temperature, humidity, seasonal oscillations, and glacial sea-level cycles govern local carrying capacity ($K$), available biomass ($R$), and movement barriers. +**Evolution** models spatial ecology across a **`169`-hex (`map_radius = 7`)** pointy-top axial hexagonal grid (`HexMath`, `EvoTypes.HexCell`) where elevation, temperature, humidity, seasonal oscillations, rural hinterland cultivation (`crops` and `pasture`), and glacial sea-level cycles govern local carrying capacity ($K$), available biomass ($R$), and movement barriers. -Rather than relying on random unstructured noise, the simulator ships with three **Scientific World Presets** (`EvoPresets`) designed to test foundational hypotheses in evolutionary biology, human paleoecology, biogeography, and evolutionary game theory. +Rather than relying on random unstructured noise, the simulator ships with three **Scientific World Presets** (`EvoPresets`) designed to test foundational hypotheses in evolutionary biology, human paleoecology, insular biogeography, urban-rural co-evolution, and evolutionary game theory. --- -## Hexagonal Grid Geometry (`HexMath`) +## Hexagonal Grid Geometry & 1-Deme-Per-Hex Exclusivity (`HexMath`) Every world is a pointy-top hexagonal spiral centered at axial coordinate $(q, r) = (0, 0)$ with cube constraint $s = -q - r$ ($q + r + s = 0$). -### Coordinate System & Spiral Generation +### Coordinate System, `169`-Hex Scale & Single-Deme Footprint * **Hex Cell Identifier**: Stored as `"%d,%d" % [q, r]` (e.g., `"0,0"`, `"-2,1"`, `"2,-1"`). * **Spiral Generation (`HexMath.generate_hex_spiral(radius)`)**: Iterates over $q \in [-R, +R]$ and $r \in [\max(-R, -q - R), \min(R, -q + R)]$. The total number of hexagonal cells for map radius $R$ is: $$N_{\text{cells}}(R) = 1 + 3R(R + 1)$$ - * $R = 4$ (*The Finite Island*): **$61$ hexagons** - * $R = 5$ (*The Rift Valley* & *Darwin's Archipelago*): **$91$ hexagons** + * **$R = 7$ (All 3 Scientific Presets — *The Rift Valley*, *Darwin's Archipelago*, and *The Finite Island*)**: **$169$ hexagons** * **Axial Distance (`HexMath.hex_distance`)**: $$d\big((q_1, r_1), (q_2, r_2)\big) = \left\lfloor \frac{|q_1 - q_2| + |r_1 - r_2| + |(-q_1 - r_1) - (-q_2 - r_2)|}{2} \right\rfloor$$ * **6 Axial Neighbor Directions (`HexMath.DIRECTIONS`)**: $$\big[(+1, 0),\ (+1, -1),\ (0, -1),\ (-1, 0),\ (-1, +1),\ (0, +1)\big]$$ + Neighbor lookups (`HexMath.get_neighbor_ids`) are memoized in `_neighbor_caches_by_size` using the collision-free coordinate hash $(q + 16) \times 32 + (r + 16)$ (supporting `map_radius <= 15`). * **Pointy-Top World Plane Projection (`HexMath.axial_to_world`, $\text{HEX\_SIZE} = 240.0$)**: $$x = 240.0 \cdot \sqrt{3} \cdot (q + 0.5r), \qquad y = 240.0 \cdot 1.5 \cdot r$$ -* **Multi-Village Sector Offsets (`HexMath.compute_village_sector_offset`)**: Up to **3 demes** can cohabit a single hex cell simultaneously. When multiple demes occupy the same hex, their plazas are arranged symmetrically around the hex center: - * **1 Deme**: Center $(0, 0)$ at scale $1.00\times$. - * **2 Demes**: Opposite diagonal offsets $(\mp 0.45 \cdot \text{HEX\_SIZE},\ \mp 0.14 \cdot \text{HEX\_SIZE})$ at scale $0.86\times$. - * **3 Demes**: Radial triad at angles $\big[-90^\circ,\ +32.4^\circ,\ +147.6^\circ\big]$ and radius $0.48\text{–}0.52 \cdot \text{HEX\_SIZE}$ at scale $0.78\times$. +* **Single-Deme-Per-Hex Exclusivity (`cell.deme_ids.size() <= 1`)**: + Every habitable hex cell supports **at most one resident deme (`cell.deme_ids.size() <= 1`)**. `HexMath.compute_village_sector_offset` always returns `Vector2.ZERO`, giving every settlement the full $240\text{-px}$ radial footprint of its hexagon for its 4-tier urban architecture (`I · Camp` $\to$ `IV · Metropolis`), hex-edge perimeter walls (`wall_r = 0.78`), and animated villagers. All cooperation, trade, Hawk-Dove competition, raids, wars, rural hinterland cultivation, and synoecism occur **across adjacent hex edges**. --- -## The 7 Biomes & Climate Defaults +## The 7 Biomes, Rural Land Use & Climate Defaults -Each `HexCell` stores its active `biome`, `temperature` ($[-1.0, +1.0]$), `humidity` ($[0.0, +1.0]$), `altitude` ($[0.0, +1.0]$), current biomass `current_resources` ($R$), current `carrying_capacity` ($K$), undamaged ceiling `carrying_capacity_max` ($K_{\max}$), and `seasonal_regen_rate` ($r_{\text{regen}}$). +Each `HexCell` stores its active `biome`, `temperature` ($[-1.0, +1.0]$), `humidity` ($[0.0, +1.0]$), `altitude` ($[0.0, +1.0]$), current biomass `current_resources` ($R$), current `carrying_capacity` ($K$), undamaged ceiling `carrying_capacity_max` ($K_{\max}$), `seasonal_regen_rate` ($r_{\text{regen}}$), rural `land_use` (`"wild" | "crops" | "pasture"`), `owner_deme_id`, and persistent `has_ruins` flag. The baseline biophysical parameters defined in `EvoTypes.BIOME_CLIMATE_DEFAULTS` are: -| Biome ID | Display Name (EN / ES) | Temp ($T$) | Humidity ($H$) | Altitude Range (`min`–`max`) | Seasonal Regen ($r_{\text{regen}}$) | Base $K_{\max}$ | Ecological Role & Flora Props | +| Biome ID | Display Name (EN / ES) | Temp ($T$) | Humidity ($H$) | Altitude Range (`min`–`max`) | Seasonal Regen ($r_{\text{regen}}$) | Base $K_{\max}$ | Ecological Role & Rural Land-Use Conversion | | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :--- | -| `tropical_forest` | **Tropical Forest** / *Bosque Tropical* | `+0.45` | `0.85` | `0.28 – 0.65` | `0.28` | `600.0` | High-biomass, high-regeneration sanctuary that favors conservative foraging (`dec-conservative-forage`). | -| `savanna` | **Savanna** / *Sabana* | `+0.50` | `0.40` | `0.28 – 0.65` | `0.18` | `420.0` | Intermediate seasonal mosaic; rewards food storage, cooperation, and selective migration during dry cycles. | -| `arid_steppe` | **Arid Steppe** / *Estepa Árida* | `+0.65` | `0.20` | `0.30 – 0.70` | `0.12` | `280.0` | Water-stressed scrubland with slow biomass replenishment and high vulnerability to overexploitation. | -| `tundra` | **Tundra** / *Tundra* | `-0.55` | `0.35` | `0.35 – 0.74` | `0.10` | `240.0` | Sub-zero boreal habitat; imposes steep thermal metabolic costs unless buffered by high `spatial_mobility` / `bio-thermal-tolerance`. | -| `high_mountain` | **High Mountain** / *Alta Montaña* | `-0.40` | `0.25` | `0.78 – 0.98` | `0.08` | `140.0` | Tectonic barrier ridge. Traversable only when $\text{altitude} \le 0.85$ and $(\text{altitude} - 0.50) \le v_{\text{eff}}.\text{spatial\_mobility}$. | -| `coastal_shallows` | **Coastal Shallows** / *Bajíos Costeros* | `+0.20` | `0.70` | `0.14 – 0.24` | `0.20` | `260.0` | Intertidal land bridges (`alt = 0.18` in Darwin's Archipelago) that periodically emerge and flood with sea-level changes. | +| `tropical_forest` | **Tropical Forest** / *Bosque Tropical* | `+0.45` | `0.85` | `0.28 – 0.65` | `0.28` | `600.0` | High-biomass, high-regeneration sanctuary; converts to golden agricultural **`"crops"`** when claimed as a rural hinterland. | +| `savanna` | **Savanna** / *Sabana* | `+0.50` | `0.40` | `0.28 – 0.65` | `0.18` | `420.0` | Intermediate seasonal mosaic; rewards food storage, cooperation, and converts to **`"crops"`** when cultivated by an adjacent settlement. | +| `arid_steppe` | **Arid Steppe** / *Estepa Árida* | `+0.65` | `0.20` | `0.30 – 0.70` | `0.12` | `280.0` | Water-stressed scrubland; converts to fenced livestock **`"pasture"`** (`pasture_pen` & `granary`) when claimed as a rural hinterland. | +| `tundra` | **Tundra** / *Tundra* | `-0.55` | `0.35` | `0.35 – 0.74` | `0.10` | `240.0` | Sub-zero boreal habitat; imposes thermal metabolic costs and converts to pastoral **`"pasture"`** when claimed by an adjacent town. | +| `high_mountain` | **High Mountain** / *Alta Montaña* | `-0.40` | `0.25` | `0.78 – 0.98` | `0.08` | `140.0` | Tectonic barrier ridge. Traversable only when $\text{altitude} \le 0.85$ and $(\text{altitude} - 0.50) \le v_{\text{eff}}.\text{spatial\_mobility}$; cannot be claimed as rural farmland. | +| `coastal_shallows` | **Coastal Shallows** / *Bajíos Costeros* | `+0.20` | `0.70` | `0.14 – 0.24` | `0.20` | `260.0` | Intertidal land bridges (`alt = 0.18` in Darwin's Archipelago) that periodically emerge and flood with sea-level changes; converts to **`"crops"`** when exposed and claimed. | | `ocean` | **Ocean** / *Océano* | `+0.10` | `0.90` | `0.02 – 0.08` | `0.02` | `50.0` | Deep marine barrier ($R = 0.0$). Impassable to terrestrial demes; submergence displaces or extinguishes resident populations. | ### Passability & Barrier Traversal Formula -Whether a deme can migrate into or maintain genetic contact across an adjacent hexagon `ncell` is governed by `EvoDemographyMigration.is_hex_passable_for_deme(ncell, spatial_mobility)`: +Whether a deme can migrate into, claim rural hinterlands in, or maintain genetic contact across an adjacent hexagon `ncell` is governed by `EvoDemographyMigration.is_hex_passable_for_deme(ncell, spatial_mobility)`: $$\text{Passable}(\text{ncell}, v_{\text{eff}}) \iff \text{ncell.biome} \neq \text{"ocean"} \;\land\; \text{ncell.altitude} \le 0.85 \;\land\; (\text{ncell.altitude} - 0.50) \le v_{\text{eff}}.\text{spatial\_mobility}$$ -!!! info "Tectonic Ridges as Selective Barriers" - In **The Rift Valley**, the central mountain ridge (`q == 0, r != 0`) has `altitude = 0.88 > 0.85`, forming an impassable tectonic wall—forcing populations to either funnel through the central valley pass at `(0, 0)` (`altitude = 0.52`), wait for the player to carve a mountain pass with the **Valley Pickaxe** (`-0.18` altitude), or evolve in geographic isolation. +!!! info "Tectonic Ridges & Mountain Passes" + In **The Rift Valley**, the central mountain ridge (`q == 0`) has `altitude = 0.88 > 0.85` except at three natural savanna mountain passes at **`(0, -3)`**, **`(0, 0)`**, and **`(0, 3)`** (`altitude = 0.52`), forcing populations to funnel through the passes, carve new corridors with the **Valley Pickaxe** (`-0.18` altitude), or evolve in geographic isolation. --- @@ -67,7 +65,7 @@ flowchart TD Glacial -- "Yes" --> SeaToggle["Toggle Sea Level:\n0.12 (Low Tide) ↔ 0.25 (High Tide)"] Glacial -- "No" --> FloodCheck["Evaluate Coastal Elevation vs. sea_level"] SeaToggle --> FloodCheck - FloodCheck --> Submerge["alt < sea_level → biome = 'ocean'\nDisplace Demes (−20% Reserves) or Drown"] + FloodCheck --> Submerge["alt < sea_level → biome = 'ocean'\nClear land_use / owner_deme_id\nDisplace Deme to Empty Neighbor or Drown"] FloodCheck --> Emerge["alt >= sea_level (was ocean) → Emerge Land\ncoastal_shallows / savanna / steppe / mountain"] Submerge & Emerge --> Regen["Oscillate Cell Temp (±0.04) & Humidity (±0.05)\nRecover K (+5% or +0.5% if Collapsed)\nRegenerate Biomass R = min(K, R + K · r_regen · γ_season)"] ``` @@ -84,16 +82,16 @@ When `state.glacial_sea_level_cycle == true` (active by default in `preset-darwi $$\text{sea\_level}(t) = \begin{cases} 0.12 \ (\text{Low Tide — Land Bridges Exposed}) & \text{if } (t / 8) \bmod 2 == 0 \\ 0.25 \ (\text{High Tide — Land Bridges Submerged}) & \text{if } (t / 8) \bmod 2 == 1 \end{cases}$$ -* **Generations `1–7`**: `sea_level = 0.12` — coastal shallows (`altitude = 0.18`) remain passable land bridges. -* **Generations `8–15`**: `sea_level = 0.25` — coastal shallows flood into `ocean`, isolating island populations for 8 consecutive turns. +* **Generations `1–7`**: `sea_level = 0.12` — the 7 coastal shallows (`altitude = 0.18`) remain passable land bridges. +* **Generations `8–15`**: `sea_level = 0.25` — coastal shallows flood into `ocean`, isolating outer island populations for 8 consecutive turns. * **Generations `16–23`**: `sea_level = 0.12` — land bridges re-emerge as `coastal_shallows` with $K_{\max} = \max(160.0, K_{\max})$, $K = \max(\text{round}(0.88 \cdot K_{\max}), K)$, and $R = \max(R, \text{round}(0.35 \cdot K))$. -### 3. Coastal Submergence & Deme Relocation -Whenever a hex's `altitude` falls below `state.sea_level` (via glacial cycles or God-mode altitude/sea-level tools), it converts to `"ocean"` and any resident demes must evacuate immediately: +### 3. Coastal Submergence & 1-Deme-Per-Hex Relocation +Whenever a hex's `altitude` falls below `state.sea_level` (via glacial cycles or God-mode altitude/sea-level tools), it converts to `"ocean"`, resets `land_use = "wild"`, `owner_deme_id = ""`, and `has_ruins = false`, and any resident deme must evacuate immediately: -1. The engine scans all 6 adjacent neighbor hexes for valid refuges (`ncell.biome != "ocean"`, `ncell.deme_ids.size() < 3`, and `ncell.altitude <= 0.85`), sorted by highest `current_resources` ($R$). -2. **Emergency Relocation**: If a refuge exists, the deme relocates to the best neighbor, suffers a **$20\%$ energy reserve penalty** ($E_{\text{reserves}} \leftarrow 0.80 \cdot E_{\text{reserves}}$), and generates a `"migration"` flow (`intensity = 0.80`). -3. **Submergence Extinction**: If all surrounding hexes are ocean, impassable peaks (`altitude > 0.85`), or full ($3$ demes), the deme drowns—recording an `"extinction"` entry in the Chronicle and leaving behind sunken `ruins`. +1. The engine scans all 6 adjacent neighbor hexes for valid **empty** refuges (`ncell.biome != "ocean"`, `ncell.deme_ids.is_empty()`, `ncell.altitude <= 0.85`, and `ncell.altitude - 0.50 <= v_eff.spatial_mobility`), sorted by highest `current_resources` ($R$). +2. **Emergency Relocation**: If an empty passable refuge exists, the deme relocates to the best neighbor (clearing any prior rural `owner_deme_id`/`land_use` on that hex), suffers a **$10\%$ transit population loss** and **$20\%$ energy reserve penalty** ($E_{\text{reserves}} \leftarrow 0.80 \cdot E_{\text{reserves}}$), and generates a `"migration"` flow (`intensity = 0.80`). +3. **Submergence Extinction**: If all surrounding hexes are ocean, impassable peaks (`altitude > 0.85`), or already occupied (`deme_ids.size() >= 1`), the deme drowns—recording an `"extinction"` entry in the Chronicle and releasing any rural hinterlands it owned into `has_ruins = true`. ### 4. Passive Carrying Capacity ($K$) & Biomass ($R$) Regeneration Each non-ocean cell recovers its carrying capacity $K$ toward $K_{\max}$ and replenishes available food $R$: @@ -106,55 +104,53 @@ $$R_{t+1} = \min\Big(K_{t+1},\ R_t + K_{t+1} \cdot r_{\text{regen}} \cdot \gamma --- -## Deep-Dive: The 3 Scientific World Presets & 1v1 Strategy Matchups +## Deep-Dive: The 3 Scientific World Presets (`169` Hexes) & 1v1 Strategy Matchups -Every preset world (`EvoPresets.create_preset_world(preset_id, lang, strategy_a_id, strategy_b_id)`) initializes a balanced **1v1 Evolutionary Strategy Duel** between **Founder A (`state.strategy_a_id`)** and **Founder B (`state.strategy_b_id`)**. By default, `strategy_a_id` is `strat-cooperation` (🤝 Cooperative Alliance, `#10B981`) and `strategy_b_id` is `strat-war` (⚔️ Conquering Horde, `#EF4444`), and players can switch either slot to any of the 5 base archetypes or custom user strategies (`~/.config/Evolution/user_catalog.json`) via the Top HUD Matchup Bar at Generation 0. - -When a preset builds its founding subpopulations, `EvoCatalog.apply_strategy_to_deme(state, deme, strategy_id, lang)` assigns each founder's `strategy_id`, `color`, `biological_trait_ids`, and `cultural_trait_ids` (enforcing `deme_has_cultural_capacity`), and synchronizes its root `LineageNode`. +Every preset world (`EvoPresets.create_preset_world(preset_id, lang, strategy_a_id, strategy_b_id)`) initializes a balanced **1v1 Evolutionary Strategy Duel** between **Founder A (`state.strategy_a_id`)** and **Founder B (`state.strategy_b_id`)** on a **`169`-hex (`map_radius = 7`)** map. By default, `strategy_a_id` is `strat-cooperation` (🤝 Cooperative Alliance, `#10B981`) and `strategy_b_id` is `strat-war` (⚔️ Conquering Horde, `#EF4444`), and players can switch either slot to any of the 5 base archetypes or custom user strategies (`~/.config/Evolution/user_catalog.json`) via the Top HUD Matchup Bar at Generation 0. ### 1. The Rift Valley (`preset-rift-savanna`) -> **Scientific Hypothesis**: *East Side Story / Tectonic Savanna Hypothesis* — How tectonic uplift and eastern rain-shadow aridification pit a western rainforest population (Strategy A) against an eastern savanna-steppe population (Strategy B) separated by a high-mountain ridge. +> **Scientific Hypothesis**: *East Side Story / Tectonic Savanna Hypothesis* — How tectonic uplift and eastern rain-shadow aridification pit a western rainforest population (Strategy A) against an eastern savanna-steppe population (Strategy B) separated by a high-mountain ridge pierced by three mountain passes. -![Late Simulation in The Rift Valley — Consolidated Settlements & Road Networks](assets/img/screenshot_late_simulation.png) +![Continuous Multi-Hex Walled Union & Hinterlands in The Rift Valley](assets/img/screenshot_late_simulation.png) -* **Map Geometry**: Radius $R = 5$ (**$91$ hexes**), `sea_level = 0.12`, `glacial_sea_level_cycle = false`. +* **Map Geometry**: Radius $R = 7$ (**$169$ hexes**), `sea_level = 0.12`, `glacial_sea_level_cycle = false`. * **Topographic Partition**: | Region | Axial Condition | Biome | Temp | Humidity | Altitude | Initial $R$ | Initial $K$ | $K_{\max}$ | Regen $r_{\text{regen}}$ | | :--- | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | - | **Western Basin** | $q < 0$ (40 hexes) | `tropical_forest` | `+0.45` | `0.85` | `0.35` | `560.0` | `600.0` | `650.0` | `0.30` | - | **Central Pass** | $q = 0, r = 0$ (1 hex) | `savanna` | `+0.25` | `0.45` | `0.52` | `240.0` | `280.0` | `320.0` | `0.18` | - | **Tectonic Ridge** | $q = 0, r \neq 0$ (10 hexes) | `high_mountain` | `-0.35` | `0.25` | `0.88` | `70.0` | `108.0` | `120.0` | `0.08` | - | **Eastern Mosaic (Odd)** | $q > 0,\ |q+r| \bmod 2 = 1$ | `savanna` | `+0.60` | `0.30` | `0.42` | `260.0` | `335.0` | `380.0` | `0.16` | - | **Eastern Mosaic (Even)** | $q > 0,\ |q+r| \bmod 2 = 0$ | `arid_steppe` | `+0.60` | `0.30` | `0.42` | `260.0` | `335.0` | `380.0` | `0.16` | + | **Western Basin** | $q < 0$ (77 hexes) | `tropical_forest` | `+0.45` | `0.85` | `0.35` | `560.0` | `600.0` | `650.0` | `0.30` | + | **3 Mountain Passes** | $q = 0,\ r \in \{-3, 0, +3\}$ (3 hexes) | `savanna` | `+0.25` | `0.45` | `0.52` | `240.0` | `280.0` | `320.0` | `0.18` | + | **Tectonic Ridge** | $q = 0,\ r \notin \{-3, 0, +3\}$ (12 hexes) | `high_mountain` | `-0.35` | `0.25` | `0.88` | `70.0` | `108.0` | `120.0` | `0.08` | + | **Eastern Mosaic (Odd)** | $q > 0,\ |q+r| \bmod 2 = 1$ (39 hexes) | `savanna` | `+0.60` | `0.30` | `0.42` | `260.0` | `335.0` | `380.0` | `0.16` | + | **Eastern Mosaic (Even)** | $q > 0,\ |q+r| \bmod 2 = 0$ (38 hexes) | `arid_steppe` | `+0.60` | `0.30` | `0.42` | `260.0` | `335.0` | `380.0` | `0.16` | * **1v1 Founding Lineages & Subpopulations**: | Slot | Deme ID & Name (EN / ES) | Lineage ID & Name (EN / ES) | Founder Hex | Initial Pop | Reserves | $\Delta E_0$ | Assigned Strategy | System 1 Model | | :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | | **Strategy A** | `deme-rift-west`
**Western Forest Hominids**
*Homínidos del Bosque Occidental* | `lineage-rift-west`
**Western Rift Lineage**
*Linaje Occidental del Rift* | `-2,1`
(`tropical_forest`) | `140` | `180.0` | `+12.0` | `state.strategy_a_id`
*(Default: `strat-cooperation` 🤝)* | `d3:4b` | | **Strategy B** | `deme-rift-east`
**Eastern Savanna Hominids**
*Homínidos de la Sabana Oriental* | `lineage-rift-east`
**Eastern Rift Lineage**
*Linaje Oriental del Rift* | `2,-1`
(`arid_steppe`) | `140` | `180.0` | `+8.0` | `state.strategy_b_id`
*(Default: `strat-war` ⚔️)* | `tev1:4b` | -* **Evolutionary Dynamics**: - * **Western Forest Hominids (`-2,1`)** inhabit high-biomass `tropical_forest` ($R = 560, K = 600$), enabling steady demographic growth and western basin expansion. - * **Eastern Savanna Hominids (`2,-1`)** face lower humidity (`0.30`) and seasonal scarcity in the eastern mosaic, driving cooperation, storage, selective migration through the central pass `(0, 0)`, **Baldwin genetic assimilation** (`baldwin_counters >= 8`), **Cumulative Cultural Innovation**, and **Allopatric Speciation**. +* **Evolutionary & Urban Dynamics**: + * **Western Forest Hominids (`-2,1`)** begin at Tier II (`pop = 140`) in high-biomass `tropical_forest` ($R = 560, K = 600$), rapidly cultivating adjacent `"crops"` hinterlands and expanding across the western basin. + * **Eastern Savanna Hominids (`2,-1`)** begin at Tier II (`pop = 140`) in the eastern mosaic, cultivating mixed `"crops"` (savanna) and `"pasture"` (arid steppe) hinterlands while navigating seasonal scarcity, mountain passes (`(0,-3)`, `(0,0)`, `(0,3)`), **Baldwin genetic assimilation**, **Cumulative Cultural Innovation**, and **Allopatric Speciation**. --- ### 2. Darwin's Archipelago (`preset-darwin-archipelago`) -> **Scientific Hypothesis**: *Insular Biogeography & Allopatric Speciation* — How glacial eustatic sea-level oscillations alternately connect volcanic islands via temporary land bridges (`coastal_shallows`) and isolate rival colonizing founder strategies across contrasting thermal niches. +> **Scientific Hypothesis**: *Insular Biogeography & Allopatric Speciation* — How glacial eustatic sea-level oscillations alternately connect volcanic islands via 7 temporary land bridges (`coastal_shallows`) and isolate rival colonizing founder strategies across contrasting thermal niches. -![Darwin's Archipelago — Insular Dispersal & Land Bridges](assets/img/screenshot_darwin_archipelago.png) +![Darwin's Archipelago — 169-Hex Insular Dispersal & Land Bridges](assets/img/screenshot_darwin_archipelago.png) -* **Map Geometry**: Radius $R = 5$ (**$91$ hexes**), `sea_level = 0.12`, `glacial_sea_level_cycle = true` (toggles `0.12` $\leftrightarrow$ `0.25` every 8 generations). +* **Map Geometry**: Radius $R = 7$ (**$169$ hexes**), `sea_level = 0.12`, `glacial_sea_level_cycle = true` (toggles `0.12` $\leftrightarrow$ `0.25` every 8 generations). * **Archipelago Layout**: | Island / Feature | Hex Coordinates | Biome | Temp | Humidity | Altitude | Initial $R$ | Initial $K$ | $K_{\max}$ | Regen $r_{\text{regen}}$ | | :--- | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **Central Hub Island** | `0,0` (1 hex) | `tropical_forest` | `+0.20` | `0.75` | `0.40` | `320.0` | `395.0` | `450.0` | `0.24` | - | **2 Central Duel Islands** | `-1,0`, `1,0` (2 hexes) | `tropical_forest` | `+0.22` | `0.75` | `0.65` | `220.0` | `220.0` | `220.0` | `0.24` | - | **3 Tidal Land Bridges** | `0,-1`, `0,1`, `2,0` (3 hexes) | `coastal_shallows` | `+0.15` | `0.65` | `0.18` | `200.0` | `230.0` | `260.0` | `0.22` | - | **Northern Boreal Island** | `0,-2`, `0,-3` (2 hexes) | `tundra` | `-0.55` | `0.40` | `0.55` | `360.0` | `380.0` | `420.0` | `0.24` | - | **Southern Tropical Island** | `0,2`, `0,3` (2 hexes) | `tropical_forest` | `+0.55` | `0.85` | `0.45` | `420.0` | `450.0` | `500.0` | `0.28` | - | **Eastern Xeric Island** | `3,0`, `3,-1` (2 hexes) | `arid_steppe` | `+0.65` | `0.25` | `0.45` | `330.0` | `360.0` | `400.0` | `0.22` | - | **Surrounding Deep Sea** | Remaining 79 hexes | `ocean` | `+0.10` | `0.90` | `0.05` | `0.0` | `20.0` | `60.0` | `0.05` | + | **Central Duel Islands** | `-1,0`, `1,0`, `-2,0`, `-1,-1`, `2,-1`, `1,1` (6 hexes) | `tropical_forest` | `+0.22` | `0.75` | `0.65` | `220.0` | `220.0` | `220.0` | `0.24` | + | **7 Tidal Land Bridges** | `0,-1`, `0,1`, `2,0`, `-2,1`, `0,-4`, `0,4`, `4,-1` (7 hexes) | `coastal_shallows` | `+0.15` | `0.65` | `0.18` | `200.0` | `230.0` | `260.0` | `0.22` | + | **Northern Boreal Chain** | `0,-2`, `0,-3`, `1,-2`, `-1,-2`, `1,-3`, `0,-5`, `1,-4` (7 hexes) | `tundra` | `-0.55` | `0.40` | `0.55` | `360.0` | `380.0` | `420.0` | `0.24` | + | **Southern Tropical Chain** | `0,2`, `0,3`, `-1,2`, `1,2`, `-1,3`, `0,5`, `-1,4` (7 hexes) | `tropical_forest` | `+0.55` | `0.85` | `0.45` | `420.0` | `450.0` | `500.0` | `0.28` | + | **Eastern Xeric Chain** | `3,0`, `3,-1`, `4,0`, `3,1`, `4,-2`, `5,-1` (6 hexes) | `arid_steppe` | `+0.65` | `0.25` | `0.45` | `330.0` | `360.0` | `400.0` | `0.22` | + | **Surrounding Deep Sea** | Remaining 135 hexes | `ocean` | `+0.10` | `0.90` | `0.05` | `0.0` | `20.0` | `60.0` | `0.05` | * **1v1 Founding Lineages & Subpopulations**: | Slot | Deme ID & Name (EN / ES) | Lineage ID & Name (EN / ES) | Founder Hex | Initial Pop | Reserves | $\Delta E_0$ | Assigned Strategy | System 1 Model | | :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | @@ -162,24 +158,25 @@ When a preset builds its founding subpopulations, `EvoCatalog.apply_strategy_to_ | **Strategy B** | `deme-darwin-rival`
**Eastern Island Rivals**
*Rivales Insulares del Este* | `lineage-darwin-b`
**Eastern Archipelago Lineage**
*Linaje Oriental del Archipiélago* | `1,0`
(`tropical_forest`) | `95` | `165.0` | `+10.0` | `state.strategy_b_id`
*(Default: `strat-war` ⚔️)* | `tev1:4b` | * **Evolutionary Dynamics**: * Both rival founders begin on symmetric high-ground island perches (`-1,0` and `1,0`, `K = 220`) flanking the central hub `(0, 0)`. - * During **Generations 1–7** (low tide, `sea_level = 0.12`), exposed coastal land bridges (`0,-1`, `0,1`, `2,0`) allow budding daughter demes from both strategies to race across the central hub onto the outer Tundra (`0,-2`, `0,-3`), Southern Tropical (`0,2`, `0,3`), and Eastern Arid Steppe (`3,0`, `3,-1`) islands. - * At **Generation 8**, `sea_level` rises to `0.25`, submerging the 3 land bridges (`altitude = 0.18 < 0.25`) into `ocean`. Any deme still standing on a bridge flees onto an adjacent island or drowns, and isolated island colonies accumulate `isolation_turns` until reaching $\ge 10$ turns and diverging via **Allopatric Speciation** into new child species in `lineage_tree` (while retaining their parent `strategy_id`). + * During **Generations 1–7** (low tide, `sea_level = 0.12`), exposed coastal land bridges (`0,-1`, `0,1`, `2,0`, `-2,1`, `0,-4`, `0,4`, `4,-1`) allow budding daughter demes from both strategies to race across the central hub onto the outer Tundra, Southern Tropical, and Eastern Arid Steppe island chains. + * At **Generation 8**, `sea_level` rises to `0.25`, submerging all 7 land bridges (`altitude = 0.18 < 0.25`) into `ocean`. Any deme standing on a bridge evacuates to an empty adjacent island hex or drowns, and isolated island colonies accumulate `isolation_turns` until reaching $\ge 10$ turns and diverging via **Allopatric Speciation** into new child species in `lineage_tree` (while retaining their parent `strategy_id`). --- ### 3. The Finite Island (`preset-finite-island`) -> **Scientific Hypothesis**: *The Tragedy of the Commons & Cultural Stewardship* — How two competing clans sharing a bounded island ecosystem with slow biomass regeneration ($r_{\text{regen}} = 0.08$) either exhaust their carrying capacity $K$ through intensive overexploitation and Hawk-Dove conflict, or stabilize via Communal Conservation Norms and Reciprocal Symbiosis. +> **Scientific Hypothesis**: *The Tragedy of the Commons & Cultural Stewardship* — How two competing clans sharing a bounded island ecosystem with slow biomass regeneration ($r_{\text{regen}} = 0.08$) either exhaust their carrying capacity $K$ through intensive overexploitation and Hawk-Dove conflict, or stabilize via Communal Conservation Norms, rural hinterland stewardship, and Reciprocal Symbiosis. ![The Finite Island — Ecological Health Lens & Valley Legend](assets/img/screenshot_finite_island.png) -* **Map Geometry**: Radius $R = 4$ (**$61$ hexes**), `sea_level = 0.20`, `glacial_sea_level_cycle = false`. +* **Map Geometry**: Radius $R = 7$ (**$169$ hexes**), `sea_level = 0.20`, `glacial_sea_level_cycle = false`. * **Concentric Island Rings** (by axial distance $d = \text{hex\_distance}(0, 0, q, r)$): | Ring | Distance $d$ | Biome | Temp | Humidity | Altitude | Initial $R$ | Initial $K$ | $K_{\max}$ | Regen $r_{\text{regen}}$ | | :--- | :---: | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **Fertile Core** | $d \le 1$ (7 hexes) | `savanna` | `+0.30` | `0.55` | `0.42` | `580.0` | `600.0` | `600.0` | `0.08` | - | **Coastal Perimeter** | $d = 2$ (12 hexes) | `arid_steppe` | `+0.45` | `0.30` | `0.48` | `200.0` | `250.0` | `280.0` | `0.08` | - | **Abyssal Ocean** | $d \ge 3$ (42 hexes) | `ocean` | `+0.15` | `0.90` | `0.05` | `0.0` | `20.0` | `60.0` | `0.05` | + | **Steppe Interior & Coast** | $2 \le d \le 3$ (30 hexes) | `arid_steppe` | `+0.45` | `0.30` | `0.48` | `220.0` | `270.0` | `300.0` | `0.08` | + | **Outer Shallows Reefs** | $d = 4,\ |q-r| \bmod 2 = 0$ (12 hexes) | `coastal_shallows` | `+0.22` | `0.65` | `0.22` | `180.0` | `220.0` | `260.0` | `0.14` | + | **Abyssal Ocean** | Remaining 120 hexes | `ocean` | `+0.15` | `0.90` | `0.05` | `0.0` | `20.0` | `60.0` | `0.05` | * **1v1 Founding Lineages & Clans**: | Slot | Deme ID & Clan Name (EN / ES) | Lineage ID & Name (EN / ES) | Founder Hex | Initial Pop | Reserves | $\Delta E_0$ | Assigned Strategy | System 1 Model | | :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | @@ -187,4 +184,4 @@ When a preset builds its founding subpopulations, `EvoCatalog.apply_strategy_to_ | **Strategy B** | `deme-island-2`
**Southern Timber Clan**
*Clan Maderero del Sur* | `lineage-island-2`
**Southern Timber Lineage**
*Linaje Maderero del Sur* | `1,0`
(`savanna`) | `95` | `140.0` | `+8.0` | `state.strategy_b_id`
*(Default: `strat-war` ⚔️)* | `tev1:4b` | * **Evolutionary Dynamics**: * **Northern Quarry Clan (`-1,0`, Strategy A)** and **Southern Timber Clan (`1,0`, Strategy B)** start on opposite sides of the fertile savanna core ($d \le 1$, where natural regeneration is constrained to $r_{\text{regen}} = 0.08$). - * When an extractive or martial strategy (`strat-war` ⚔️ or `strat-innovation` 🔬 carrying `cul-extraction-tools` without `cul-communal-conservation`) expands across the island, intensive harvesting degrades $K$ by $18\% \cdot \sum v_{\text{eff}}.\text{ecological\_impact}$ per turn until $K < 0.60 \cdot K_{\max}$ triggers the **Tragedy of the Commons**—whereas cooperative or mercantile strategies (`strat-cooperation` 🤝, `strat-trade` ⚖️) preserve $K$ and build symbiotic trade networks. + * When an extractive or martial strategy (`strat-war` ⚔️ or `strat-innovation` 🔬 carrying `cul-extraction-tools` without `cul-communal-conservation`) expands across the island, intensive harvesting degrades $K$ by $18\% \cdot \sum v_{\text{eff}}.\text{ecological\_impact}$ per turn until $K < 0.60 \cdot K_{\max}$ triggers the **Tragedy of the Commons**—whereas cooperative or mercantile strategies (`strat-cooperation` 🤝, `strat-trade` ⚖️) preserve $K$, maintain urban `salubrity`, and build symbiotic trade networks and multi-hex walled unions. diff --git a/mkdocs.yml b/mkdocs.yml index a799d35..3975566 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Evolution -site_description: Interactive bio-cultural and evolutionary population simulator built with Godot 4.7, featuring a cozy isometric hexagonal ecosystem, 6-axis thermodynamic conservation, and a dual-frequency Ollama cognitive engine. +site_description: Interactive bio-cultural and evolutionary population simulator built with Godot 4.7, featuring a 169-hex 2.5D illustrated isometric ecosystem, GPU shaders, rural hinterlands, multi-hex walled unions, 6-axis thermodynamic conservation, and a dual-model Ollama System 1 cognitive engine. site_author: Arrase site_url: https://arrase.github.io/Evolution/ docs_dir: gh-pages diff --git a/src/core/catalog.gd b/src/core/catalog.gd index 5e631e5..09772ba 100644 --- a/src/core/catalog.gd +++ b/src/core/catalog.gd @@ -45,7 +45,6 @@ static func _make_decision( p_mobility: float, p_cohesion: float, p_threshold: float, - p_required_traits: Array[String], lang: String ) -> EvoTypes.DecisionDef: var d := EvoTypes.DecisionDef.new() @@ -55,7 +54,6 @@ static func _make_decision( d.name = EvoI18n.t(lang, "decision." + p_id + ".name") d.description = EvoI18n.t(lang, "decision." + p_id + ".desc") d.vector = EvoTypes.BiophysicalVector.new(p_cost, p_extract, p_eco, p_mobility, p_cohesion, p_threshold) - d.required_trait_ids = p_required_traits.duplicate() return d @@ -71,7 +69,7 @@ static func create_base_traits_catalog(lang: String = "es") -> Dictionary: _make_trait("cul-extraction-tools", "cultural", 0.25, 0.65, -0.25, 0.10, 0.20, 0.50, [], false, lang), _make_trait("cul-communal-conservation", "cultural", 0.15, 0.25, 0.55, 0.00, 0.50, 0.55, [], false, lang), _make_trait("cul-interdeme-barter", "cultural", 0.35, 0.40, 0.15, 0.30, 0.50, 0.60, [], false, lang), - _make_trait("cul-warrior-expansion", "cultural", 0.45, 0.30, -0.35, 0.45, 0.60, 0.45, [], false, lang) + _make_trait("cul-warrior-expansion", "cultural", 0.35, 0.35, -0.25, 0.45, 0.45, 0.35, [], false, lang) ] var catalog: Dictionary = {} for t in traits: @@ -81,13 +79,22 @@ static func create_base_traits_catalog(lang: String = "es") -> Dictionary: static func create_base_decisions_catalog(lang: String = "es") -> Dictionary: var decisions: Array[EvoTypes.DecisionDef] = [ - _make_decision("dec-conservative-forage", "forage", 0.10, 0.35, 0.10, 0.00, 0.05, 0.05, [], lang), - _make_decision("dec-intensive-overexploit", "overexploit", 0.25, 0.85, -0.65, 0.00, -0.10, 0.10, [], lang), - _make_decision("dec-selective-migration", "migrate", 0.30, 0.15, 0.15, 0.75, 0.05, 0.10, [], lang), - _make_decision("dec-cooperate-store", "cooperate", 0.25, 0.45, 0.15, -0.10, 0.65, 0.35, [], lang), - _make_decision("dec-exchange-symbiosis", "exchange", 0.35, 0.50, 0.20, 0.20, 0.60, 0.45, ["bio-encephalization", "bio-social-instinct"], lang), - _make_decision("dec-territorial-dispute", "dispute", 0.55, 0.40, -0.30, 0.25, 0.55, 0.20, [], lang), - _make_decision("dec-explore-innovate", "innovate", 0.50, 0.15, 0.00, 0.40, 0.35, 0.65, ["bio-encephalization", "bio-social-instinct"], lang) + _make_decision("forage", "forage", 0.10, 0.35, 0.10, 0.00, 0.05, 0.05, lang), + _make_decision("intensify", "overexploit", 0.25, 0.85, -0.65, 0.00, -0.10, 0.10, lang), + _make_decision("fortify", "cooperate", 0.20, 0.10, 0.00, -0.20, 0.55, 0.20, lang), + _make_decision("innovate", "innovate", 0.22, 0.42, 0.15, 0.40, 0.35, 0.65, lang), + _make_decision("settle", "migrate", 0.30, 0.15, 0.15, 0.75, 0.05, 0.10, lang), + _make_decision("trade", "exchange", 0.30, 0.50, 0.20, 0.20, 0.60, 0.45, lang), + _make_decision("aid", "cooperate", 0.15, 0.20, 0.20, 0.00, 0.70, 0.35, lang), + _make_decision("offer_pact", "pact", 0.10, 0.20, 0.10, 0.05, 0.65, 0.35, lang), + _make_decision("accept_pact", "pact", 0.10, 0.20, 0.10, 0.05, 0.65, 0.35, lang), + _make_decision("demand_tribute", "dispute", 0.30, 0.40, -0.10, 0.20, -0.15, 0.15, lang), + _make_decision("pay_tribute", "exchange", 0.10, 0.10, 0.05, 0.00, 0.30, 0.20, lang), + _make_decision("raid", "raid", 0.42, 0.48, -0.22, 0.35, -0.25, 0.15, lang), + _make_decision("betray", "betrayal", 0.40, 0.55, -0.20, 0.25, -0.45, 0.15, lang), + _make_decision("conquer", "war", 0.50, 0.45, -0.30, 0.55, 0.10, 0.20, lang), + _make_decision("unite", "pact", 0.15, 0.30, 0.15, 0.00, 0.75, 0.40, lang), + _make_decision("accept_union", "pact", 0.15, 0.30, 0.15, 0.00, 0.75, 0.40, lang) ] var catalog: Dictionary = {} for d in decisions: @@ -110,6 +117,7 @@ static func _make_strategy( s.icon = p_icon s.color = p_color s.origin = "base" + s.doctrine = EvoI18n.strategy_text(p_id, "doctrine", "", "en") s.biological_trait_ids = p_bio.duplicate() s.cultural_trait_ids = p_cul.duplicate() return s @@ -193,88 +201,10 @@ static func deme_has_trait(deme: EvoTypes.Deme, trait_id: String) -> bool: return deme.biological_trait_ids.has(trait_id) or deme.cultural_trait_ids.has(trait_id) -static func compute_baseline_decision_weights(deme: EvoTypes.Deme, traits_catalog: Dictionary = {}) -> Dictionary: - var effective_traits := traits_catalog if not traits_catalog.is_empty() else create_base_traits_catalog() - var vec := compute_deme_vector(deme, effective_traits) - var has_cultural_cap := deme_has_cultural_capacity(deme, effective_traits) or deme_has_trait(deme, "bio-encephalization") or deme_has_trait(deme, "bio-social-instinct") - var has_warrior := deme_has_trait(deme, "cul-warrior-expansion") - var has_r_strat := deme_has_trait(deme, "bio-r-strategy") - var coop_cohesion := 0.0 if has_warrior else maxf(0.0, vec.social_cohesion) * (0.18 if has_r_strat else 0.45) - - var raw_scores := { - "dec-conservative-forage": ( - 0.22 - + maxf(0.0, vec.ecological_impact) * 0.45 - + (0.20 if deme_has_trait(deme, "cul-communal-conservation") else 0.0) - ), - "dec-intensive-overexploit": ( - 0.08 - + maxf(0.0, vec.extraction_efficiency) * 0.35 - + maxf(0.0, -vec.ecological_impact) * 0.30 - + (0.18 if deme_has_trait(deme, "cul-extraction-tools") else 0.0) - + (0.12 if has_r_strat else 0.0) - ), - "dec-selective-migration": ( - 0.10 - + maxf(0.0, vec.spatial_mobility) * 0.45 - + (0.28 if deme_has_trait(deme, "bio-thermal-tolerance") else 0.0) - + (0.16 if (has_warrior or has_r_strat) else 0.0) - ), - "dec-cooperate-store": ( - 0.12 - + coop_cohesion - + (0.25 if deme_has_trait(deme, "cul-communal-conservation") else 0.0) - + (0.15 if (deme_has_trait(deme, "bio-social-instinct") and not has_warrior and not has_r_strat) else 0.0) - ), - "dec-exchange-symbiosis": ( - 0.08 - + (0.0 if has_warrior else maxf(0.0, vec.social_cohesion) * 0.25) - + maxf(0.0, vec.spatial_mobility) * 0.25 - + (0.48 if deme_has_trait(deme, "cul-interdeme-barter") else 0.0) - ) if has_cultural_cap else 0.0, - "dec-territorial-dispute": ( - 0.04 - + maxf(0.0, vec.extraction_efficiency) * 0.25 - + maxf(0.0, -vec.ecological_impact) * 0.25 - + maxf(0.0, -vec.social_cohesion) * 0.40 - + (0.55 if has_warrior else 0.0) - + (0.12 if has_r_strat else 0.0) - ), - "dec-explore-innovate": ( - 0.08 - + maxf(0.0, vec.transmission_threshold) * 0.50 - + (0.35 if (deme_has_trait(deme, "cul-fire-processing") or deme_has_trait(deme, "cul-lithic-acheulean")) else 0.0) - + (0.30 if (deme_has_trait(deme, "bio-encephalization") or deme_has_trait(deme, "bio-digestive-spec")) else 0.0) - ) if has_cultural_cap else 0.0 - } - - var eligible_ids: Array[String] = [ - "dec-conservative-forage", - "dec-intensive-overexploit", - "dec-selective-migration", - "dec-cooperate-store", - "dec-territorial-dispute" - ] - if has_cultural_cap: - eligible_ids.append("dec-exchange-symbiosis") - eligible_ids.append("dec-explore-innovate") - - return EvoHeuristicScorer.normalize_decision_probabilities(raw_scores, eligible_ids) - - -static func compute_strategy_baseline_probabilities(deme: EvoTypes.Deme, traits_catalog: Dictionary = {}) -> Dictionary: - var norm := compute_baseline_decision_weights(deme, traits_catalog) - var probs: Dictionary = norm["probabilities"] - deme.last_decision_probabilities = probs.duplicate() - deme.last_dominant_decision_id = str(norm["dominant_decision_id"]) - return probs - - static func apply_strategy_to_deme( state: EvoTypes.WorldState, deme: EvoTypes.Deme, - strategy_id: String, - _lang: String = "es" + strategy_id: String ) -> void: if not state.strategies_catalog.has(strategy_id): return @@ -287,8 +217,6 @@ static func apply_strategy_to_deme( else: deme.cultural_trait_ids = [] - compute_strategy_baseline_probabilities(deme, state.traits_catalog) - if state.lineage_tree.has(deme.lineage_id): var lin: EvoTypes.LineageNode = state.lineage_tree[deme.lineage_id] lin.strategy_id = strategy.id @@ -314,8 +242,7 @@ static func deme_has_cultural_capacity(deme: EvoTypes.Deme, traits_catalog: Dict static func inject_trait_into_deme( state: EvoTypes.WorldState, deme_id: String, - trait_id: String, - lang: String = "es" + trait_id: String ) -> bool: if not state.demes.has(deme_id) or not state.traits_catalog.has(trait_id): return false @@ -368,12 +295,6 @@ static func inject_trait_into_deme( for tid in deme.cultural_trait_ids: if not lin.acquired_trait_ids.has(tid): lin.acquired_trait_ids.append(tid) - - var eval_res := EvoDecisionModel.evaluate_deme(deme, state, lang) - deme.last_decision_probabilities = eval_res["probabilities"] - deme.last_dominant_decision_id = str(eval_res["dominant_decision_id"]) - deme.last_confidence = float(eval_res["confidence"]) if eval_res.has("confidence") else 0.85 - deme.last_justification = str(eval_res["justification"]) return true diff --git a/src/core/hex_math.gd b/src/core/hex_math.gd index 35da5e5..83d8c2a 100644 --- a/src/core/hex_math.gd +++ b/src/core/hex_math.gd @@ -10,8 +10,7 @@ const DIRECTIONS: Array[Vector2i] = [ Vector2i(-1, 1), Vector2i(0, 1) ] -static var _grid37_neighbors: Dictionary = {} -static var _grid91_neighbors: Dictionary = {} +static var _neighbor_caches_by_size: Dictionary = {} static func make_id(q: int, r: int) -> String: return "%d,%d" % [q, r] @@ -29,29 +28,22 @@ static func hex_distance(q1: int, r1: int, q2: int, r2: int) -> int: static func get_neighbor_ids(q: int, r: int, valid_cells: Dictionary) -> Array[String]: var cell_count := valid_cells.size() - if cell_count == 91: - var ikey91: int = (q + 8) * 16 + (r + 8) - if _grid91_neighbors.has(ikey91): - return _grid91_neighbors[ikey91] - var cached91: Array[String] = [] - for dir in DIRECTIONS: - var nid := "%d,%d" % [q + dir.x, r + dir.y] - if valid_cells.has(nid): - cached91.append(nid) - if valid_cells.has("0,0") and valid_cells.has("5,0"): - _grid91_neighbors[ikey91] = cached91 - return cached91 - if cell_count == 37: - var ikey: int = (q + 8) * 16 + (r + 8) - if _grid37_neighbors.has(ikey): - return _grid37_neighbors[ikey] + if cell_count in [37, 61, 91, 127, 169, 217] and absi(q) <= 15 and absi(r) <= 15 and valid_cells.has("0,0"): + var ikey: int = (q + 16) * 32 + (r + 16) + var size_cache: Dictionary + if _neighbor_caches_by_size.has(cell_count): + size_cache = _neighbor_caches_by_size[cell_count] as Dictionary + if size_cache.has(ikey): + return size_cache[ikey] + else: + size_cache = {} + _neighbor_caches_by_size[cell_count] = size_cache var cached: Array[String] = [] for dir in DIRECTIONS: var nid := "%d,%d" % [q + dir.x, r + dir.y] if valid_cells.has(nid): cached.append(nid) - if valid_cells.has("0,0") and valid_cells.has("3,0"): - _grid37_neighbors[ikey] = cached + size_cache[ikey] = cached return cached var result: Array[String] = [] for dir in DIRECTIONS: @@ -68,13 +60,16 @@ static func get_brush_hex_ids(center_id: String, radius: int, valid_cells: Dicti if radius <= 1: return result var center := parse_id(center_id) - for cell_id in valid_cells.keys(): - var cid := str(cell_id) - if cid == center_id: - continue - var coords := parse_id(cid) - if hex_distance(center.x, center.y, coords.x, coords.y) <= radius - 1: - result.append(cid) + var max_dist := radius - 1 + for dq in range(-max_dist, max_dist + 1): + var r1 := maxi(-max_dist, -dq - max_dist) + var r2 := mini(max_dist, -dq + max_dist) + for dr in range(r1, r2 + 1): + if dq == 0 and dr == 0: + continue + var cid := make_id(center.x + dq, center.y + dr) + if valid_cells.has(cid): + result.append(cid) return result static func generate_hex_spiral(radius: int) -> Array[Vector2i]: @@ -91,16 +86,3 @@ static func axial_to_world(q: int, r: int, hex_size: float = HEX_SIZE) -> Vector var x: float = hex_size * sqrt(3.0) * (float(q) + float(r) * 0.5) var y: float = hex_size * 1.5 * float(r) return Vector2(x, y) - -static func compute_village_sector_offset(index: int, total_count: int, hex_size: float = HEX_SIZE) -> Vector2: - if total_count <= 1: - return Vector2.ZERO - if total_count == 2: - var dx := hex_size * 0.45 - var dy := hex_size * 0.14 - return Vector2(-dx if index == 0 else dx, -dy if index == 0 else dy) - var angles := [-PI * 0.5, PI * 0.18, PI * 0.82] - var sector := posmod(index, 3) - var ang: float = angles[sector] - var rad := hex_size * (0.48 if sector == 0 else 0.52) - return Vector2(cos(ang) * rad, sin(ang) * rad) diff --git a/src/core/i18n.gd b/src/core/i18n.gd index 67ae584..ecccabc 100644 --- a/src/core/i18n.gd +++ b/src/core/i18n.gd @@ -40,10 +40,9 @@ const STRINGS: Dictionary = { "status.struggling": "En apuros", "status.starving": "En hambruna", "tier.1": "Campamento", - "tier.2": "Caserío", - "tier.3": "Aldea Consolidada", - "tier.4": "Poblado Complejo", - "tier.5": "Metrópolis Unificada", + "tier.2": "Aldea", + "tier.3": "Villa", + "tier.4": "Metrópolis", "tool.inspect": "Observar", "tool.set_biome": "Clima / Bioma", "tool.adjust_altitude": "Relieve / Altitud", @@ -60,9 +59,11 @@ const STRINGS: Dictionary = { "tool.chronicle": "Crónica", "tool.phylo_tree": "Linajes", "tool.catalog_lab": "Laboratorio", - "lens.biome": "Bioma", - "lens.food": "Alimento (R)", - "lens.health": "Salud (K/Kmax)", + "lens.biome": "🌿 Bioma", + "lens.food": "🌾 Alimento", + "lens.health": "✚ Salud", + "lens.diplomacy": "🤝 Diplom.", + "lens.territory": "🚩 Territ.", "panel.populations": "Aldeas Activas", "panel.no_populations": "Todas las poblaciones se han extinguido.", "panel.population": "Población", @@ -127,20 +128,38 @@ const STRINGS: Dictionary = { "trait.cul-interdeme-barter.desc": "Protocolos codificados de diplomacia intergrupal e intercambio recíproco que facilitan la cohabitación pacífica, bonos calóricos y difusión cultural.", "trait.cul-warrior-expansion.name": "Tradición Guerrera / Expansionista", "trait.cul-warrior-expansion.desc": "Organización marcial y doctrinas de expansión territorial que maximizan el dominio competitivo en disputas a alto coste energético y ecológico.", - "decision.dec-conservative-forage.name": "Forrajeo Conservador", - "decision.dec-conservative-forage.desc": "Recolección oportunista de bajo riesgo y eficiente dentro de los límites de capacidad de carga local, preservando la regeneración del hábitat.", - "decision.dec-intensive-overexploit.name": "Sobreexplotación Intensiva", - "decision.dec-intensive-overexploit.desc": "Extracción agresiva de alto rendimiento que maximiza la ingesta calórica inmediata a costa de una severa degradación de la capacidad de carga K.", - "decision.dec-selective-migration.name": "Migración Selectiva", - "decision.dec-selective-migration.desc": "Dispersión espacial dirigida hacia hexágonos adyacentes transitables con mejor ratio de recursos por habitante.", - "decision.dec-cooperate-store.name": "Cooperación y Almacenamiento", - "decision.dec-cooperate-store.desc": "Puesta en común de recursos, conservación de alimentos y defensa mutua que amortiguan la escasez estacional y refuerzan la cohesión.", - "decision.dec-exchange-symbiosis.name": "Intercambio / Simbiosis", - "decision.dec-exchange-symbiosis.desc": "Intercambio recíproco de recursos y transmisión cultural horizontal con demes cohabitantes o vecinos, generando excedentes energéticos.", - "decision.dec-territorial-dispute.name": "Disputa Territorial", - "decision.dec-territorial-dispute.desc": "Estrategia Halcón agonística de monopolización de recursos frente a demes rivales en hexágonos compartidos, con alto riesgo metabólico y demográfico.", - "decision.dec-explore-innovate.name": "Exploración / Innovación", - "decision.dec-explore-innovate.desc": "Experimentación cognitiva de alto coste y prototipado de herramientas que sacrifica rendimiento inmediato para acumular innovación cultural.", + "decision.forage.name": "Forrajeo", + "decision.forage.desc": "Recolección cuidadosa en el propio hexágono: +20% de cosecha sin dañar el hábitat.", + "decision.intensify.name": "Sobreexplotar", + "decision.intensify.desc": "Cosecha agresiva (+55%) que degrada permanentemente la capacidad de carga del hexágono.", + "decision.fortify.name": "Fortificar", + "decision.fortify.desc": "Invierte reservas y recursos del hexágono en subir un nivel de muralla.", + "decision.innovate.name": "Innovar", + "decision.innovate.desc": "Experimentación cultural que acumula progreso hacia una nueva tradición.", + "decision.settle.name": "Colonizar", + "decision.settle.desc": "Envía colonos y alimento a fundar un campamento en un hexágono vacío adyacente.", + "decision.trade.name": "Comerciar", + "decision.trade.desc": "Intercambio con un vecino: beneficio para ambos si es recíproco.", + "decision.aid.name": "Ayudar", + "decision.aid.desc": "Regala alimento a un vecino; aumenta su confianza y tu reputación.", + "decision.offer_pact.name": "Proponer pacto", + "decision.offer_pact.desc": "Ofrece una alianza; el vecino decide si la acepta el turno siguiente.", + "decision.accept_pact.name": "Aceptar pacto", + "decision.accept_pact.desc": "Acepta la alianza propuesta: bonificación de cosecha y defensa mutua.", + "decision.demand_tribute.name": "Exigir tributo", + "decision.demand_tribute.desc": "Exige alimento a un vecino más débil bajo amenaza.", + "decision.pay_tribute.name": "Pagar tributo", + "decision.pay_tribute.desc": "Paga el tributo exigido a cambio de una tregua.", + "decision.raid.name": "Saquear", + "decision.raid.desc": "Incursión para robar reservas y cosechas de un vecino no aliado.", + "decision.betray.name": "Traicionar", + "decision.betray.desc": "Rompe una alianza con un ataque por sorpresa contra el aliado.", + "decision.conquer.name": "Conquistar", + "decision.conquer.desc": "Envía un ejército a tomar el hexágono del vecino.", + "decision.unite.name": "Proponer unión", + "decision.unite.desc": "Propone a un aliado o pariente fusionar ambos asentamientos.", + "decision.accept_union.name": "Aceptar unión", + "decision.accept_union.desc": "Acepta la unión propuesta (sinecismo o unión amurallada).", "chronicle.type.baldwin_trait": "Asimilación de Baldwin", "chronicle.type.cultural_innovation": "Innovación Cultural", "chronicle.type.allopatric_speciation": "Especiación Alopátrica", @@ -159,6 +178,10 @@ const STRINGS: Dictionary = { "chronicle.type.merge": "Unión de Poblaciones", "chronicle.type.tribute": "Pago de Tributo", "chronicle.type.siege": "Asedio a Fortaleza", + "chronicle.type.epidemic": "Epidemia Urbana", + "chronicle.type.epidemic_spread": "Contagio Epidémico", + "chronicle.type.epidemic_recovered": "Inmunidad Post-Epidemia", + "chronicle.type.famine": "Hambruna Severa", "filter_war": "Guerra", "filter_raid": "Saqueo", "filter_pact": "Pactos", @@ -210,16 +233,31 @@ const STRINGS: Dictionary = { "chronicle_tribute_desc": "{victim} entregó {loot} reservas como tributo a {raider} en ({hex}), evitando bajas y sellando una tregua.", "chronicle_siege_title": "Asedio Repelido en ({hex})", "chronicle_siege_desc": "Las fortificaciones de {defender} en ({hex}) resistieron el asedio de {attacker}, causando {casualties} bajas a los asaltantes.", + "chronicle_epidemic_title": "Epidemia Urbana en ({hex})", + "chronicle_epidemic_desc": "El hacinamiento y baja salubridad en ({hex}) desataron una epidemia en {deme}, causando {casualties} bajas.", + "chronicle_epidemic_spread_title": "Contagio Epidémico en ({hex})", + "chronicle_epidemic_spread_desc": "El contacto con {source} propagó la epidemia a {deme} en ({hex}), causando {casualties} bajas.", + "chronicle_epidemic_recovered_title": "Fin de Epidemia en ({hex})", + "chronicle_epidemic_recovered_desc": "{deme} superó el brote epidémico en ({hex}) y adquirió inmunidad temporal.", + "chronicle_famine_title": "Hambruna en ({hex})", + "chronicle_famine_desc": "El déficit calórico severo provocó una hambruna en {deme} ({hex}), con una pérdida de {casualties} habitantes.", + "chronicle_walled_union_title": "Unión Amurallada en ({hex})", + "chronicle_walled_union_desc": "{deme_a} y {deme_b} unificaron sus murallas perimetrales en una ciudad multi-hexágono ({hex}).", "strategy.strat-cooperation.name": "Alianza Cooperativa", "strategy.strat-cooperation.desc": "Fomenta la cohesión social, la conservación comunal del hábitat y el almacenamiento.", + "strategy.strat-cooperation.doctrine": "Alianza Cooperativa: valoras la solidaridad, cumples tu palabra y proteges a tus vecinos; recuerdas quién te ayudó.", "strategy.strat-expansion.name": "Pioneros Expansionistas", "strategy.strat-expansion.desc": "Reproducción rápida, alta movilidad y tolerancia térmica para colonizar nuevos territorios.", + "strategy.strat-expansion.doctrine": "Pioneros Expansionistas: valoras la tierra nueva y las familias numerosas; colonizas cada valle fértil.", "strategy.strat-trade.name": "Liga Mercantil", "strategy.strat-trade.desc": "Impulsa el trueque inter-tribal, la simbiosis comercial y la difusión cultural.", + "strategy.strat-trade.doctrine": "Liga Mercantil: valoras los buenos tratos, la confianza y la reputación; la riqueza fluye hacia quien comercia.", "strategy.strat-war.name": "Horda Conquistadora", "strategy.strat-war.desc": "Expansión marcial agresiva y extracción intensiva para dominar territorios rivales.", + "strategy.strat-war.doctrine": "Horda Conquistadora: honras la fuerza; la conquista, el saqueo y el tributo dan respeto; la debilidad invita al ataque.", "strategy.strat-innovation.name": "Tecnócratas Innovadores", "strategy.strat-innovation.desc": "Alta encefalización, procesado térmico y herramientas especializadas de extracción.", + "strategy.strat-innovation.doctrine": "Tecnócratas Innovadores: valoras el conocimiento y las herramientas; inviertes en inventar y superas a tus rivales con ingenio.", "lab_tab_strat": "🛡️ Estrategias", "lab_new_strat": "+ Nueva Estrategia", "lab_save_strat": "💾 Guardar Estrategia", @@ -233,96 +271,7 @@ const STRINGS: Dictionary = { "hud_matchup_reset": "🔄", "hud_matchup_reset_tip": "Reiniciar duelo en Generación 0 con las estrategias seleccionadas", "insp_strategy": "Estrategia", - "legend_strategies": "Duelo de Estrategias", - "strategies": { - "strat-cooperation": { - "name": "Alianza Cooperativa", - "desc": "Fomenta la cohesión social, la conservación comunal del hábitat y el almacenamiento." - }, - "strat-expansion": { - "name": "Pioneros Expansionistas", - "desc": "Reproducción rápida, alta movilidad y tolerancia térmica para colonizar nuevos territorios." - }, - "strat-trade": { - "name": "Liga Mercantil", - "desc": "Impulsa el trueque inter-tribal, la simbiosis comercial y la difusión cultural." - }, - "strat-war": { - "name": "Horda Conquistadora", - "desc": "Expansión marcial agresiva y extracción intensiva para dominar territorios rivales." - }, - "strat-innovation": { - "name": "Tecnócratas Innovadores", - "desc": "Alta encefalización, procesado térmico y herramientas especializadas de extracción." - } - }, - "ui": { - "lab_tab_strat": "🛡️ Estrategias", - "lab_new_strat": "+ Nueva Estrategia", - "lab_save_strat": "💾 Guardar Estrategia", - "lab_strat_icon": "Emblema", - "lab_strat_color": "Color", - "lab_strat_traits": "Rasgos de la Estrategia", - "lab_strat_vector": "Huella Biofísica Combinada", - "lab_use_strat_a": "Usar como Estrategia A", - "lab_use_strat_b": "Usar como Estrategia B", - "hud_matchup_vs": "⚔️", - "hud_matchup_reset": "🔄", - "hud_matchup_reset_tip": "Reiniciar duelo en Generación 0 con las estrategias seleccionadas", - "insp_strategy": "Estrategia", - "legend_strategies": "Duelo de Estrategias", - "filter_war": "Guerra", - "filter_raid": "Saqueo", - "filter_pact": "Pactos", - "filter_betrayal": "Traición", - "filter_fortify": "Defensa", - "filter_merge": "Unión", - "filter_tribute": "Tributo", - "filter_siege": "Asedio", - "event_war": "Guerra de Conquista", - "event_raid": "Incursión de Saqueo", - "event_pact": "Pacto Formal", - "event_betrayal": "Traición de Pacto", - "event_fortify": "Fortificación Defensiva", - "event_merge": "Unión de Poblaciones", - "event_tribute": "Pago de Tributo", - "event_siege": "Asedio a Fortaleza", - "inspector_pacts": "Pactos y Alianzas", - "inspector_no_pacts": "Sin pactos activos", - "inspector_pact_with": "Pacto activo con {partner}", - "inspector_defense_label": "Defensa", - "inspector_defense_0": "Sin fortificar", - "inspector_defense_1": "Nv.1 Empalizada (+45%)", - "inspector_defense_2": "Nv.2 Muralla (+95%)", - "inspector_defense_3": "Nv.3 Castillo (+155%)", - "inspector_merged_city": "Ciudad Unificada ({count} pueblos)", - "inspector_vendetta_with": "Rencor / Vendetta contra {partner}", - "legend_flow_war": "Guerra / Invasión", - "legend_flow_raid": "Saqueo / Incursión", - "legend_flow_pact": "Pacto / Alianza", - "legend_flow_betrayal": "Traición", - "legend_flow_tribute": "Tributo / Tregua", - "legend_flow_merge": "Unión en Ciudad", - "legend_flow_siege": "Asedio Activo", - "chronicle_war_victory_title": "Conquista Militar en ({hex})", - "chronicle_war_victory_desc": "{attacker} derrotó a {defender} en el hexágono ({hex}), causando {casualties} bajas y capturando {loot} reservas energéticas.", - "chronicle_war_repelled_title": "Invasión Repelida en ({hex})", - "chronicle_war_repelled_desc": "{defender} repelió la ofensiva de {attacker} en ({hex}), infligiendo {casualties} bajas a los atacantes.", - "chronicle_raid_title": "Incursión de Saqueo en ({hex})", - "chronicle_raid_desc": "{raider} saqueó a {victim} en ({hex}), arrebatando {loot} unidades de reservas calóricas.", - "chronicle_pact_title": "Pacto de Alianza en ({hex})", - "chronicle_pact_desc": "{deme_a} y {deme_b} consolidaron un pacto formal de cooperación e intercambio mutuo en ({hex}).", - "chronicle_betrayal_title": "Traición de Pacto en ({hex})", - "chronicle_betrayal_desc": "{betrayer} rompió su alianza y atacó por sorpresa a {victim} en ({hex}), saqueando {loot} reservas energéticas.", - "chronicle_fortify_title": "Fortificación Erigida en ({hex})", - "chronicle_fortify_desc": "{deme} invirtió excedentes para erigir {tier_name} en ({hex}), reforzando su defensa territorial.", - "chronicle_merge_title": "Unión de Pueblos en ({hex})", - "chronicle_merge_desc": "{deme_a} y {deme_b} unieron sus poblaciones y defensas en una sola ciudad en ({hex}) (pob. {pop}).", - "chronicle_tribute_title": "Tributo de Paz en ({hex})", - "chronicle_tribute_desc": "{victim} entregó {loot} reservas como tributo a {raider} en ({hex}), evitando bajas y sellando una tregua.", - "chronicle_siege_title": "Asedio Repelido en ({hex})", - "chronicle_siege_desc": "Las fortificaciones de {defender} en ({hex}) resistieron el asedio de {attacker}, causando {casualties} bajas a los asaltantes." - } + "legend_strategies": "Duelo de Estrategias" }, "en": { "app.title": "Evolution — Population Evolutionary Simulator", @@ -362,10 +311,9 @@ const STRINGS: Dictionary = { "status.struggling": "Struggling", "status.starving": "Starving", "tier.1": "Camp", - "tier.2": "Hamlet", - "tier.3": "Consolidated Village", - "tier.4": "Complex Settlement", - "tier.5": "Unified Metropolis", + "tier.2": "Village", + "tier.3": "Town", + "tier.4": "Metropolis", "tool.inspect": "Observe", "tool.set_biome": "Climate / Biome", "tool.adjust_altitude": "Terrain / Altitude", @@ -382,9 +330,11 @@ const STRINGS: Dictionary = { "tool.chronicle": "Chronicle", "tool.phylo_tree": "Lineages", "tool.catalog_lab": "Catalog Lab", - "lens.biome": "Biome", - "lens.food": "Food (R)", - "lens.health": "Health (K/Kmax)", + "lens.biome": "🌿 Biome", + "lens.food": "🌾 Food", + "lens.health": "✚ Health", + "lens.diplomacy": "🤝 Diplom.", + "lens.territory": "🚩 Territ.", "panel.populations": "Active Villages", "panel.no_populations": "All populations are extinct.", "panel.population": "Population", @@ -449,20 +399,38 @@ const STRINGS: Dictionary = { "trait.cul-interdeme-barter.desc": "Codified protocols of inter-group diplomacy and reciprocal exchange that facilitate peaceful cohabitation, mutual caloric bonuses, and cultural diffusion.", "trait.cul-warrior-expansion.name": "Warrior / Expansionist Tradition", "trait.cul-warrior-expansion.desc": "Militarized organization and territorial expansion norms that maximize competitive dominance in inter-deme disputes at high energetic and ecological cost.", - "decision.dec-conservative-forage.name": "Conservative Foraging", - "decision.dec-conservative-forage.desc": "Low-risk, energy-efficient opportunistic gathering within local carrying capacity limits, preserving habitat regeneration.", - "decision.dec-intensive-overexploit.name": "Intensive Overexploitation", - "decision.dec-intensive-overexploit.desc": "Aggressive, high-yield biomass extraction that maximizes immediate caloric intake at the expense of severe habitat carrying capacity degradation.", - "decision.dec-selective-migration.name": "Selective Migration", - "decision.dec-selective-migration.desc": "Directed spatial dispersal toward adjacent passable hexes with superior resource-to-population ratios.", - "decision.dec-cooperate-store.name": "Cooperation & Storage", - "decision.dec-cooperate-store.desc": "Collective resource pooling, food preservation, and mutual defense that buffer seasonal scarcity and strengthen intra-deme cohesion.", - "decision.dec-exchange-symbiosis.name": "Exchange / Symbiosis", - "decision.dec-exchange-symbiosis.desc": "Reciprocal resource exchange and horizontal cultural transmission with cohabiting or neighboring demes, generating non-zero-sum energetic surpluses.", - "decision.dec-territorial-dispute.name": "Territorial Dispute", - "decision.dec-territorial-dispute.desc": "Agonistic Hawk-strategy resource monopolization against rival demes in shared hexes, seizing competitor biomass shares at high metabolic and demographic risk.", - "decision.dec-explore-innovate.name": "Exploration / Innovation", - "decision.dec-explore-innovate.desc": "High-cost cognitive experimentation and tool prototyping that sacrifices immediate foraging yield to accumulate cultural innovation.", + "decision.forage.name": "Forage", + "decision.forage.desc": "Careful gathering on the home hex: +20% harvest without harming the habitat.", + "decision.intensify.name": "Overexploit", + "decision.intensify.desc": "Aggressive harvest (+55%) that permanently degrades the hex carrying capacity.", + "decision.fortify.name": "Fortify", + "decision.fortify.desc": "Invest reserves and hex resources to raise the wall level by one.", + "decision.innovate.name": "Innovate", + "decision.innovate.desc": "Cultural experimentation that accumulates progress toward a new tradition.", + "decision.settle.name": "Settle", + "decision.settle.desc": "Send settlers and food to found a camp on an adjacent empty hex.", + "decision.trade.name": "Trade", + "decision.trade.desc": "Exchange with a neighbor: both gain if it is reciprocated.", + "decision.aid.name": "Aid", + "decision.aid.desc": "Give food to a neighbor; raises its trust and your reputation.", + "decision.offer_pact.name": "Offer pact", + "decision.offer_pact.desc": "Offer an alliance; the neighbor decides next turn whether to accept.", + "decision.accept_pact.name": "Accept pact", + "decision.accept_pact.desc": "Accept the proposed alliance: harvest bonus and mutual defense.", + "decision.demand_tribute.name": "Demand tribute", + "decision.demand_tribute.desc": "Demand food from a weaker neighbor under threat.", + "decision.pay_tribute.name": "Pay tribute", + "decision.pay_tribute.desc": "Pay the demanded tribute in exchange for a truce.", + "decision.raid.name": "Raid", + "decision.raid.desc": "Raid a non-allied neighbor to steal reserves and crops.", + "decision.betray.name": "Betray", + "decision.betray.desc": "Break an alliance with a surprise attack on the ally.", + "decision.conquer.name": "Conquer", + "decision.conquer.desc": "Send an army to seize the neighbor's hex.", + "decision.unite.name": "Propose union", + "decision.unite.desc": "Propose to an ally or kin to merge both settlements.", + "decision.accept_union.name": "Accept union", + "decision.accept_union.desc": "Accept the proposed union (synoecism or walled union).", "chronicle.type.baldwin_trait": "Baldwin Assimilation", "chronicle.type.cultural_innovation": "Cultural Innovation", "chronicle.type.allopatric_speciation": "Allopatric Speciation", @@ -481,6 +449,10 @@ const STRINGS: Dictionary = { "chronicle.type.merge": "Settlement Unification", "chronicle.type.tribute": "Tribute Payment", "chronicle.type.siege": "Fortress Siege", + "chronicle.type.epidemic": "Urban Epidemic", + "chronicle.type.epidemic_spread": "Epidemic Contagion", + "chronicle.type.epidemic_recovered": "Epidemic Recovery", + "chronicle.type.famine": "Severe Famine", "filter_war": "War", "filter_raid": "Raid", "filter_pact": "Pacts", @@ -532,16 +504,31 @@ const STRINGS: Dictionary = { "chronicle_tribute_desc": "{victim} paid {loot} reserves as tribute to {raider} at ({hex}), avoiding casualties and sealing a truce.", "chronicle_siege_title": "Siege Repelled at ({hex})", "chronicle_siege_desc": "The fortifications of {defender} at ({hex}) withstood the siege by {attacker}, inflicting {casualties} casualties on the assailants.", + "chronicle_epidemic_title": "Urban Epidemic at ({hex})", + "chronicle_epidemic_desc": "Crowding and poor sanitation at ({hex}) triggered an epidemic in {deme}, causing {casualties} casualties.", + "chronicle_epidemic_spread_title": "Epidemic Contagion at ({hex})", + "chronicle_epidemic_spread_desc": "Contact with {source} spread the epidemic to {deme} at ({hex}), causing {casualties} casualties.", + "chronicle_epidemic_recovered_title": "Epidemic Subsided at ({hex})", + "chronicle_epidemic_recovered_desc": "{deme} overcame the epidemic outbreak at ({hex}) and acquired temporary immunity.", + "chronicle_famine_title": "Famine at ({hex})", + "chronicle_famine_desc": "Severe caloric deficit triggered famine in {deme} ({hex}), causing a loss of {casualties} inhabitants.", + "chronicle_walled_union_title": "Walled Union at ({hex})", + "chronicle_walled_union_desc": "{deme_a} and {deme_b} joined their perimeter walls into a multi-hex city ({hex}).", "strategy.strat-cooperation.name": "Cooperative Alliance", "strategy.strat-cooperation.desc": "Fosters social cohesion, communal habitat conservation, and resource buffering.", + "strategy.strat-cooperation.doctrine": "Cooperative Alliance: you value solidarity, keep your word and protect your neighbors; you remember who helped you.", "strategy.strat-expansion.name": "Expansionist Pioneers", "strategy.strat-expansion.desc": "Fast reproduction, high mobility, and thermal tolerance to colonize new territories.", + "strategy.strat-expansion.doctrine": "Expansionist Pioneers: you prize new land and large families; you settle every fertile valley.", "strategy.strat-trade.name": "Merchant League", "strategy.strat-trade.desc": "Drives inter-village barter, commercial symbiosis, and cultural diffusion.", + "strategy.strat-trade.doctrine": "Merchant League: you value profitable deals, trust and reputation; wealth flows to those who trade.", "strategy.strat-war.name": "Conquering Horde", "strategy.strat-war.desc": "Aggressive martial expansion and intensive extraction to dominate rival territories.", + "strategy.strat-war.doctrine": "Conquering Horde: you honor strength; conquest, plunder and tribute earn respect; weakness invites attack.", "strategy.strat-innovation.name": "Innovator Technocrats", "strategy.strat-innovation.desc": "High encephalization, thermal processing, and specialized extraction tools.", + "strategy.strat-innovation.doctrine": "Innovator Technocrats: you value knowledge and tools; you invest in invention and outthink your rivals.", "lab_tab_strat": "🛡️ Strategies", "lab_new_strat": "+ New Strategy", "lab_save_strat": "💾 Save Strategy", @@ -555,140 +542,31 @@ const STRINGS: Dictionary = { "hud_matchup_reset": "🔄", "hud_matchup_reset_tip": "Reset duel at Generation 0 with the selected strategies", "insp_strategy": "Strategy", - "legend_strategies": "Strategy Duel", - "strategies": { - "strat-cooperation": { - "name": "Cooperative Alliance", - "desc": "Fosters social cohesion, communal habitat conservation, and resource buffering." - }, - "strat-expansion": { - "name": "Expansionist Pioneers", - "desc": "Fast reproduction, high mobility, and thermal tolerance to colonize new territories." - }, - "strat-trade": { - "name": "Merchant League", - "desc": "Drives inter-village barter, commercial symbiosis, and cultural diffusion." - }, - "strat-war": { - "name": "Conquering Horde", - "desc": "Aggressive martial expansion and intensive extraction to dominate rival territories." - }, - "strat-innovation": { - "name": "Innovator Technocrats", - "desc": "High encephalization, thermal processing, and specialized extraction tools." - } - }, - "ui": { - "lab_tab_strat": "🛡️ Strategies", - "lab_new_strat": "+ New Strategy", - "lab_save_strat": "💾 Save Strategy", - "lab_strat_icon": "Emblem", - "lab_strat_color": "Color", - "lab_strat_traits": "Strategy Traits", - "lab_strat_vector": "Combined Biophysical Footprint", - "lab_use_strat_a": "Set as Strategy A", - "lab_use_strat_b": "Set as Strategy B", - "hud_matchup_vs": "⚔️", - "hud_matchup_reset": "🔄", - "hud_matchup_reset_tip": "Reset duel at Generation 0 with the selected strategies", - "insp_strategy": "Strategy", - "legend_strategies": "Strategy Duel", - "filter_war": "War", - "filter_raid": "Raid", - "filter_pact": "Pacts", - "filter_betrayal": "Betrayal", - "filter_fortify": "Defense", - "filter_merge": "Union", - "filter_tribute": "Tribute", - "filter_siege": "Siege", - "event_war": "War of Conquest", - "event_raid": "Plunder Raid", - "event_pact": "Formal Pact", - "event_betrayal": "Pact Betrayal", - "event_fortify": "Defensive Fortification", - "event_merge": "Settlement Unification", - "event_tribute": "Tribute Payment", - "event_siege": "Fortress Siege", - "inspector_pacts": "Pacts & Alliances", - "inspector_no_pacts": "No active pacts", - "inspector_pact_with": "Active pact with {partner}", - "inspector_defense_label": "Defense", - "inspector_defense_0": "Unfortified", - "inspector_defense_1": "Lv.1 Palisade (+45%)", - "inspector_defense_2": "Lv.2 Stone Wall (+95%)", - "inspector_defense_3": "Lv.3 Castle (+155%)", - "inspector_merged_city": "Unified City ({count} peoples)", - "inspector_vendetta_with": "Grudge / Vendetta against {partner}", - "legend_flow_war": "War / Invasion", - "legend_flow_raid": "Raid / Plunder", - "legend_flow_pact": "Pact / Alliance", - "legend_flow_betrayal": "Betrayal", - "legend_flow_tribute": "Tribute / Truce", - "legend_flow_merge": "City Unification", - "legend_flow_siege": "Active Siege", - "chronicle_war_victory_title": "Military Conquest at ({hex})", - "chronicle_war_victory_desc": "{attacker} defeated {defender} at hex ({hex}), inflicting {casualties} casualties and seizing {loot} energy reserves.", - "chronicle_war_repelled_title": "Invasion Repelled at ({hex})", - "chronicle_war_repelled_desc": "{defender} repelled the assault by {attacker} at ({hex}), inflicting {casualties} casualties on the attackers.", - "chronicle_raid_title": "Plunder Raid at ({hex})", - "chronicle_raid_desc": "{raider} raided {victim} at ({hex}), plundering {loot} caloric reserve units.", - "chronicle_pact_title": "Alliance Pact at ({hex})", - "chronicle_pact_desc": "{deme_a} and {deme_b} forged a formal pact of mutual cooperation and exchange at ({hex}).", - "chronicle_betrayal_title": "Pact Betrayal at ({hex})", - "chronicle_betrayal_desc": "{betrayer} broke their alliance and ambushed {victim} at ({hex}), plundering {loot} energy reserves.", - "chronicle_fortify_title": "Fortification Built at ({hex})", - "chronicle_fortify_desc": "{deme} invested surplus resources to build {tier_name} at ({hex}), reinforcing its territorial defense.", - "chronicle_merge_title": "Unification of Peoples at ({hex})", - "chronicle_merge_desc": "{deme_a} and {deme_b} united their populations and defenses into a single city at ({hex}) (pop. {pop}).", - "chronicle_tribute_title": "Peace Tribute at ({hex})", - "chronicle_tribute_desc": "{victim} paid {loot} reserves as tribute to {raider} at ({hex}), avoiding casualties and sealing a truce.", - "chronicle_siege_title": "Siege Repelled at ({hex})", - "chronicle_siege_desc": "The fortifications of {defender} at ({hex}) withstood the siege by {attacker}, inflicting {casualties} casualties on the assailants." - } + "legend_strategies": "Strategy Duel" } } -const TRANSLATIONS: Dictionary = STRINGS - static func resolve_lang(lang: String) -> String: return "en" if lang == "en" else "es" -static func t(lang: String, key: String = "es", params: Dictionary = {}) -> String: - var actual_lang := lang - var actual_key := key - if lang not in ["es", "en"] and key in ["es", "en"]: - actual_lang = key - actual_key = lang - var res := actual_key - var table: Dictionary = STRINGS[resolve_lang(actual_lang)] - if table.has(actual_key) and table[actual_key] is String: - res = str(table[actual_key]) - elif (table["ui"] as Dictionary).has(actual_key): - res = str((table["ui"] as Dictionary)[actual_key]) - else: - var es_table: Dictionary = STRINGS["es"] - if es_table.has(actual_key) and es_table[actual_key] is String: - res = str(es_table[actual_key]) - elif (es_table["ui"] as Dictionary).has(actual_key): - res = str((es_table["ui"] as Dictionary)[actual_key]) +static func t(lang: String, key: String, params: Dictionary = {}) -> String: + var table: Dictionary = STRINGS[resolve_lang(lang)] + var res := key + if table.has(key): + res = str(table[key]) + elif STRINGS["es"].has(key): + res = str(STRINGS["es"][key]) return res.format(params) if not params.is_empty() else res -static func ui(key: String, lang: String = "es", params: Dictionary = {}) -> String: - return t(lang, key, params) - - static func strategy_text(strategy_id: String, field: String, fallback: String = "", lang: String = "es") -> String: - var l := resolve_lang(lang) - var strategies: Dictionary = STRINGS[l]["strategies"] - if strategies.has(strategy_id): - var entry: Dictionary = strategies[strategy_id] - if entry.has(field): - return str(entry[field]) - if field == "description" and entry.has("desc"): - return str(entry["desc"]) + var key_suffix := "desc" if field == "description" else field + var key := "strategy.%s.%s" % [strategy_id, key_suffix] + var table: Dictionary = STRINGS[resolve_lang(lang)] + if table.has(key): + return str(table[key]) return fallback @@ -704,22 +582,10 @@ static func strategy_description(lang: String, strat_def: EvoTypes.StrategyDef) return strategy_text(strat_def.id, "desc", strat_def.description, lang) -static func trait_text(trait_id: String, field: String, fallback: String = "", lang: String = "es") -> String: - var f := "desc" if field == "description" else field - var key := "trait." + trait_id + "." + f - var table: Dictionary = STRINGS[resolve_lang(lang)] - if table.has(key): - return str(table[key]) - return fallback - - -static func decision_text(decision_id: String, field: String, fallback: String = "", lang: String = "es") -> String: - var f := "desc" if field == "description" else field - var key := "decision." + decision_id + "." + f - var table: Dictionary = STRINGS[resolve_lang(lang)] - if table.has(key): - return str(table[key]) - return fallback +static func strategy_doctrine(lang: String, strat_def: EvoTypes.StrategyDef) -> String: + if strat_def is Object and strat_def.get("origin") != null and str(strat_def.get("origin")) not in ["base", "catalog"]: + return str(strat_def.doctrine) + return strategy_text(strat_def.id, "doctrine", strat_def.doctrine, lang) static func localize_world_state(state: EvoTypes.WorldState, lang: String = "es") -> void: @@ -753,7 +619,6 @@ static func localize_world_state(state: EvoTypes.WorldState, lang: String = "es" "deme-rift-west": "Western Forest Hominids" if is_en else "Homínidos del Bosque Occidental", "deme-rift-east": "Eastern Savanna Hominids" if is_en else "Homínidos de la Sabana Oriental", "deme-darwin-founder": "Western Island Founders" if is_en else "Fundadores Insulares del Oeste", - "deme-darwin-west": "Western Island Founders" if is_en else "Fundadores Insulares del Oeste", "deme-darwin-rival": "Eastern Island Rivals" if is_en else "Rivales Insulares del Este", "deme-island-1": "Northern Quarry Clan" if is_en else "Clan de la Cantera Norte", "deme-island-2": "Southern Timber Clan" if is_en else "Clan Maderero del Sur" @@ -771,7 +636,7 @@ static func localize_world_state(state: EvoTypes.WorldState, lang: String = "es" if base_deme_names.has(did): d.name = str(base_deme_names[did]) elif base_lineage_names.has(d.lineage_id): - var root_id := EvoDemographyMigration._strip_migration_suffix(str(did)) + var root_id := EvoSettlementPhase.strip_migration_suffix(str(did)) if base_deme_names.has(root_id): if str(did).contains("-w"): d.name = "%s · %s (%s)" % [base_deme_names[root_id], "Warband" if is_en else "Horda", d.hex_id] @@ -831,29 +696,5 @@ static func decision_description(lang: String, dec_def: EvoTypes.DecisionDef) -> return dec_def.description -static func archetype_name(lang: String, archetype: String) -> String: - return t(lang, "archetype." + archetype) - - -static func status_name(lang: String, status: String) -> String: - return t(lang, "status." + status) - - -static func village_tier_name(lang: String, tier: int) -> String: - return t(lang, "tier." + str(clampi(tier, 1, 5))) - - static func preset_title(lang: String, preset_id: String) -> String: return t(lang, "preset." + preset_id + ".name") - - -static func preset_description(lang: String, preset_id: String) -> String: - return t(lang, "preset." + preset_id + ".desc") - - -static func preset_hypothesis(lang: String, preset_id: String) -> String: - return t(lang, "preset." + preset_id + ".hyp") - - -static func chronicle_type_name(lang: String, event_type: String) -> String: - return t(lang, "chronicle.type." + event_type) diff --git a/src/core/presets.gd b/src/core/presets.gd index 2c2e433..64a18ce 100644 --- a/src/core/presets.gd +++ b/src/core/presets.gd @@ -54,34 +54,36 @@ static func create_preset_world( state.strategy_a_id = strategy_a_id if state.strategies_catalog.has(strategy_a_id) else "strat-cooperation" state.strategy_b_id = strategy_b_id if state.strategies_catalog.has(strategy_b_id) else "strat-war" + state.rng_seed = int(absi(("%s|%s|%s" % [preset_id, state.strategy_a_id, state.strategy_b_id]).hash())) + 1 + state.rng.seed = state.rng_seed var is_en := lang == "en" if preset_id == "preset-darwin-archipelago": - _build_darwin_archipelago(state, lang, is_en) + _build_darwin_archipelago(state, is_en) elif preset_id == "preset-finite-island": - _build_finite_island(state, lang, is_en) + _build_finite_island(state, is_en) else: _build_rift_savanna(state, lang, is_en) + EvoRuralHinterland.claim_hinterlands(state) for did in state.demes.keys(): var d: EvoTypes.Deme = state.demes[did] - EvoCatalog.compute_strategy_baseline_probabilities(d, state.traits_catalog) state.previous_populations[did] = d.population - EvoDecisionModel.evaluate_all_demes(state, lang) + EvoDecisionModel.evaluate_all_demes(state, lang, true) return state -static func _build_darwin_archipelago(state: EvoTypes.WorldState, lang: String, is_en: bool) -> void: - state.map_radius = 5 +static func _build_darwin_archipelago(state: EvoTypes.WorldState, is_en: bool) -> void: + state.map_radius = 7 state.sea_level = 0.12 state.glacial_sea_level_cycle = true - var central_duel_ids := {"-1,0": true, "1,0": true} - var bridge_ids := {"0,-1": true, "0,1": true, "2,0": true} - var north_ids := {"0,-2": true, "0,-3": true} - var south_ids := {"0,2": true, "0,3": true} - var east_ids := {"3,0": true, "3,-1": true} + var central_duel_ids := {"-1,0": true, "1,0": true, "-2,0": true, "-1,-1": true, "2,-1": true, "1,1": true} + var bridge_ids := {"0,-1": true, "0,1": true, "2,0": true, "-2,1": true, "0,-4": true, "0,4": true, "4,-1": true} + var north_ids := {"0,-2": true, "0,-3": true, "1,-2": true, "-1,-2": true, "1,-3": true, "0,-5": true, "1,-4": true} + var south_ids := {"0,2": true, "0,3": true, "-1,2": true, "1,2": true, "-1,3": true, "0,5": true, "-1,4": true} + var east_ids := {"3,0": true, "3,-1": true, "4,0": true, "3,1": true, "4,-2": true, "5,-1": true} for coord in HexMath.generate_hex_spiral(state.map_radius): var cid := HexMath.make_id(coord.x, coord.y) @@ -119,7 +121,7 @@ static func _build_darwin_archipelago(state: EvoTypes.WorldState, lang: String, founder.energy_reserves = 165.0 founder.last_energy_delta = 12.0 founder.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(founder.id) - EvoCatalog.apply_strategy_to_deme(state, founder, state.strategy_a_id, lang) + EvoCatalog.apply_strategy_to_deme(state, founder, state.strategy_a_id) state.demes[founder.id] = founder (state.cells["-1,0"] as EvoTypes.HexCell).deme_ids.append(founder.id) @@ -142,13 +144,13 @@ static func _build_darwin_archipelago(state: EvoTypes.WorldState, lang: String, rival.energy_reserves = 165.0 rival.last_energy_delta = 10.0 rival.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(rival.id) - EvoCatalog.apply_strategy_to_deme(state, rival, state.strategy_b_id, lang) + EvoCatalog.apply_strategy_to_deme(state, rival, state.strategy_b_id) state.demes[rival.id] = rival (state.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(rival.id) -static func _build_finite_island(state: EvoTypes.WorldState, lang: String, is_en: bool) -> void: - state.map_radius = 4 +static func _build_finite_island(state: EvoTypes.WorldState, is_en: bool) -> void: + state.map_radius = 7 state.sea_level = 0.20 state.glacial_sea_level_cycle = false @@ -157,8 +159,10 @@ static func _build_finite_island(state: EvoTypes.WorldState, lang: String, is_en var dist := HexMath.hex_distance(0, 0, coord.x, coord.y) if dist <= 1: state.cells[cid] = _make_cell(coord.x, coord.y, "savanna", 0.30, 0.55, 0.42, 580.0, 600.0, 600.0, 0.08) - elif dist == 2: - state.cells[cid] = _make_cell(coord.x, coord.y, "arid_steppe", 0.45, 0.30, 0.48, 200.0, 250.0, 280.0, 0.08) + elif dist <= 3: + state.cells[cid] = _make_cell(coord.x, coord.y, "arid_steppe", 0.45, 0.30, 0.48, 220.0, 270.0, 300.0, 0.08) + elif dist == 4 and int(abs(coord.x - coord.y)) % 2 == 0: + state.cells[cid] = _make_cell(coord.x, coord.y, "coastal_shallows", 0.22, 0.65, 0.22, 180.0, 220.0, 260.0, 0.14) else: state.cells[cid] = _make_cell(coord.x, coord.y, "ocean", 0.15, 0.90, 0.05, 0.0, 20.0, 60.0, 0.05) @@ -202,7 +206,7 @@ static func _build_finite_island(state: EvoTypes.WorldState, lang: String, is_en deme.energy_reserves = 140.0 deme.last_energy_delta = 8.0 deme.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(deme.id) - EvoCatalog.apply_strategy_to_deme(state, deme, str(cfg["strategy_id"]), lang) + EvoCatalog.apply_strategy_to_deme(state, deme, str(cfg["strategy_id"])) state.demes[deme.id] = deme (state.cells[hex_id] as EvoTypes.HexCell).deme_ids.append(deme.id) @@ -210,14 +214,14 @@ static func _build_finite_island(state: EvoTypes.WorldState, lang: String, is_en static func _build_rift_savanna(state: EvoTypes.WorldState, lang: String, is_en: bool) -> void: state.preset_id = "preset-rift-savanna" state.world_name = EvoI18n.preset_title(lang, "preset-rift-savanna") - state.map_radius = 5 + state.map_radius = 7 state.sea_level = 0.12 state.glacial_sea_level_cycle = false for coord in HexMath.generate_hex_spiral(state.map_radius): var cid := HexMath.make_id(coord.x, coord.y) - if coord.x == 0 and coord.y == 0: - state.cells[cid] = _make_cell(0, 0, "savanna", 0.25, 0.45, 0.52, 240.0, 280.0, 320.0, 0.18) + if coord.x == 0 and (coord.y == 0 or abs(coord.y) == 3): + state.cells[cid] = _make_cell(coord.x, coord.y, "savanna", 0.25, 0.45, 0.52, 240.0, 280.0, 320.0, 0.18) elif coord.x == 0: state.cells[cid] = _make_cell(coord.x, coord.y, "high_mountain", -0.35, 0.25, 0.88, 70.0, 108.0, 120.0, 0.08) elif coord.x < 0: @@ -246,7 +250,7 @@ static func _build_rift_savanna(state: EvoTypes.WorldState, lang: String, is_en: west_deme.energy_reserves = 180.0 west_deme.last_energy_delta = 12.0 west_deme.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(west_deme.id) - EvoCatalog.apply_strategy_to_deme(state, west_deme, state.strategy_a_id, lang) + EvoCatalog.apply_strategy_to_deme(state, west_deme, state.strategy_a_id) state.demes[west_deme.id] = west_deme (state.cells["-2,1"] as EvoTypes.HexCell).deme_ids.append(west_deme.id) @@ -269,6 +273,6 @@ static func _build_rift_savanna(state: EvoTypes.WorldState, lang: String, is_en: east_deme.energy_reserves = 180.0 east_deme.last_energy_delta = 8.0 east_deme.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(east_deme.id) - EvoCatalog.apply_strategy_to_deme(state, east_deme, state.strategy_b_id, lang) + EvoCatalog.apply_strategy_to_deme(state, east_deme, state.strategy_b_id) state.demes[east_deme.id] = east_deme (state.cells["2,-1"] as EvoTypes.HexCell).deme_ids.append(east_deme.id) diff --git a/src/core/types.gd b/src/core/types.gd index 52810e8..93e8fd4 100644 --- a/src/core/types.gd +++ b/src/core/types.gd @@ -19,7 +19,10 @@ const ARCHETYPES: Array[String] = [ "exchange", "dispute", "innovate", - "custom" + "pact", + "raid", + "war", + "betrayal" ] const SPECIATION_PALETTE: Array[String] = [ @@ -33,6 +36,16 @@ const SPECIATION_PALETTE: Array[String] = [ "#00F5D4" ] +const EVENT_TYPES: Array[String] = [ + "pact", "betrayal", "raid", "tribute", "pillage", + "war", "war_repelled", "siege", "fortify", "walled_union", + "absorption", "colonize", "epidemic", "epidemic_spread", + "epidemic_recovered", "famine", "extinction", + "allopatric_speciation", "cultural_innovation", "baldwin_trait", "symbiosis" +] + +const STATS_HISTORY_LIMIT: int = 200 + const BIOME_CLIMATE_DEFAULTS: Dictionary = { "tropical_forest": {"temperature": 0.45, "humidity": 0.85, "min_alt": 0.28, "max_alt": 0.65, "regen": 0.28, "k_max": 600.0}, "savanna": {"temperature": 0.50, "humidity": 0.40, "min_alt": 0.28, "max_alt": 0.65, "regen": 0.18, "k_max": 420.0}, @@ -51,11 +64,6 @@ class BiophysicalVector extends RefCounted: var spatial_mobility: float = 0.0 var social_cohesion: float = 0.0 var transmission_threshold: float = 0.0 - var cognitive_plasticity: float: - get: - return transmission_threshold - set(value): - transmission_threshold = value func _init( p_cost: float = 0.0, @@ -152,6 +160,8 @@ class TraitDef extends RefCounted: return t +# Fixed catalog of action types a settlement can choose each turn ("forage", "raid", ...). +# `archetype` selects the village action sprite; `vector` feeds Baldwin assimilation. class DecisionDef extends RefCounted: var id: String = "" var name: String = "" @@ -159,18 +169,6 @@ class DecisionDef extends RefCounted: var origin: String = "catalog" var description: String = "" var vector: BiophysicalVector = BiophysicalVector.new() - var required_trait_ids: Array[String] = [] - - func duplicate_decision() -> DecisionDef: - var dec := DecisionDef.new() - dec.id = id - dec.name = name - dec.archetype = archetype - dec.origin = origin - dec.description = description - dec.vector = vector.duplicate_vec() - dec.required_trait_ids = required_trait_ids.duplicate() - return dec func to_dict() -> Dictionary: return { @@ -179,8 +177,7 @@ class DecisionDef extends RefCounted: "archetype": archetype, "origin": origin, "description": description, - "vector": vector.to_dict(), - "requiredTraitIds": required_trait_ids.duplicate() + "vector": vector.to_dict() } static func from_dict(d: Dictionary) -> DecisionDef: @@ -188,15 +185,51 @@ class DecisionDef extends RefCounted: dec.id = str(d["id"]) dec.name = str(d["name"]) dec.archetype = str(d["archetype"]) - dec.origin = str(d["origin"]) if d.has("origin") else "catalog" + dec.origin = str(d["origin"]) dec.description = str(d["description"]) dec.vector = BiophysicalVector.from_dict(d["vector"]) - dec.required_trait_ids = [] - for item in d["requiredTraitIds"]: - dec.required_trait_ids.append(str(item)) return dec +# Bilateral relationship between two settlements (keyed by `make_pair_key`). +class Relation extends RefCounted: + var pact_turns: int = 0 # > 0 means an active alliance pact + var trust: float = 0.0 # [-1, 1] + var feud_turns: int = 0 # > 0 means active hostility after raids, betrayals or wars + var truce_turns: int = 0 # > 0 after tribute was paid + var proposal_from: String = "" # deme id that made a pending proposal + var proposal_kind: String = "" # "pact" | "union" | "tribute" + var proposal_amount: float = 0.0 + var proposal_gen: int = -1 + var last_event: String = "" + + func to_dict() -> Dictionary: + return { + "pactTurns": pact_turns, + "trust": trust, + "feudTurns": feud_turns, + "truceTurns": truce_turns, + "proposalFrom": proposal_from, + "proposalKind": proposal_kind, + "proposalAmount": proposal_amount, + "proposalGen": proposal_gen, + "lastEvent": last_event + } + + static func from_dict(d: Dictionary) -> Relation: + var r := Relation.new() + r.pact_turns = int(d["pactTurns"]) + r.trust = float(d["trust"]) + r.feud_turns = int(d["feudTurns"]) + r.truce_turns = int(d["truceTurns"]) + r.proposal_from = str(d["proposalFrom"]) + r.proposal_kind = str(d["proposalKind"]) + r.proposal_amount = float(d["proposalAmount"]) + r.proposal_gen = int(d["proposalGen"]) + r.last_event = str(d["lastEvent"]) + return r + + class StrategyDef extends RefCounted: var id: String = "" var name: String = "" @@ -204,6 +237,7 @@ class StrategyDef extends RefCounted: var icon: String = "🛡️" var color: String = "#10B981" var origin: String = "base" # "base" | "user" + var doctrine: String = "" # cultural persona given to the decision model var biological_trait_ids: Array[String] = [] var cultural_trait_ids: Array[String] = [] @@ -215,6 +249,7 @@ class StrategyDef extends RefCounted: s.icon = icon s.color = color s.origin = origin + s.doctrine = doctrine s.biological_trait_ids = biological_trait_ids.duplicate() s.cultural_trait_ids = cultural_trait_ids.duplicate() return s @@ -227,8 +262,7 @@ class StrategyDef extends RefCounted: "icon": icon, "color": color, "origin": origin, - "biologicalTraitIds": biological_trait_ids.duplicate(), - "culturalTraitIds": cultural_trait_ids.duplicate(), + "doctrine": doctrine, "biological_trait_ids": biological_trait_ids.duplicate(), "cultural_trait_ids": cultural_trait_ids.duplicate() } @@ -241,13 +275,12 @@ class StrategyDef extends RefCounted: s.icon = str(data["icon"]) if data.has("icon") else "🛡️" s.color = str(data["color"]) if data.has("color") else "#10B981" s.origin = str(data["origin"]) if data.has("origin") else "base" + s.doctrine = str(data["doctrine"]) if data.has("doctrine") else "" s.biological_trait_ids = [] - var raw_bio: Array = data["biologicalTraitIds"] if data.has("biologicalTraitIds") else (data["biological_trait_ids"] if data.has("biological_trait_ids") else []) - for item in raw_bio: + for item in data["biological_trait_ids"]: s.biological_trait_ids.append(str(item)) s.cultural_trait_ids = [] - var raw_cul: Array = data["culturalTraitIds"] if data.has("culturalTraitIds") else (data["cultural_trait_ids"] if data.has("cultural_trait_ids") else []) - for item in raw_cul: + for item in data["cultural_trait_ids"]: s.cultural_trait_ids.append(str(item)) return s @@ -264,30 +297,11 @@ class HexCell extends RefCounted: var current_resources: float = 300.0 var carrying_capacity: float = 400.0 var carrying_capacity_max: float = 450.0 - var max_carrying_capacity: float: - get: - return carrying_capacity_max - set(value): - carrying_capacity_max = value var seasonal_regen_rate: float = 0.18 var deme_ids: Array[String] = [] - - func duplicate_cell() -> HexCell: - var c := HexCell.new() - c.id = id - c.q = q - c.r = r - c.s = s - c.biome = biome - c.temperature = temperature - c.humidity = humidity - c.altitude = altitude - c.current_resources = current_resources - c.carrying_capacity = carrying_capacity - c.carrying_capacity_max = carrying_capacity_max - c.seasonal_regen_rate = seasonal_regen_rate - c.deme_ids = deme_ids.duplicate() - return c + var land_use: String = "wild" + var owner_deme_id: String = "" + var has_ruins: bool = false func to_dict() -> Dictionary: return { @@ -303,7 +317,10 @@ class HexCell extends RefCounted: "carryingCapacity": carrying_capacity, "carryingCapacityMax": carrying_capacity_max, "seasonalRegenRate": seasonal_regen_rate, - "demeIds": deme_ids.duplicate() + "demeIds": deme_ids.duplicate(), + "landUse": land_use, + "ownerDemeId": owner_deme_id, + "hasRuins": has_ruins } static func from_dict(d: Dictionary) -> HexCell: @@ -323,6 +340,12 @@ class HexCell extends RefCounted: c.deme_ids = [] for item in d["demeIds"]: c.deme_ids.append(str(item)) + if d.has("landUse"): + c.land_use = str(d["landUse"]) + if d.has("ownerDemeId"): + c.owner_deme_id = str(d["ownerDemeId"]) + if d.has("hasRuins"): + c.has_ruins = bool(d["hasRuins"]) return c @@ -342,13 +365,23 @@ class Deme extends RefCounted: var innovation_counter: float = 0.0 var isolation_turns: int = 0 var decision_model_tag: String = EvoUserSettings.primary_model - var last_confidence: float = 0.85 - var last_decision_probabilities: Dictionary = {"dec-conservative-forage": 1.0} # String -> float - var last_dominant_decision_id: String = "dec-conservative-forage" - var last_justification: String = "" + var last_confidence: float = 0.0 + var chosen_action: Dictionary = {"key": "forage", "type": "forage", "target": ""} + var action_probabilities: Dictionary = {} # action key -> probability from the decision model + var action_forecasts: Dictionary = {} # action key -> forecast text shown to the decision model + var last_outcome: String = "" + var memory: Array[String] = [] + var reputation: float = 0.0 # [-1, 1], visible to every neighbor var defense_tier: int = 0 var merged_count: int = 0 var secondary_color: String = "" + var salubrity: float = 1.0 + var walled_union_id: String = "" + var infection_turns: int = 0 + var immunity_turns: int = 0 + var last_food_intake: float = 0.0 + var last_food_need: float = 0.0 + var last_population_delta: int = 0 func duplicate_deme() -> Deme: var d := Deme.new() @@ -368,12 +401,22 @@ class Deme extends RefCounted: d.isolation_turns = isolation_turns d.decision_model_tag = decision_model_tag d.last_confidence = last_confidence - d.last_decision_probabilities = last_decision_probabilities.duplicate() - d.last_dominant_decision_id = last_dominant_decision_id - d.last_justification = last_justification + d.chosen_action = chosen_action.duplicate() + d.action_probabilities = action_probabilities.duplicate() + d.action_forecasts = action_forecasts.duplicate() + d.last_outcome = last_outcome + d.memory = memory.duplicate() + d.reputation = reputation d.defense_tier = defense_tier d.merged_count = merged_count d.secondary_color = secondary_color + d.salubrity = salubrity + d.walled_union_id = walled_union_id + d.infection_turns = infection_turns + d.immunity_turns = immunity_turns + d.last_food_intake = last_food_intake + d.last_food_need = last_food_need + d.last_population_delta = last_population_delta return d func get_vital_status() -> String: @@ -388,20 +431,16 @@ class Deme extends RefCounted: return "stable" func get_village_tier() -> int: - if merged_count >= 1 and (population >= 220 or defense_tier >= 3): - return 5 - if population >= 400 or defense_tier >= 3: + if population >= 380 or (population >= 280 and (defense_tier >= 2 or merged_count >= 1 or walled_union_id != "")): return 4 - if population >= 150 or defense_tier == 2: + if population >= 240 or (population >= 170 and (defense_tier >= 1 or merged_count >= 1)): return 3 - if population >= 50 or defense_tier == 1: + if population >= 120 or defense_tier == 1: return 2 return 1 func get_village_roman() -> String: var tier := get_village_tier() - if tier == 5: - return "V" if tier == 4: return "IV" if tier == 3: @@ -415,7 +454,6 @@ class Deme extends RefCounted: "id": id, "name": name, "lineageId": lineage_id, - "strategyId": strategy_id, "strategy_id": strategy_id, "color": color, "hexId": hex_id, @@ -429,12 +467,22 @@ class Deme extends RefCounted: "isolationTurns": isolation_turns, "decisionModelTag": decision_model_tag, "lastConfidence": last_confidence, - "lastDecisionProbabilities": last_decision_probabilities.duplicate(), - "lastDominantDecisionId": last_dominant_decision_id, - "lastJustification": last_justification, + "chosenAction": chosen_action.duplicate(), + "actionProbabilities": action_probabilities.duplicate(), + "actionForecasts": action_forecasts.duplicate(), + "lastOutcome": last_outcome, + "memory": memory.duplicate(), + "reputation": reputation, "defenseTier": defense_tier, "mergedCount": merged_count, - "secondaryColor": secondary_color + "secondaryColor": secondary_color, + "salubrity": salubrity, + "walledUnionId": walled_union_id, + "infectionTurns": infection_turns, + "immunityTurns": immunity_turns, + "lastFoodIntake": last_food_intake, + "lastFoodNeed": last_food_need, + "lastPopulationDelta": last_population_delta } static func from_dict(dict_data: Dictionary) -> Deme: @@ -442,10 +490,7 @@ class Deme extends RefCounted: d.id = str(dict_data["id"]) d.name = str(dict_data["name"]) d.lineage_id = str(dict_data["lineageId"]) - if dict_data.has("strategyId"): - d.strategy_id = str(dict_data["strategyId"]) - elif dict_data.has("strategy_id"): - d.strategy_id = str(dict_data["strategy_id"]) + d.strategy_id = str(dict_data["strategy_id"]) d.color = str(dict_data["color"]) d.hex_id = str(dict_data["hexId"]) d.population = int(dict_data["population"]) @@ -463,23 +508,26 @@ class Deme extends RefCounted: d.baldwin_counters[str(k)] = int(raw_bc[k]) d.innovation_counter = float(dict_data["innovationCounter"]) d.isolation_turns = int(dict_data["isolationTurns"]) - d.decision_model_tag = str(dict_data["decisionModelTag"]) if dict_data.has("decisionModelTag") else EvoUserSettings.primary_model - d.last_confidence = float(dict_data["lastConfidence"]) if dict_data.has("lastConfidence") else 0.85 - d.last_dominant_decision_id = str(dict_data["lastDominantDecisionId"]) - d.last_decision_probabilities = {} - var raw_probs: Dictionary = dict_data["lastDecisionProbabilities"] - for k in raw_probs.keys(): - d.last_decision_probabilities[str(k)] = float(raw_probs[k]) - if d.last_decision_probabilities.is_empty(): - var fallback_dec_id := d.last_dominant_decision_id if d.last_dominant_decision_id != "" else "dec-conservative-forage" - d.last_decision_probabilities = {fallback_dec_id: 1.0} - d.last_justification = str(dict_data["lastJustification"]) - if dict_data.has("defenseTier"): - d.defense_tier = int(dict_data["defenseTier"]) - if dict_data.has("mergedCount"): - d.merged_count = int(dict_data["mergedCount"]) - if dict_data.has("secondaryColor"): - d.secondary_color = str(dict_data["secondaryColor"]) + d.decision_model_tag = str(dict_data["decisionModelTag"]) + d.last_confidence = float(dict_data["lastConfidence"]) + d.chosen_action = (dict_data["chosenAction"] as Dictionary).duplicate() + d.action_probabilities = (dict_data["actionProbabilities"] as Dictionary).duplicate() + d.action_forecasts = (dict_data["actionForecasts"] as Dictionary).duplicate() + d.last_outcome = str(dict_data["lastOutcome"]) + d.memory = [] + for item in dict_data["memory"]: + d.memory.append(str(item)) + d.reputation = float(dict_data["reputation"]) + d.defense_tier = int(dict_data["defenseTier"]) + d.merged_count = int(dict_data["mergedCount"]) + d.secondary_color = str(dict_data["secondaryColor"]) + d.salubrity = float(dict_data["salubrity"]) + d.walled_union_id = str(dict_data["walledUnionId"]) + d.infection_turns = int(dict_data["infectionTurns"]) + d.immunity_turns = int(dict_data["immunityTurns"]) + d.last_food_intake = float(dict_data["lastFoodIntake"]) + d.last_food_need = float(dict_data["lastFoodNeed"]) + d.last_population_delta = int(dict_data["lastPopulationDelta"]) return d @@ -497,31 +545,11 @@ class LineageNode extends RefCounted: var biological_trait_ids: Array[String] = [] var cultural_trait_ids: Array[String] = [] - func duplicate_node() -> LineageNode: - var n := LineageNode.new() - n.id = id - n.name = name - n.parent_id = parent_id - n.strategy_id = strategy_id - n.color = color - n.founded_generation = founded_generation - n.extinct_generation = extinct_generation - n.founder_hex_id = founder_hex_id - n.divergence_reason = divergence_reason - n.acquired_trait_ids = acquired_trait_ids.duplicate() - n.biological_trait_ids = biological_trait_ids.duplicate() - n.cultural_trait_ids = cultural_trait_ids.duplicate() - return n - - func duplicate_lineage() -> LineageNode: - return duplicate_node() - func to_dict() -> Dictionary: return { "id": id, "name": name, "parentId": parent_id, - "strategyId": strategy_id, "strategy_id": strategy_id, "color": color, "foundedGeneration": founded_generation, @@ -538,10 +566,7 @@ class LineageNode extends RefCounted: n.id = str(d["id"]) n.name = str(d["name"]) n.parent_id = str(d["parentId"]) if d.has("parentId") and d["parentId"] != null else "" - if d.has("strategyId"): - n.strategy_id = str(d["strategyId"]) - elif d.has("strategy_id"): - n.strategy_id = str(d["strategy_id"]) + n.strategy_id = str(d["strategy_id"]) n.color = str(d["color"]) n.founded_generation = int(d["foundedGeneration"]) n.extinct_generation = int(d["extinctGeneration"]) if d.has("extinctGeneration") and d["extinctGeneration"] != null else -1 @@ -565,45 +590,20 @@ class ChronicleEntry extends RefCounted: var id: String = "" var generation: int = 0 var event_type: String = "" # "baldwin_trait" | "cultural_innovation" | "allopatric_speciation" | "extinction" | "tragedy_of_commons" | "symbiosis" | "cultural_loss" | "war" | "raid" | "pact" | "betrayal" - var type: String: # "baldwin_trait" | "cultural_innovation" | "allopatric_speciation" | "extinction" | "tragedy_of_commons" | "symbiosis" | "cultural_loss" | "war" | "raid" | "pact" | "betrayal" - get: - return event_type - set(value): - event_type = value var title: String = "" var causal_explanation: String = "" - var description: String: - get: - return causal_explanation - set(value): - causal_explanation = value var hex_id: String = "0,0" var deme_id: String = "" var lineage_id: String = "" var trait_id: String = "" - func duplicate_entry() -> ChronicleEntry: - var e := ChronicleEntry.new() - e.id = id - e.generation = generation - e.event_type = event_type - e.title = title - e.causal_explanation = causal_explanation - e.hex_id = hex_id - e.deme_id = deme_id - e.lineage_id = lineage_id - e.trait_id = trait_id - return e - func to_dict() -> Dictionary: return { "id": id, "generation": generation, - "eventType": event_type, - "type": event_type, + "event_type": event_type, "title": title, - "causalExplanation": causal_explanation, - "description": causal_explanation, + "causal_explanation": causal_explanation, "hexId": hex_id, "demeId": deme_id, "lineageId": lineage_id, @@ -614,9 +614,9 @@ class ChronicleEntry extends RefCounted: var e := ChronicleEntry.new() e.id = str(d["id"]) e.generation = int(d["generation"]) - e.event_type = str(d["eventType"]) if d.has("eventType") else (str(d["type"]) if d.has("type") else "") + e.event_type = str(d["event_type"]) e.title = str(d["title"]) - e.causal_explanation = str(d["causalExplanation"]) if d.has("causalExplanation") else (str(d["description"]) if d.has("description") else "") + e.causal_explanation = str(d["causal_explanation"]) e.hex_id = str(d["hexId"]) e.deme_id = str(d["demeId"]) if d.has("demeId") else "" e.lineage_id = str(d["lineageId"]) if d.has("lineageId") else "" @@ -632,22 +632,11 @@ class HexEdgeFlow extends RefCounted: var from_deme_id: String = "" var to_deme_id: String = "" - func duplicate_flow() -> HexEdgeFlow: - var f := HexEdgeFlow.new() - f.from_hex_id = from_hex_id - f.to_hex_id = to_hex_id - f.flow_type = flow_type - f.intensity = intensity - f.from_deme_id = from_deme_id - f.to_deme_id = to_deme_id - return f - func to_dict() -> Dictionary: return { "fromHexId": from_hex_id, "toHexId": to_hex_id, - "type": flow_type, - "flowType": flow_type, + "flow_type": flow_type, "intensity": intensity, "fromDemeId": from_deme_id, "toDemeId": to_deme_id @@ -657,7 +646,7 @@ class HexEdgeFlow extends RefCounted: var f := HexEdgeFlow.new() f.from_hex_id = str(d["fromHexId"]) f.to_hex_id = str(d["toHexId"]) - f.flow_type = str(d["type"]) if d.has("type") else (str(d["flowType"]) if d.has("flowType") else "migration") + f.flow_type = str(d["flow_type"]) f.intensity = float(d["intensity"]) f.from_deme_id = str(d["fromDemeId"]) if d.has("fromDemeId") else "" f.to_deme_id = str(d["toDemeId"]) if d.has("toDemeId") else "" @@ -685,14 +674,11 @@ class WorldState extends RefCounted: var strategy_a_id: String = "strat-cooperation" var strategy_b_id: String = "strat-war" var lineage_tree: Dictionary = {} # String (lineage_id) -> LineageNode - var lineages: Dictionary: - get: - return lineage_tree - set(value): - lineage_tree = value var chronicle: Array[ChronicleEntry] = [] var active_flows: Array[HexEdgeFlow] = [] - var active_pacts: Dictionary = {} # pair_key ("idA__idB" sorted) -> int (consecutive turns of mutual cooperation/exchange; >= 2 means formal active pact) + var relations: Dictionary = {} # pair_key ("idA__idB" sorted) -> Relation + var rng_seed: int = 1 + var rng := RandomNumberGenerator.new() var recent_extinction_hexes: Array[String] = [] # hex_ids that had real extinction this turn var disused_connections: Array[Dictionary] = [] # previous turn roads/bridges var quiet_turns: int = 0 @@ -700,48 +686,8 @@ class WorldState extends RefCounted: var macro_outcome_type: String = "" # "" | "total_extinction" | "single_survivor" | "ecological_equilibrium" var macro_outcome_title: String = "" var macro_outcome_summary: String = "" - - func duplicate_state() -> WorldState: - var s := WorldState.new() - s.preset_id = preset_id - s.world_name = world_name - s.generation = generation - s.season = season - s.map_radius = map_radius - s.sea_level = sea_level - s.glacial_sea_level_cycle = glacial_sea_level_cycle - s.selective_pressure_points = selective_pressure_points - s.selective_pressure_regen_per_turn = selective_pressure_regen_per_turn - s.unlimited_pressure_mode = unlimited_pressure_mode - s.free_sandbox_mode = free_sandbox_mode - s.auto_pause_on_milestone = auto_pause_on_milestone - s.strategy_a_id = strategy_a_id - s.strategy_b_id = strategy_b_id - for k in cells.keys(): - s.cells[k] = (cells[k] as HexCell).duplicate_cell() - for k in demes.keys(): - s.demes[k] = (demes[k] as Deme).duplicate_deme() - for k in traits_catalog.keys(): - s.traits_catalog[k] = (traits_catalog[k] as TraitDef).duplicate_trait() - for k in decisions_catalog.keys(): - s.decisions_catalog[k] = (decisions_catalog[k] as DecisionDef).duplicate_decision() - for k in strategies_catalog.keys(): - s.strategies_catalog[k] = (strategies_catalog[k] as StrategyDef).duplicate_strategy() - for k in lineage_tree.keys(): - s.lineage_tree[k] = (lineage_tree[k] as LineageNode).duplicate_node() - for entry in chronicle: - s.chronicle.append(entry.duplicate_entry()) - for fl in active_flows: - s.active_flows.append(fl.duplicate_flow()) - s.active_pacts = active_pacts.duplicate() - s.recent_extinction_hexes = recent_extinction_hexes.duplicate() - s.disused_connections = disused_connections.duplicate(true) - s.quiet_turns = quiet_turns - s.previous_populations = previous_populations.duplicate() - s.macro_outcome_type = macro_outcome_type - s.macro_outcome_title = macro_outcome_title - s.macro_outcome_summary = macro_outcome_summary - return s + var turn_events: Array[Dictionary] = [] + var stats_history: Array[Dictionary] = [] func to_dict() -> Dictionary: var cells_dict := {} @@ -768,6 +714,9 @@ class WorldState extends RefCounted: var flows_arr := [] for fl in active_flows: flows_arr.append(fl.to_dict()) + var relations_dict := {} + for k in relations.keys(): + relations_dict[k] = (relations[k] as Relation).to_dict() return { "presetId": preset_id, "worldName": world_name, @@ -781,28 +730,27 @@ class WorldState extends RefCounted: "unlimitedPressureMode": unlimited_pressure_mode, "freeSandboxMode": free_sandbox_mode, "autoPauseOnMilestone": auto_pause_on_milestone, - "strategyAId": strategy_a_id, - "strategyBId": strategy_b_id, "strategy_a_id": strategy_a_id, "strategy_b_id": strategy_b_id, "cells": cells_dict, "demes": demes_dict, "traitsCatalog": traits_dict, "decisionsCatalog": decisions_dict, - "strategiesCatalog": strategies_dict, "strategies_catalog": strategies_dict, "lineageTree": lineage_dict, "chronicle": chron_arr, "activeFlows": flows_arr, - "activePacts": active_pacts.duplicate(), - "active_pacts": active_pacts.duplicate(), + "relations": relations_dict, + "rngSeed": rng_seed, + "rngState": rng.state, "recentExtinctionHexes": recent_extinction_hexes.duplicate(), "disusedConnections": disused_connections.duplicate(true), "quietTurns": quiet_turns, "previousPopulations": previous_populations.duplicate(), "macroOutcomeType": macro_outcome_type, "macroOutcomeTitle": macro_outcome_title, - "macroOutcomeSummary": macro_outcome_summary + "macroOutcomeSummary": macro_outcome_summary, + "statsHistory": stats_history.duplicate(true) } static func from_dict(d: Dictionary) -> WorldState: @@ -819,23 +767,29 @@ class WorldState extends RefCounted: s.unlimited_pressure_mode = bool(d["unlimitedPressureMode"]) s.free_sandbox_mode = bool(d["freeSandboxMode"]) s.auto_pause_on_milestone = bool(d["autoPauseOnMilestone"]) - if d.has("strategyAId"): - s.strategy_a_id = str(d["strategyAId"]) - elif d.has("strategy_a_id"): - s.strategy_a_id = str(d["strategy_a_id"]) - if d.has("strategyBId"): - s.strategy_b_id = str(d["strategyBId"]) - elif d.has("strategy_b_id"): - s.strategy_b_id = str(d["strategy_b_id"]) + s.strategy_a_id = str(d["strategy_a_id"]) + s.strategy_b_id = str(d["strategy_b_id"]) for k in d["cells"].keys(): s.cells[str(k)] = HexCell.from_dict(d["cells"][k]) for k in d["demes"].keys(): s.demes[str(k)] = Deme.from_dict(d["demes"][k]) + for cid in s.cells.keys(): + (s.cells[cid] as HexCell).deme_ids.clear() + var invalid_deme_ids: Array[String] = [] + for did in s.demes.keys(): + var dm: Deme = s.demes[did] + if not s.cells.has(dm.hex_id): + invalid_deme_ids.append(did) + continue + var host_cell: HexCell = s.cells[dm.hex_id] + host_cell.deme_ids.append(dm.id) + for did in invalid_deme_ids: + s.demes.erase(did) for k in d["traitsCatalog"].keys(): s.traits_catalog[str(k)] = TraitDef.from_dict(d["traitsCatalog"][k]) for k in d["decisionsCatalog"].keys(): s.decisions_catalog[str(k)] = DecisionDef.from_dict(d["decisionsCatalog"][k]) - var raw_strats: Dictionary = d["strategiesCatalog"] if d.has("strategiesCatalog") else (d["strategies_catalog"] if d.has("strategies_catalog") else {}) + var raw_strats: Dictionary = d["strategies_catalog"] for k in raw_strats.keys(): s.strategies_catalog[str(k)] = StrategyDef.from_dict(raw_strats[k]) for k in d["lineageTree"].keys(): @@ -844,10 +798,13 @@ class WorldState extends RefCounted: s.chronicle.append(ChronicleEntry.from_dict(item)) for item in d["activeFlows"]: s.active_flows.append(HexEdgeFlow.from_dict(item)) - s.active_pacts = {} - var raw_pacts: Dictionary = d["activePacts"] if d.has("activePacts") else (d["active_pacts"] if d.has("active_pacts") else {}) - for k in raw_pacts.keys(): - s.active_pacts[str(k)] = int(raw_pacts[k]) + s.relations = {} + var raw_relations: Dictionary = d["relations"] + for k in raw_relations.keys(): + s.relations[str(k)] = Relation.from_dict(raw_relations[k]) + s.rng_seed = int(d["rngSeed"]) + s.rng.seed = s.rng_seed + s.rng.state = int(d["rngState"]) s.recent_extinction_hexes = [] if d.has("recentExtinctionHexes"): for item in d["recentExtinctionHexes"]: @@ -869,22 +826,183 @@ class WorldState extends RefCounted: s.macro_outcome_type = str(d["macroOutcomeType"]) if d.has("macroOutcomeType") else "" s.macro_outcome_title = str(d["macroOutcomeTitle"]) if d.has("macroOutcomeTitle") else "" s.macro_outcome_summary = str(d["macroOutcomeSummary"]) if d.has("macroOutcomeSummary") else "" + s.stats_history = [] + for item in d["statsHistory"]: + s.stats_history.append((item as Dictionary).duplicate(true)) return s +static func push_event( + state: WorldState, + type: String, + from_hex: String, + to_hex: String, + from_deme: String, + to_deme: String = "", + magnitude: float = 0.0, + casualties: int = 0 +) -> void: + var actor_color := "" + if state.demes.has(from_deme): + var d: Deme = state.demes[from_deme] + actor_color = (state.strategies_catalog[d.strategy_id] as StrategyDef).color if state.strategies_catalog.has(d.strategy_id) else d.color + state.turn_events.append({ + "type": type, + "from_hex": from_hex, + "to_hex": to_hex if to_hex != "" else from_hex, + "from_deme": from_deme, + "to_deme": to_deme, + "actor_color": actor_color, + "magnitude": magnitude, + "casualties": casualties + }) + + +static func _is_side_b_deme(state: WorldState, d: Deme) -> bool: + if state.strategy_a_id != state.strategy_b_id: + if d.strategy_id == state.strategy_b_id: + return true + if d.strategy_id == state.strategy_a_id: + return false + return d.id.begins_with("deme-rift-east") or d.id.begins_with("deme-darwin-rival") or d.id.begins_with("deme-island-2") + + +static func compute_world_stats(state: WorldState) -> Dictionary: + var side_a := { + "strategy_id": state.strategy_a_id, + "villages": 0, + "population": 0, + "pop_delta": 0, + "reserves": 0.0, + "food_intake": 0.0, + "food_need": 0.0, + "food_ratio": 1.0, + "salubrity_avg": 1.0, + "infected": 0, + "starving": 0, + "rural_hexes": 0 + } + var side_b := { + "strategy_id": state.strategy_b_id, + "villages": 0, + "population": 0, + "pop_delta": 0, + "reserves": 0.0, + "food_intake": 0.0, + "food_need": 0.0, + "food_ratio": 1.0, + "salubrity_avg": 1.0, + "infected": 0, + "starving": 0, + "rural_hexes": 0 + } + var sal_sum_a: float = 0.0 + var sal_sum_b: float = 0.0 + var union_ids: Dictionary = {} + + for d: Deme in state.demes.values(): + if d.population <= 0: + continue + if d.walled_union_id != "": + union_ids[d.walled_union_id] = true + var is_b := _is_side_b_deme(state, d) + var target: Dictionary = side_b if is_b else side_a + target["villages"] = int(target["villages"]) + 1 + target["population"] = int(target["population"]) + d.population + target["pop_delta"] = int(target["pop_delta"]) + d.last_population_delta + target["reserves"] = float(target["reserves"]) + d.energy_reserves + target["food_intake"] = float(target["food_intake"]) + d.last_food_intake + target["food_need"] = float(target["food_need"]) + d.last_food_need + if d.infection_turns > 0: + target["infected"] = int(target["infected"]) + 1 + if d.get_vital_status() == "starving": + target["starving"] = int(target["starving"]) + 1 + if is_b: + sal_sum_b += d.salubrity + else: + sal_sum_a += d.salubrity + + for c: HexCell in state.cells.values(): + if c.owner_deme_id != "" and (c.land_use == "crops" or c.land_use == "pasture") and state.demes.has(c.owner_deme_id): + var owner: Deme = state.demes[c.owner_deme_id] + var target: Dictionary = side_b if _is_side_b_deme(state, owner) else side_a + target["rural_hexes"] = int(target["rural_hexes"]) + 1 + + var v_a := int(side_a["villages"]) + var v_b := int(side_b["villages"]) + var need_a := float(side_a["food_need"]) + var need_b := float(side_b["food_need"]) + side_a["food_ratio"] = clampf(float(side_a["food_intake"]) / maxf(1.0, need_a), 0.0, 2.0) if need_a > 0.0 else (1.0 if v_a > 0 else 0.0) + side_b["food_ratio"] = clampf(float(side_b["food_intake"]) / maxf(1.0, need_b), 0.0, 2.0) if need_b > 0.0 else (1.0 if v_b > 0 else 0.0) + side_a["salubrity_avg"] = (sal_sum_a / float(v_a)) if v_a > 0 else 0.0 + side_b["salubrity_avg"] = (sal_sum_b / float(v_b)) if v_b > 0 else 0.0 + + var pacts_count: int = 0 + var vendettas_count: int = 0 + for pk in state.relations.keys(): + var rel: Relation = state.relations[pk] + if rel.pact_turns > 0: + pacts_count += 1 + elif rel.feud_turns > 0: + vendettas_count += 1 + + var wars_count: int = 0 + for ev in state.turn_events: + if str(ev["type"]) in ["war", "war_repelled", "siege", "raid", "betrayal"]: + wars_count += 1 + + return { + "generation": state.generation, + "a": side_a, + "b": side_b, + "pacts": pacts_count, + "vendettas": vendettas_count, + "unions": union_ids.size(), + "wars": wars_count + } + + +static func record_turn_stats(state: WorldState) -> void: + var snap := compute_world_stats(state) + if not state.stats_history.is_empty() and int(state.stats_history.back()["generation"]) == state.generation: + state.stats_history[state.stats_history.size() - 1] = snap + else: + state.stats_history.append(snap) + while state.stats_history.size() > STATS_HISTORY_LIMIT: + state.stats_history.pop_front() + + static func make_pair_key(id_a: String, id_b: String) -> String: return (id_a + "__" + id_b) if id_a < id_b else (id_b + "__" + id_a) static func is_pact_active(state: WorldState, id_a: String, id_b: String) -> bool: - if state.active_pacts.is_empty(): - return false - var key := (id_a + "__" + id_b) if id_a < id_b else (id_b + "__" + id_a) - return int(state.active_pacts.get(key, 0)) >= 2 + var key := make_pair_key(id_a, id_b) + return state.relations.has(key) and (state.relations[key] as Relation).pact_turns > 0 static func is_vendetta_active(state: WorldState, id_a: String, id_b: String) -> bool: - if state.active_pacts.is_empty(): - return false - var key := (id_a + "__" + id_b) if id_a < id_b else (id_b + "__" + id_a) - return int(state.active_pacts.get(key, 0)) < 0 + var key := make_pair_key(id_a, id_b) + return state.relations.has(key) and (state.relations[key] as Relation).feud_turns > 0 + + +static func get_relation(state: WorldState, id_a: String, id_b: String) -> Relation: + var key := make_pair_key(id_a, id_b) + if not state.relations.has(key): + state.relations[key] = Relation.new() + return state.relations[key] + + +static func pair_partner(pair_key: String, deme_id: String) -> String: + var parts := pair_key.split("__") + if parts[0] == deme_id: + return parts[1] + if parts[1] == deme_id: + return parts[0] + return "" + + +static func erase_deme_relations(state: WorldState, deme_id: String) -> void: + for pk in state.relations.keys(): + if pair_partner(str(pk), deme_id) != "": + state.relations.erase(pk) diff --git a/src/core/user_settings.gd b/src/core/user_settings.gd index 74e3854..38c4654 100644 --- a/src/core/user_settings.gd +++ b/src/core/user_settings.gd @@ -3,7 +3,7 @@ extends RefCounted const DEFAULT_PRIMARY_MODEL: String = "d3:4b" const DEFAULT_SECONDARY_MODEL: String = "tev1:4b" -const DEFAULT_CONTEXT_LENGTH: int = 8192 +const DEFAULT_CONTEXT_LENGTH: int = 16384 static var primary_model: String = DEFAULT_PRIMARY_MODEL static var secondary_model: String = DEFAULT_SECONDARY_MODEL @@ -80,15 +80,9 @@ static func save_user_catalog(state: EvoTypes.WorldState) -> void: var tr: EvoTypes.TraitDef = state.traits_catalog[tid] if tr.origin in ["user", "custom"]: traits_arr.append(tr.to_dict()) - var decisions_arr: Array = [] - for did in state.decisions_catalog.keys(): - var dec: EvoTypes.DecisionDef = state.decisions_catalog[did] - if dec.origin in ["user", "custom"]: - decisions_arr.append(dec.to_dict()) var payload := { "strategies": strategies_arr, - "traits": traits_arr, - "decisions": decisions_arr + "traits": traits_arr } file.store_string(JSON.stringify(payload, "\t") + "\n") file.close() @@ -113,11 +107,6 @@ static func load_user_catalog_into(state: EvoTypes.WorldState) -> void: if item is Dictionary: var tr := EvoTypes.TraitDef.from_dict(item as Dictionary) state.traits_catalog[tr.id] = tr - if data.has("decisions") and data["decisions"] is Array: - for item in data["decisions"]: - if item is Dictionary: - var dec := EvoTypes.DecisionDef.from_dict(item as Dictionary) - state.decisions_catalog[dec.id] = dec if data.has("strategies") and data["strategies"] is Array: for item in data["strategies"]: if item is Dictionary: diff --git a/src/engine/agency/action_menu.gd b/src/engine/agency/action_menu.gd new file mode 100644 index 0000000..ea5f832 --- /dev/null +++ b/src/engine/agency/action_menu.gd @@ -0,0 +1,113 @@ +class_name EvoActionMenu +extends RefCounted + +## Feasible, targeted actions for a settlement this turn. + +const MAX_NEIGHBORS := 3 +const MAX_SETTLE_OPTIONS := 2 +const MAX_OPTIONS := 14 + + +static func option(action_type: String, target: String = "") -> Dictionary: + return { + "key": action_type if target == "" else "%s:%s" % [action_type, target], + "type": action_type, + "target": target + } + + +static func parse_key(key: String) -> Dictionary: + var sep := key.find(":") + return option(key, "") if sep < 0 else option(key.substr(0, sep), key.substr(sep + 1)) + + +static func is_passable(cell: EvoTypes.HexCell) -> bool: + return cell.biome not in ["ocean", "high_mountain"] and cell.altitude <= 0.85 + + +static func neighbor_demes(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Array[EvoTypes.Deme]: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var result: Array[EvoTypes.Deme] = [] + for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if is_passable(ncell): + for oid in ncell.deme_ids: + result.append(state.demes[oid]) + return result + + +static func settle_targets(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Array[EvoTypes.HexCell]: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var result: Array[EvoTypes.HexCell] = [] + for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if is_passable(ncell) and ncell.deme_ids.is_empty() and (ncell.owner_deme_id == "" or ncell.owner_deme_id == deme.id): + result.append(ncell) + result.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: + return a.current_resources > b.current_resources + ) + return result + + +static func _salience(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var key := EvoTypes.make_pair_key(deme.id, other.id) + var score := float(other.population) + if state.relations.has(key): + var rel: EvoTypes.Relation = state.relations[key] + if rel.proposal_from == other.id: + score += 10000.0 + if rel.pact_turns > 0 or rel.feud_turns > 0: + score += 1000.0 + if other.lineage_id != deme.lineage_id: + score += 500.0 + return score + + +static func build(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Array[Dictionary]: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var responses: Array[Dictionary] = [] + var options: Array[Dictionary] = [option("forage"), option("intensify")] + if EvoPayoffs.can_fortify(deme, cell): + options.append(option("fortify")) + if EvoCatalog.deme_has_cultural_capacity(deme, state.traits_catalog): + options.append(option("innovate")) + if deme.population >= EvoPayoffs.SETTLE_MIN_POP and deme.energy_reserves >= 45.0: + for target: EvoTypes.HexCell in settle_targets(deme, state).slice(0, MAX_SETTLE_OPTIONS): + options.append(option("settle", target.id)) + + var neighbors := neighbor_demes(deme, state) + neighbors.sort_custom(func(a: EvoTypes.Deme, b: EvoTypes.Deme) -> bool: + return _salience(deme, a, state) > _salience(deme, b, state) + ) + for other: EvoTypes.Deme in neighbors.slice(0, MAX_NEIGHBORS): + var key := EvoTypes.make_pair_key(deme.id, other.id) + var rel: EvoTypes.Relation = state.relations[key] if state.relations.has(key) else EvoTypes.Relation.new() + var kin: bool = other.lineage_id == deme.lineage_id + if EvoRelations.has_open_proposal(state, other.id, deme.id, "pact"): + responses.append(option("accept_pact", other.id)) + if EvoRelations.has_open_proposal(state, other.id, deme.id, "union"): + responses.append(option("accept_union", other.id)) + if EvoRelations.has_open_proposal(state, other.id, deme.id, "tribute"): + responses.append(option("pay_tribute", other.id)) + if rel.feud_turns == 0: + options.append(option("trade", other.id)) + if deme.energy_reserves >= 40.0 and other.energy_reserves < float(other.population) * 0.9: + options.append(option("aid", other.id)) + if rel.pact_turns >= 3 and deme.get_village_tier() >= 2 and other.get_village_tier() >= 2 and deme.walled_union_id == "" and other.walled_union_id == "": + options.append(option("unite", other.id)) + if rel.pact_turns > 0: + var hungry_or_martial := deme.energy_reserves < EvoPayoffs.food_need(deme, cell, state) * 1.35 or deme.cultural_trait_ids.has("cul-warrior-expansion") + if not kin or (rel.pact_turns >= 2 and other.energy_reserves >= 65.0 and hungry_or_martial): + options.append(option("betray", other.id)) + continue + if rel.feud_turns == 0 and rel.proposal_from != deme.id and (kin or rel.trust >= 0.12): + options.append(option("offer_pact", other.id)) + if kin or rel.truce_turns > 0: + continue + if other.defense_tier < 3: + options.append(option("raid", other.id)) + options.append(option("conquer", other.id)) + if rel.proposal_from == "" and other.energy_reserves >= 20.0 and float(EvoPayoffs.raid_odds(deme, other, state, false)["p_win"]) >= 0.35: + options.append(option("demand_tribute", other.id)) + + return (responses + options).slice(0, MAX_OPTIONS) diff --git a/src/engine/agency/action_menu.gd.uid b/src/engine/agency/action_menu.gd.uid new file mode 100644 index 0000000..8d73586 --- /dev/null +++ b/src/engine/agency/action_menu.gd.uid @@ -0,0 +1 @@ +uid://bilew2pi56w0d diff --git a/src/engine/agency/action_policy.gd b/src/engine/agency/action_policy.gd new file mode 100644 index 0000000..9d4a9ca --- /dev/null +++ b/src/engine/agency/action_policy.gd @@ -0,0 +1,70 @@ +class_name EvoActionPolicy +extends RefCounted + +const INSTRUCTIONS := "You lead this settlement. Choose the action that best grows your lineage's total population and territory over the next 10 turns, weighing the expected gains, risks, and how neighbors will react." +const TEMPERATURE := 0.8 + + +static func prepare_deme_menu(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Dictionary: + var menu := EvoActionMenu.build(deme, state) + var forecasts: Dictionary = {} + var probs: Dictionary = {} + var uniform_p := snappedf(1.0 / float(maxi(1, menu.size())), 0.0001) + for opt in menu: + var key := str(opt["key"]) + forecasts[key] = EvoOutcomeForecast.describe(deme, opt, state) + probs[key] = uniform_p + deme.action_forecasts = forecasts + deme.action_probabilities = probs + deme.chosen_action = menu[0] + return { + "deme_id": deme.id, + "preferred_tag": deme.decision_model_tag, + "briefing": EvoSituationReport.build(deme, state), + "instructions": INSTRUCTIONS, + "criteria": forecasts.duplicate() + } + + +static func sample_action( + deme: EvoTypes.Deme, + probabilities: Dictionary, + confidence: float, + rng: RandomNumberGenerator +) -> void: + var keys: Array[String] = [] + for k in deme.action_forecasts.keys(): + keys.append(str(k)) + if keys.is_empty(): + return + + var sharpened: Dictionary = {} + var total := 0.0 + for k in keys: + var raw_p := maxf(0.0, float(probabilities[k])) if probabilities.has(k) else 0.0 + var w := pow(raw_p, 1.0 / TEMPERATURE) if raw_p > 0.0 else 0.0 + sharpened[k] = w + total += w + + var norm: Dictionary = {} + if total <= 0.0: + var u := snappedf(1.0 / float(keys.size()), 0.0001) + for k in keys: + norm[k] = u + else: + for k in keys: + norm[k] = snappedf(float(sharpened[k]) / total, 0.0001) + + deme.action_probabilities = norm + deme.last_confidence = confidence + + var roll := rng.randf() + var accum := 0.0 + var chosen_key := keys[0] + for k in keys: + accum += float(norm[k]) + if roll <= accum: + chosen_key = k + break + + deme.chosen_action = EvoActionMenu.parse_key(chosen_key) diff --git a/src/engine/agency/action_policy.gd.uid b/src/engine/agency/action_policy.gd.uid new file mode 100644 index 0000000..79da3c9 --- /dev/null +++ b/src/engine/agency/action_policy.gd.uid @@ -0,0 +1 @@ +uid://blwhsba367fmn diff --git a/src/engine/agency/outcome_forecast.gd b/src/engine/agency/outcome_forecast.gd new file mode 100644 index 0000000..d088cd7 --- /dev/null +++ b/src/engine/agency/outcome_forecast.gd @@ -0,0 +1,124 @@ +class_name EvoOutcomeForecast +extends RefCounted + +## English, quantified expected outcome of each menu option. These texts are the +## decision criteria scored by the model, so they are the explicit reward signal. + +const WALL_NAMES: Array[String] = ["no walls", "a palisade", "stone walls", "a castle"] + + +static func _pct(p: float) -> String: + return "%d%%" % int(roundf(p * 100.0)) + + +static func _food(value: float) -> String: + return str(int(roundf(value))) + + +static func union_kind(a: EvoTypes.Deme, b: EvoTypes.Deme) -> String: + var ta := a.get_village_tier() + var tb := b.get_village_tier() + return "walled_union" if ta == tb and ta >= 2 else "absorption" + + +static func describe(deme: EvoTypes.Deme, opt: Dictionary, state: EvoTypes.WorldState) -> String: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var action_type := str(opt["type"]) + var target := str(opt["target"]) + var other: EvoTypes.Deme = state.demes[target] if state.demes.has(target) else null + var need := EvoPayoffs.food_need(deme, cell, state) + var base_income := EvoPayoffs.expected_income(deme, state, "none") + match action_type: + "forage": + var income := EvoPayoffs.expected_income(deme, state, "forage") + return "Forage carefully: harvest %s food this turn (need %s), land stays healthy." % [_food(income), _food(need)] + "intensify": + var income := EvoPayoffs.expected_income(deme, state, "intensify") + var extra := income - base_income + var k_pct := int(roundf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max) * 100.0)) + return "Overexploit the land: harvest %s food (+%s extra now, need %s), but land capacity drops 7%% permanently (now %d%% of max)." % [_food(income), _food(extra), _food(need), k_pct] + "fortify": + var tier := deme.defense_tier + return "Build %s (harvest %s food, spend %s food): defenders fight +%s stronger, raiders steal %s less, upkeep %s food/turn." % [ + WALL_NAMES[tier + 1], + _food(base_income), + _food(EvoPayoffs.FORTIFY_COST_RESERVES[tier]), + _pct(EvoPayoffs.WALL_DEFENSE[tier + 1] - EvoPayoffs.WALL_DEFENSE[tier]), + _pct(EvoPayoffs.WALL_LOOT_SHIELD[tier + 1]), + _food(EvoPayoffs.WALL_UPKEEP[tier + 1]) + ] + "innovate": + var step := EvoPayoffs.innovation_step(deme, state) + return "Experiment with new techniques (harvest %s food): innovation %.2f -> %.2f of 1.0; at 1.0 you unlock a permanent cultural tradition." % [ + _food(base_income), deme.innovation_counter, minf(1.0, deme.innovation_counter + step) + ] + "settle": + var hex: EvoTypes.HexCell = state.cells[target] + var n := EvoPayoffs.settlers(deme) + var food := deme.energy_reserves * float(n) / float(deme.population) + return "Found a new camp at %s (%s, land %s): +1 settlement for your lineage with %d settlers and %s food (you keep %d people and harvest %s food)." % [ + target, hex.biome.replace("_", " "), _food(hex.current_resources), n, _food(food), deme.population - n, _food(base_income) + ] + "trade": + var gain := EvoPayoffs.trade_gain(deme, other, state) + var alone := EvoPayoffs.unreciprocated_trade_gain(deme, other, state) + return "Trade with %s: harvest %s food + %s trade bonus = %s food (%s if mutual trade), trust +0.12, and diffuse cultural knowledge." % [ + other.name, _food(base_income), _food(alone), _food(base_income + alone), _food(base_income + gain) + ] + "aid": + var amt := EvoPayoffs.aid_amount(deme) + return "Give %s surplus food to %s (you harvest %s food): trust +0.25, reputation improves, and seals any open alliance offer." % [_food(amt), other.name, _food(base_income)] + "offer_pact": + var instant := other.lineage_id == deme.lineage_id or (state.relations.has(EvoTypes.make_pair_key(deme.id, other.id)) and (state.relations[EvoTypes.make_pair_key(deme.id, other.id)] as EvoTypes.Relation).trust >= 0.30) + return "Form an alliance with %s (harvest %s food): %s, both gain +%s food harvest every turn and mutual defense against attackers." % [ + other.name, _food(base_income), "seals immediately" if instant else "if they accept", _pct(EvoPayoffs.PACT_HARVEST_BONUS) + ] + "accept_pact": + return "Accept %s's alliance (harvest %s food): seals alliance immediately, both gain +%s food harvest every turn and mutual defense." % [ + other.name, _food(base_income), _pct(EvoPayoffs.PACT_HARVEST_BONUS) + ] + "demand_tribute": + var odds_t := EvoPayoffs.raid_odds(deme, other, state, false) + var trib := EvoPayoffs.tribute_amount(other) + return "Demand %s food tribute from %s (harvest %s food + %s tribute = %s food if paid, zero casualties; your raid win chance is %s): if they refuse next turn, your raiders automatically strike for ~%s food." % [ + _food(trib), other.name, _food(base_income), _food(trib), _food(base_income + trib), _pct(float(odds_t["p_win"])), _food(float(odds_t["loot"])) + ] + "pay_tribute": + var key := EvoTypes.make_pair_key(deme.id, other.id) + var amount := (state.relations[key] as EvoTypes.Relation).proposal_amount + var threat := EvoPayoffs.raid_odds(other, deme, state, false) + return "Pay %s food tribute to %s (harvest %s food): buys a %d-turn truce and avoids raid casualties (if you refuse, they immediately raid with %s win chance to steal ~%s food)." % [ + _food(amount), other.name, _food(base_income), EvoPayoffs.TRUCE_AFTER_TRIBUTE, _pct(float(threat["p_win"])), _food(float(threat["loot"])) + ] + "raid": + var odds := EvoPayoffs.raid_odds(deme, other, state, false) + var loot := float(odds["loot"]) + return "Raid %s with %d fighters (win chance %s): harvest %s food + steal ~%s food on win = ~%s food and pillage 1 field (you lose ~%d, they lose ~%d; on loss you lose ~%d). Starts %d-turn feud." % [ + other.name, int(odds["troops"]), _pct(float(odds["p_win"])), _food(base_income), _food(loot), _food(base_income + loot), + int(odds["attacker_loss_win"]), int(odds["defender_loss_win"]), int(odds["attacker_loss_lose"]), EvoPayoffs.FEUD_AFTER_RAID + ] + "betray": + var odds := EvoPayoffs.raid_odds(deme, other, state, true) + var loot := float(odds["loot"]) + return "Betray ally %s with a surprise attack while their gates are open (win chance %s): harvest %s food + seize ~%s food and 1 field on win = ~%s food (they lose ~%d, on loss you lose ~%d; breaks pact, %d-turn feud, reputation -0.50)." % [ + other.name, _pct(float(odds["p_win"])), _food(base_income), _food(loot), _food(base_income + loot), int(odds["defender_loss_win"]), + int(odds["attacker_loss_lose"]), EvoPayoffs.FEUD_AFTER_BETRAYAL + ] + "conquer": + var odds := EvoPayoffs.conquest_odds(deme, other, state) + var fields := EvoRuralHinterland.owned_hexes(other, state).size() + var loot := roundf(other.energy_reserves * 0.5) + return "Conquer %s with %d warriors (win chance %s): harvest %s food + capture their hex%s and +%s food on win = ~%s food (+1 settlement, they lose ~%d and flee or are absorbed; on loss ~%d warriors die). Starts %d-turn feud." % [ + other.name, int(odds["troops"]), _pct(float(odds["p_win"])), _food(base_income), + " and %d fields" % fields if fields > 0 else "", _food(loot), _food(base_income + loot), int(odds["defender_loss_win"]), int(odds["attacker_loss_lose"]), EvoPayoffs.FEUD_AFTER_WAR + ] + "unite", "accept_union": + var verb := "Propose to unite with" if action_type == "unite" else "Accept union with" + var outcome := ( + "both cities keep their hexes, share walls and defend as one" + if union_kind(deme, other) == "walled_union" + else "merge into a single larger city (%d people together)" % (deme.population + other.population) + ) + var timing := " if they accept next turn" if action_type == "unite" else "" + return "%s %s%s (harvest %s food): %s." % [verb, other.name, timing, _food(base_income), outcome] + return action_type diff --git a/src/engine/agency/outcome_forecast.gd.uid b/src/engine/agency/outcome_forecast.gd.uid new file mode 100644 index 0000000..01dee67 --- /dev/null +++ b/src/engine/agency/outcome_forecast.gd.uid @@ -0,0 +1 @@ +uid://c6qdcn1feu63i diff --git a/src/engine/agency/situation_report.gd b/src/engine/agency/situation_report.gd new file mode 100644 index 0000000..39b8c2b --- /dev/null +++ b/src/engine/agency/situation_report.gd @@ -0,0 +1,124 @@ +class_name EvoSituationReport +extends RefCounted + +## Natural-language briefing given to the decision model: doctrine, goal-relevant +## state, neighbors with their relationship history, memory and pending proposals. + +const DIRECTIONS := { + Vector2i(1, 0): "E", Vector2i(1, -1): "NE", Vector2i(0, -1): "NW", + Vector2i(-1, 0): "W", Vector2i(-1, 1): "SW", Vector2i(0, 1): "SE" +} + + +static func doctrine_of(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: + var strategy: EvoTypes.StrategyDef = state.strategies_catalog[deme.strategy_id] + return strategy.doctrine if strategy.doctrine != "" else strategy.description + + +static func culture_name(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: + var strategy: EvoTypes.StrategyDef = state.strategies_catalog[deme.strategy_id] + return EvoI18n.strategy_text(strategy.id, "name", strategy.name, "en") + + +static func _relation_text(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: + var key := EvoTypes.make_pair_key(deme.id, other.id) + var parts: Array[String] = [] + parts.append("your kin" if other.lineage_id == deme.lineage_id else "foreign lineage") + if not state.relations.has(key): + parts.append("no treaty, no history") + return ", ".join(parts) + var rel: EvoTypes.Relation = state.relations[key] + if rel.pact_turns > 0: + parts.append("ALLIED for %d turns" % rel.pact_turns) + elif rel.feud_turns > 0: + parts.append("FEUD (%d turns left)" % rel.feud_turns) + elif rel.truce_turns > 0: + parts.append("truce (%d turns left)" % rel.truce_turns) + else: + parts.append("no treaty") + parts.append("trust %s" % EvoRelations.trust_word(rel.trust)) + if rel.last_event != "": + parts.append("last: %s" % rel.last_event) + return ", ".join(parts) + + +static func _neighbor_lines(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Array[String]: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var lines: Array[String] = [] + for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + var dir := str(DIRECTIONS[Vector2i(ncell.q - cell.q, ncell.r - cell.r)]) + if not EvoActionMenu.is_passable(ncell): + continue + if ncell.deme_ids.is_empty(): + var use := "your fields" if ncell.owner_deme_id == deme.id else ("fields of another settlement" if ncell.owner_deme_id != "" else "empty") + lines.append("- %s (%s): %s %s, land %d." % [dir, ncell.id, use, ncell.biome.replace("_", " "), int(ncell.current_resources)]) + continue + var other: EvoTypes.Deme = state.demes[ncell.deme_ids[0]] + lines.append("- %s (%s) %s [%s culture]: %d people, food %d, %s; %s; reputation %s." % [ + dir, ncell.id, other.name, culture_name(other, state), other.population, int(other.energy_reserves), + EvoOutcomeForecast.WALL_NAMES[other.defense_tier], _relation_text(deme, other, state), + EvoRelations.reputation_word(other.reputation) + ]) + return lines + + +static func _lineage_line(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: + var own_n := 0 + var own_pop := 0 + var other_n := 0 + var other_pop := 0 + for d: EvoTypes.Deme in state.demes.values(): + if d.lineage_id == deme.lineage_id: + own_n += 1 + own_pop += d.population + else: + other_n += 1 + other_pop += d.population + return "Your lineage: %d settlements, %d people. Rival lineages: %d settlements, %d people." % [own_n, own_pop, other_n, other_pop] + + +static func _pending_lines(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Array[String]: + var lines: Array[String] = [] + for pk in state.relations.keys(): + var rel: EvoTypes.Relation = state.relations[pk] + var partner_id := EvoTypes.pair_partner(str(pk), deme.id) + if partner_id == "" or rel.proposal_from != partner_id: + continue + var partner: EvoTypes.Deme = state.demes[partner_id] + match rel.proposal_kind: + "pact": + lines.append("%s proposes an alliance." % partner.name) + "union": + lines.append("%s proposes to unite with you." % partner.name) + "tribute": + lines.append("%s demands %d food as tribute, or else." % [partner.name, int(rel.proposal_amount)]) + return lines + + +static func build(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var need := EvoPayoffs.food_need(deme, cell, state) + var turns_of_food := deme.energy_reserves / maxf(1.0, need) + var land_pct := int(roundf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max) * 100.0)) + var lines: Array[String] = [ + "Culture: %s" % doctrine_of(deme, state), + "Turn %d, %s season. Settlement %s (hex %s, %s): %d people, food stock %d (%.1f turns of need), last turn %s%d food, land at %d%% of capacity, %s." % [ + state.generation, "wet" if state.season == "wet_warm" else "dry", deme.name, cell.id, cell.biome.replace("_", " "), + deme.population, int(deme.energy_reserves), turns_of_food, "+" if deme.last_energy_delta >= 0.0 else "", + int(roundf(deme.last_energy_delta)), land_pct, EvoOutcomeForecast.WALL_NAMES[deme.defense_tier] + ], + "Your reputation among neighbors: %s." % EvoRelations.reputation_word(deme.reputation), + _lineage_line(deme, state), + "Neighbors:" + ] + var neighbor_lines := _neighbor_lines(deme, state) + lines.append_array(neighbor_lines if not neighbor_lines.is_empty() else ["- none reachable"]) + if deme.last_outcome != "": + lines.append("Your last action: %s" % deme.last_outcome) + if not deme.memory.is_empty(): + lines.append("Recent history: %s" % " | ".join(deme.memory)) + var pending := _pending_lines(deme, state) + if not pending.is_empty(): + lines.append("Pending: %s" % " ".join(pending)) + return "\n".join(lines) diff --git a/src/engine/agency/situation_report.gd.uid b/src/engine/agency/situation_report.gd.uid new file mode 100644 index 0000000..8a3d466 --- /dev/null +++ b/src/engine/agency/situation_report.gd.uid @@ -0,0 +1 @@ +uid://d1b7qymquo5gp diff --git a/src/engine/biophys/conflict_phase.gd b/src/engine/biophys/conflict_phase.gd new file mode 100644 index 0000000..72f51bb --- /dev/null +++ b/src/engine/biophys/conflict_phase.gd @@ -0,0 +1,154 @@ +class_name EvoConflictPhase +extends RefCounted + +## Resolves betrayals, raids and conquests with `EvoPayoffs` odds and the world RNG. + + +static func resolve(state: EvoTypes.WorldState, lang: String) -> void: + for action_type in ["betray", "raid", "conquer"]: + for did in state.demes.keys(): + if not state.demes.has(did): + continue + var deme: EvoTypes.Deme = state.demes[did] + if str(deme.chosen_action["type"]) != action_type: + continue + var other := EvoDiplomacyPhase.adjacent_target(deme, state) + if other == null or (action_type != "betray" and other.lineage_id == deme.lineage_id): + deme.last_outcome = "Your %s was called off: no valid target." % action_type + continue + var rel := EvoTypes.get_relation(state, deme.id, other.id) + var allied := rel.pact_turns > 0 + if (action_type == "betray") != allied or rel.truce_turns > 0: + deme.last_outcome = "Your %s against %s was called off." % [action_type, other.name] + continue + if action_type == "conquer": + _conquer(deme, other, rel, state, lang) + else: + _raid(deme, other, rel, state, lang, action_type == "betray") + + +static func _warn_allies(attacker: EvoTypes.Deme, victim: EvoTypes.Deme, state: EvoTypes.WorldState) -> void: + for pk in state.relations.keys(): + var ally_id := EvoTypes.pair_partner(str(pk), victim.id) + if ally_id == "" or ally_id == attacker.id or (state.relations[pk] as EvoTypes.Relation).pact_turns <= 0: + continue + var ally: EvoTypes.Deme = state.demes[ally_id] + EvoRelations.record(state, ally, attacker, -0.2, "%s attacked %s's ally %s" % [attacker.name, ally.name, victim.name]) + EvoRelations.remember(ally, state, "%s attacked your ally %s." % [attacker.name, victim.name]) + + +static func _raid(attacker: EvoTypes.Deme, defender: EvoTypes.Deme, rel: EvoTypes.Relation, state: EvoTypes.WorldState, lang: String, surprise: bool) -> void: + var odds := EvoPayoffs.raid_odds(attacker, defender, state, surprise) + var provoked := rel.feud_turns > 0 + var won := state.rng.randf() < float(odds["p_win"]) + var verb := "betrayed" if surprise else "raided" + var loot := 0.0 + var a_loss := mini(attacker.population, int(odds["attacker_loss_win"]) if won else int(odds["attacker_loss_lose"])) + var d_loss := mini(defender.population, int(odds["defender_loss_win"]) if won else int(odds["defender_loss_lose"])) + attacker.population -= a_loss + defender.population -= d_loss + if won: + loot = minf(float(odds["loot"]), defender.energy_reserves) + _pillage_field(attacker, defender, state) + defender.energy_reserves = maxf(0.0, defender.energy_reserves - minf(float(odds["loot"]), defender.energy_reserves)) + attacker.energy_reserves += loot + attacker.last_outcome = "You %s %s and won: +%d food, lost %d, they lost %d." % [verb, defender.name, int(loot), a_loss, d_loss] + EvoRelations.remember(defender, state, "%s %s you: you lost %d food and %d people." % [attacker.name, verb, int(loot), d_loss]) + else: + attacker.last_outcome = "You %s %s but were repelled: lost %d people." % [verb, defender.name, a_loss] + EvoRelations.remember(defender, state, "You repelled %s's %s (they lost %d)." % [attacker.name, "betrayal" if surprise else "raid", a_loss]) + + EvoRelations.start_feud(rel, EvoPayoffs.FEUD_AFTER_BETRAYAL if surprise else EvoPayoffs.FEUD_AFTER_RAID) + EvoRelations.record(state, attacker, defender, -1.0 if surprise else -0.5, "%s %s %s" % [attacker.name, verb, defender.name]) + EvoRelations.adjust_reputation(attacker, -0.5 if surprise else (0.0 if provoked else -0.08)) + _warn_allies(attacker, defender, state) + if surprise: + state.disused_connections.append({ + "from_hex_id": attacker.hex_id, + "to_hex_id": defender.hex_id, + "from_deme_id": attacker.id, + "to_deme_id": defender.id, + "age": 1, + "broken_gen": state.generation + }) + + var event_type := "betrayal" if surprise else "raid" + EvoSettlementPhase.push_flow(state, attacker, defender, event_type, 1.0 if surprise else clampf(loot / 32.0, 0.5, 1.0)) + EvoTypes.push_event(state, event_type, attacker.hex_id, defender.hex_id, attacker.id, defender.id, loot, a_loss + d_loss) + if not won and not surprise: + return + var params := {"raider": attacker.name, "betrayer": attacker.name, "victim": defender.name, "loot": str(int(loot)), "hex": defender.hex_id} + EvoSettlementPhase.chronicle(state, "chronicle-%s-%d-%s-%s" % [event_type, state.generation, attacker.id, defender.id], event_type, lang, + "chronicle_" + event_type, params, defender.hex_id, attacker) + + +static func _pillage_field(attacker: EvoTypes.Deme, defender: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var fields := EvoRuralHinterland.owned_hexes(defender, state) + if fields.is_empty(): + return 0.0 + var field := fields[0] + var plunder := minf(field.current_resources * 0.3, 20.0) + field.current_resources -= plunder + if defender.defense_tier < 2: + field.owner_deme_id = "" + field.land_use = "wild" + field.has_ruins = true + EvoTypes.push_event(state, "pillage", attacker.hex_id, field.id, attacker.id, defender.id, plunder, 0) + return plunder + + +static func _conquer(attacker: EvoTypes.Deme, defender: EvoTypes.Deme, rel: EvoTypes.Relation, state: EvoTypes.WorldState, lang: String) -> void: + var odds := EvoPayoffs.conquest_odds(attacker, defender, state) + var troops := int(odds["troops"]) + var provoked := rel.feud_turns > 0 + var origin_hex := attacker.hex_id + var target_hex := defender.hex_id + var params := {"attacker": attacker.name, "defender": defender.name, "hex": target_hex} + EvoRelations.start_feud(rel, EvoPayoffs.FEUD_AFTER_WAR) + EvoRelations.record(state, attacker, defender, -0.7, "%s attacked %s to conquer it" % [attacker.name, defender.name]) + EvoRelations.adjust_reputation(attacker, 0.0 if provoked else -0.15) + _warn_allies(attacker, defender, state) + attacker.population -= troops + + if state.rng.randf() < float(odds["p_win"]): + var a_loss := int(odds["attacker_loss_win"]) + var d_loss := mini(defender.population, int(odds["defender_loss_win"])) + defender.population -= d_loss + var loot := roundf(defender.energy_reserves * 0.5) + defender.energy_reserves -= loot + var captured_tier := maxi(0, defender.defense_tier - 1) + var warband := EvoSettlementPhase.occupy_conquered_hex(attacker, defender, troops - a_loss, loot, captured_tier, state) + EvoRelations.start_feud(EvoTypes.get_relation(state, warband.id, defender.id), EvoPayoffs.FEUD_AFTER_WAR) + attacker.last_outcome = "You conquered %s's hex %s: %d warriors now hold it with %d food." % [defender.name, target_hex, warband.population, int(loot)] + EvoRelations.remember(warband, state, "Your warriors took this hex from %s." % defender.name) + EvoRelations.remember(defender, state, "%s conquered your home %s; %d of your people died." % [attacker.name, target_hex, d_loss]) + var flow := EvoTypes.HexEdgeFlow.new() + flow.from_hex_id = origin_hex + flow.to_hex_id = target_hex + flow.flow_type = "war" + flow.intensity = 1.0 + flow.from_deme_id = attacker.id + flow.to_deme_id = warband.id + state.active_flows.append(flow) + EvoTypes.push_event(state, "war", origin_hex, target_hex, attacker.id, defender.id, loot, a_loss + d_loss) + params["casualties"] = str(a_loss + d_loss) + params["loot"] = str(int(loot)) + EvoSettlementPhase.chronicle(state, "chronicle-war-win-%d-%s-%s" % [state.generation, attacker.id, defender.id], "war", lang, + "chronicle_war_victory", params, target_hex, warband) + return + + var lost := int(odds["attacker_loss_lose"]) + var d_lost := mini(defender.population, int(odds["defender_loss_lose"])) + attacker.population += troops - lost + defender.population -= d_lost + params["casualties"] = str(lost + d_lost) + attacker.last_outcome = "Your assault on %s failed: %d warriors died." % [defender.name, lost] + EvoRelations.remember(defender, state, "You repelled %s's invasion (they lost %d)." % [attacker.name, lost]) + EvoSettlementPhase.push_flow(state, attacker, defender, "war", 0.85) + EvoTypes.push_event(state, "war_repelled", origin_hex, target_hex, attacker.id, defender.id, 0.0, lost + d_lost) + if defender.defense_tier >= 1: + EvoSettlementPhase.push_flow(state, attacker, defender, "siege", 0.9) + EvoTypes.push_event(state, "siege", origin_hex, target_hex, attacker.id, defender.id, float(defender.defense_tier), d_lost) + EvoSettlementPhase.chronicle(state, "chronicle-siege-%d-%s-%s" % [state.generation, defender.id, attacker.id], "siege", lang, + "chronicle_siege", params, target_hex, defender) + EvoSettlementPhase.chronicle(state, "chronicle-war-repel-%d-%s-%s" % [state.generation, defender.id, attacker.id], "war", lang, + "chronicle_war_repelled", params, target_hex, defender) diff --git a/src/engine/biophys/conflict_phase.gd.uid b/src/engine/biophys/conflict_phase.gd.uid new file mode 100644 index 0000000..3c9c77d --- /dev/null +++ b/src/engine/biophys/conflict_phase.gd.uid @@ -0,0 +1 @@ +uid://bm8wnnxtdnsjg diff --git a/src/engine/biophys/culture.gd b/src/engine/biophys/culture.gd new file mode 100644 index 0000000..e3d3b26 --- /dev/null +++ b/src/engine/biophys/culture.gd @@ -0,0 +1,47 @@ +class_name EvoCulture +extends RefCounted + +## Trait compatibility and horizontal cultural transmission between trading partners. + + +static func can_acquire_trait(deme: EvoTypes.Deme, trait_id: String, traits_catalog: Dictionary) -> bool: + if deme.cultural_trait_ids.has(trait_id) or deme.biological_trait_ids.has(trait_id): + return false + var tdef: EvoTypes.TraitDef = traits_catalog[trait_id] + for existing_id in deme.biological_trait_ids + deme.cultural_trait_ids: + var existing: EvoTypes.TraitDef = traits_catalog[existing_id] + if tdef.mutually_exclusive_with.has(existing_id) or existing.mutually_exclusive_with.has(trait_id): + return false + return true + + +## The receiver adopts the first donor tradition its social cohesion can absorb. +static func diffuse(donor: EvoTypes.Deme, receiver: EvoTypes.Deme, state: EvoTypes.WorldState, lang: String) -> void: + if not EvoCatalog.deme_has_cultural_capacity(receiver, state.traits_catalog): + return + var cohesion := EvoPayoffs.vector(receiver, state).social_cohesion + for tid in donor.cultural_trait_ids: + var tdef: EvoTypes.TraitDef = state.traits_catalog[tid] + if not can_acquire_trait(receiver, tid, state.traits_catalog) or cohesion < tdef.vector.transmission_threshold: + continue + receiver.cultural_trait_ids.append(tid) + EvoTypes.push_event(state, "symbiosis", donor.hex_id, receiver.hex_id, donor.id, receiver.id, 1.0, 0) + var tname := EvoI18n.trait_name(lang, tdef) + var is_en := lang == "en" + var entry := EvoTypes.ChronicleEntry.new() + entry.id = "chronicle-symbiosis-%d-%s-%s" % [state.generation, receiver.id, tid] + entry.generation = state.generation + entry.event_type = "symbiosis" + entry.title = ("Cultural Symbiosis: %s" if is_en else "Simbiosis Cultural: %s") % tname + entry.causal_explanation = ( + "%s acquired %s through trade with %s." % [receiver.name, tname, donor.name] + if is_en + else "%s adquirió %s mediante el comercio con %s." % [receiver.name, tname, donor.name] + ) + entry.hex_id = receiver.hex_id + entry.deme_id = receiver.id + entry.lineage_id = receiver.lineage_id + entry.trait_id = tid + state.chronicle.append(entry) + EvoRelations.remember(receiver, state, "Adopted the %s tradition from %s." % [tdef.name, donor.name]) + return diff --git a/src/engine/biophys/culture.gd.uid b/src/engine/biophys/culture.gd.uid new file mode 100644 index 0000000..336fd38 --- /dev/null +++ b/src/engine/biophys/culture.gd.uid @@ -0,0 +1 @@ +uid://belsbrrh6ybqf diff --git a/src/engine/biophys/demography_migration.gd b/src/engine/biophys/demography_migration.gd deleted file mode 100644 index f0f744b..0000000 --- a/src/engine/biophys/demography_migration.gd +++ /dev/null @@ -1,1223 +0,0 @@ -class_name EvoDemographyMigration -extends RefCounted - - -static func is_hex_passable_for_deme(cell: EvoTypes.HexCell, spatial_mobility: float) -> bool: - return cell.biome != "ocean" and cell.altitude <= 0.85 and (cell.altitude - 0.50) <= spatial_mobility - - -static func compute_deme_divergence(deme_a: EvoTypes.Deme, deme_b: EvoTypes.Deme) -> float: - var count_a := deme_a.biological_trait_ids.size() + deme_a.cultural_trait_ids.size() - var count_b := deme_b.biological_trait_ids.size() + deme_b.cultural_trait_ids.size() - if count_a == 0 and count_b == 0: - return 0.0 - var intersection_count: int = 0 - for tid in deme_a.biological_trait_ids: - if deme_b.biological_trait_ids.has(tid) or deme_b.cultural_trait_ids.has(tid): - intersection_count += 1 - for tid in deme_a.cultural_trait_ids: - if deme_b.biological_trait_ids.has(tid) or deme_b.cultural_trait_ids.has(tid): - intersection_count += 1 - var union_size := count_a + count_b - intersection_count - if union_size <= 0: - return 0.0 - return snappedf(1.0 - float(intersection_count) / float(union_size), 0.0001) - - -static func _strip_coord_suffix(name_str: String) -> String: - var open_idx := name_str.rfind(" (") - if open_idx > 0 and name_str.ends_with(")"): - var inside := name_str.substr(open_idx + 2, name_str.length() - open_idx - 3) - if inside.contains(","): - return name_str.substr(0, open_idx) - return name_str - - -static func _strip_migration_suffix(id_str: String) -> String: - for i in range(1, id_str.length() - 2): - if id_str.unicode_at(i) == 45: # '-' - var tag := id_str.unicode_at(i + 1) - var d := id_str.unicode_at(i + 2) - if (tag == 109 or tag == 119) and d >= 48 and d <= 57: # 'm' or 'w' followed by '0'..'9' - return id_str.substr(0, i) - return id_str - - -static func _is_connected_to_founder( - cell: EvoTypes.HexCell, - founder_hex_id: String, - mobility: float, - deme: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> bool: - for oid in cell.deme_ids: - if oid != deme.id and state.demes.has(oid) and (state.demes[oid] as EvoTypes.Deme).lineage_id == deme.lineage_id: - return true - if founder_hex_id != "" and cell.id == founder_hex_id: - return true - var visited: Dictionary = {cell.id: 0} - var queue: Array[String] = [cell.id] - var head := 0 - while head < queue.size(): - var cur_id := queue[head] - head += 1 - var cur_dist: int = int(visited[cur_id]) - if cur_dist >= 3: - continue - var cur_cell: EvoTypes.HexCell = state.cells[cur_id] - for nid in HexMath.get_neighbor_ids(cur_cell.q, cur_cell.r, state.cells): - if visited.has(nid): - continue - var ncell: EvoTypes.HexCell = state.cells[nid] - if not is_hex_passable_for_deme(ncell, mobility): - continue - if nid == founder_hex_id: - return true - for oid in ncell.deme_ids: - if oid != deme.id and state.demes.has(oid) and (state.demes[oid] as EvoTypes.Deme).lineage_id == deme.lineage_id: - return true - visited[nid] = cur_dist + 1 - queue.append(nid) - return false - - -static func _compute_combat_forces( - attacker: EvoTypes.Deme, - warband_pop: int, - defender: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> Vector2: - var att_vec := EvoCatalog.compute_deme_vector(attacker, state.traits_catalog) - var def_vec := EvoCatalog.compute_deme_vector(defender, state.traits_catalog) - var pact_bonus := EvoEcologyInteraction.compute_pact_defense_bonus(defender, state) - var p_att_disp := EvoEcologyInteraction.get_archetype_prob(attacker, state.decisions_catalog, "dispute") - if p_att_disp <= 0.0 and attacker.last_decision_probabilities.has("dec-territorial-dispute"): - p_att_disp = float(attacker.last_decision_probabilities["dec-territorial-dispute"]) - - var att_warrior := 0.45 if attacker.cultural_trait_ids.has("cul-warrior-expansion") else 0.0 - var att_r_strat := 0.20 if attacker.biological_trait_ids.has("bio-r-strategy") else 0.0 - var def_warrior := 0.35 if defender.cultural_trait_ids.has("cul-warrior-expansion") else 0.0 - var fort_bonus: float = EvoEcologyInteraction.DEFENSE_TIER_BONUS[clampi(defender.defense_tier, 0, 3)] - - var flank_bonus := 0.0 - if state.cells.has(defender.hex_id): - var dcell: EvoTypes.HexCell = state.cells[defender.hex_id] - var nids := HexMath.get_neighbor_ids(dcell.q, dcell.r, state.cells) - for oid in state.demes.keys(): - var ally_att: EvoTypes.Deme = state.demes[oid] - if ally_att.id == attacker.id or ally_att.lineage_id != attacker.lineage_id or ally_att.population < 35: - continue - if ally_att.hex_id == defender.hex_id or nids.has(ally_att.hex_id): - flank_bonus = minf(0.85, flank_bonus + 0.28) - - var att_force := ( - float(warband_pop) - * (1.0 + 0.55 * maxf(0.0, att_vec.extraction_efficiency) + att_warrior + att_r_strat + flank_bonus) - * (0.75 + p_att_disp) - ) - var def_force := ( - float(defender.population) - * (1.0 + 0.60 * maxf(0.0, def_vec.social_cohesion) + pact_bonus + def_warrior + fort_bonus) - ) - return Vector2(att_force, def_force) - - -static func _min_distance_to_rival( - cell: EvoTypes.HexCell, - deme: EvoTypes.Deme, - state: EvoTypes.WorldState, - cache: Dictionary = {} -) -> int: - var c_key := (cell.id + "|" + deme.lineage_id) if state.active_pacts.is_empty() else (cell.id + "|" + deme.id) - if cache.has(c_key): - return int(cache[c_key]) - var min_dist := 999 - var visited_hexes: Dictionary = {} - for oid in state.demes.keys(): - var other: EvoTypes.Deme = state.demes[oid] - if other.lineage_id == deme.lineage_id or EvoTypes.is_pact_active(state, deme.id, other.id): - continue - if visited_hexes.has(other.hex_id) or not state.cells.has(other.hex_id): - continue - visited_hexes[other.hex_id] = true - var ocell: EvoTypes.HexCell = state.cells[other.hex_id] - var d := HexMath.hex_distance(cell.q, cell.r, ocell.q, ocell.r) - if d < min_dist: - min_dist = d - cache[c_key] = min_dist - return min_dist - - -static func _has_vanguard_closer_to_rival( - deme: EvoTypes.Deme, - cur_rival_dist: int, - state: EvoTypes.WorldState, - cache: Dictionary = {} -) -> bool: - for oid in state.demes.keys(): - var other: EvoTypes.Deme = state.demes[oid] - if other.id == deme.id or other.lineage_id != deme.lineage_id: - continue - if other.population < 45 or not state.cells.has(other.hex_id): - continue - var ocell: EvoTypes.HexCell = state.cells[other.hex_id] - if _min_distance_to_rival(ocell, other, state, cache) < cur_rival_dist: - return true - return false - - -static func _execute_territorial_invasion( - attacker: EvoTypes.Deme, - origin_cell: EvoTypes.HexCell, - target_cell: EvoTypes.HexCell, - defender: EvoTypes.Deme, - state: EvoTypes.WorldState, - lang: String, - geo_flows: Array[EvoTypes.HexEdgeFlow], - newly_created_demes: Dictionary, - initial_pop_by_deme: Dictionary, - pre_fission_pop_by_deme: Dictionary, - effective_vec_by_deme: Dictionary -) -> void: - var original_pop := attacker.population - var warband_pop := int(round(float(attacker.population) * 0.48)) - attacker.population -= warband_pop - - var forces := _compute_combat_forces(attacker, warband_pop, defender, state) - var att_force := forces.x - var def_force := forces.y - - if att_force >= def_force: - var captured_tier := maxi(0, defender.defense_tier - 1) - var att_loss := maxi(2, int(round(float(warband_pop) * clampf(0.22 * (def_force / maxf(1.0, att_force)), 0.08, 0.35)))) - var def_loss := maxi(4, int(round(float(defender.population) * clampf(0.35 * (att_force / maxf(1.0, def_force)), 0.20, 0.55)))) - var loot := roundf(defender.energy_reserves * 0.38) - defender.energy_reserves = maxf(0.0, snappedf(defender.energy_reserves - loot, 0.01)) - defender.population = maxi(0, defender.population - def_loss) - pre_fission_pop_by_deme[defender.id] = defender.population - - var energy_fraction := attacker.energy_reserves * (float(warband_pop) / float(maxi(1, original_pop))) - attacker.energy_reserves = maxf(0.0, snappedf(attacker.energy_reserves - energy_fraction, 0.01)) - var warband_energy := snappedf(energy_fraction * 0.85 + loot, 0.01) - var surviving_warband := maxi(10, warband_pop - att_loss) - - var retreated := false - if defender.population >= 10 or target_cell.deme_ids.size() >= 3: - var retreat_candidates: Array[EvoTypes.HexCell] = [] - for nid in HexMath.get_neighbor_ids(target_cell.q, target_cell.r, state.cells): - if nid == origin_cell.id: - continue - var rcell: EvoTypes.HexCell = state.cells[nid] - if rcell.biome != "ocean" and rcell.altitude <= 0.85 and rcell.deme_ids.size() < 3: - retreat_candidates.append(rcell) - retreat_candidates.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: - var rivals_a := 0 - for did_a in a.deme_ids: - if state.demes.has(did_a) and (state.demes[did_a] as EvoTypes.Deme).lineage_id != defender.lineage_id: - rivals_a += 1 - var rivals_b := 0 - for did_b in b.deme_ids: - if state.demes.has(did_b) and (state.demes[did_b] as EvoTypes.Deme).lineage_id != defender.lineage_id: - rivals_b += 1 - if rivals_a != rivals_b: - return rivals_a < rivals_b - return a.current_resources > b.current_resources - ) - if not retreat_candidates.is_empty(): - var retreat_cell := retreat_candidates[0] - target_cell.deme_ids.erase(defender.id) - defender.hex_id = retreat_cell.id - defender.defense_tier = 0 - if not retreat_cell.deme_ids.has(defender.id): - retreat_cell.deme_ids.append(defender.id) - retreated = true - if not retreated and target_cell.deme_ids.size() >= 3: - target_cell.deme_ids.erase(defender.id) - defender.population = 0 - defender.defense_tier = 0 - pre_fission_pop_by_deme[defender.id] = 0 - elif not retreated: - defender.defense_tier = 0 - - state.active_pacts[EvoTypes.make_pair_key(attacker.id, defender.id)] = -3 - - var base_id := _strip_migration_suffix(attacker.id) - var warband_id := "%s-w%d" % [base_id, state.generation] - var dup_idx := 2 - while state.demes.has(warband_id): - warband_id = "%s-w%d-%d" % [base_id, state.generation, dup_idx] - dup_idx += 1 - var base_name := _strip_coord_suffix(attacker.name) - var warband_name := ("%s (%s)" % [base_name, target_cell.id]).substr(0, 60) - - var warband := EvoTypes.Deme.new() - warband.id = warband_id - warband.name = warband_name - warband.lineage_id = attacker.lineage_id - warband.strategy_id = attacker.strategy_id - warband.color = attacker.color - warband.hex_id = target_cell.id - warband.population = surviving_warband - warband.energy_reserves = warband_energy - warband.last_energy_delta = 0.0 - warband.biological_trait_ids = attacker.biological_trait_ids.duplicate() - warband.cultural_trait_ids = attacker.cultural_trait_ids.duplicate() - warband.baldwin_counters = {} - warband.innovation_counter = 0.0 - warband.isolation_turns = 0 - warband.defense_tier = captured_tier - warband.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(warband_id) - warband.last_confidence = attacker.last_confidence - warband.last_decision_probabilities = attacker.last_decision_probabilities.duplicate() - warband.last_dominant_decision_id = attacker.last_dominant_decision_id - warband.last_justification = attacker.last_justification - - state.demes[warband_id] = warband - newly_created_demes[warband_id] = true - target_cell.deme_ids.append(warband_id) - initial_pop_by_deme[warband_id] = surviving_warband - pre_fission_pop_by_deme[warband_id] = surviving_warband - effective_vec_by_deme[warband_id] = effective_vec_by_deme[attacker.id] - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = origin_cell.id - flow.to_hex_id = target_cell.id - flow.flow_type = "war" - flow.intensity = 1.0 - flow.from_deme_id = attacker.id - flow.to_deme_id = warband_id - state.active_flows.append(flow) - geo_flows.append(flow) - - var params := { - "attacker": attacker.name, - "defender": defender.name, - "hex": target_cell.id, - "casualties": str(att_loss + def_loss), - "loot": str(int(round(loot))) - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-war-win-%d-%s-%s" % [state.generation, attacker.id, defender.id] - entry.generation = state.generation - entry.event_type = "war" - entry.title = EvoI18n.t(lang, "chronicle_war_victory_title", params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_war_victory_desc", params) - entry.hex_id = target_cell.id - entry.deme_id = warband_id - entry.lineage_id = attacker.lineage_id - state.chronicle.append(entry) - else: - var def_tier := clampi(defender.defense_tier, 0, 3) - var loss_rate := 0.28 + 0.06 * float(def_tier) - var att_loss := maxi(4, int(round(float(warband_pop) * loss_rate))) - var def_loss := maxi(2, int(round(float(defender.population) * (0.16 - 0.02 * float(def_tier))))) - var survivors := maxi(0, warband_pop - att_loss) - attacker.population = maxi(10, attacker.population + survivors) - attacker.energy_reserves = maxf(0.0, snappedf(attacker.energy_reserves - 15.0, 0.01)) - defender.population = maxi(5, defender.population - def_loss) - pre_fission_pop_by_deme[defender.id] = defender.population - - for pk in state.active_pacts.keys(): - var sep := "__" if str(pk).contains("__") else "|" - var parts := str(pk).split(sep) - if parts.size() == 2 and (parts[0] == defender.id or parts[1] == defender.id): - if int(state.active_pacts[pk]) > 0: - state.active_pacts[pk] = int(state.active_pacts[pk]) + 1 - - var ad_pk := EvoTypes.make_pair_key(attacker.id, defender.id) - if int(state.active_pacts.get(ad_pk, 0)) <= 0: - state.active_pacts[ad_pk] = mini(-2, int(state.active_pacts.get(ad_pk, 0))) - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = origin_cell.id - flow.to_hex_id = target_cell.id - flow.flow_type = "war" - flow.intensity = 0.85 - flow.from_deme_id = attacker.id - flow.to_deme_id = defender.id - state.active_flows.append(flow) - geo_flows.append(flow) - - if def_tier >= 1: - var is_isolated_defender := defender.merged_count == 0 and EvoEcologyInteraction.compute_pact_defense_bonus(defender, state) <= 0.0 - if att_force >= def_force * 0.80: - if defender.energy_reserves >= 10.0 and not is_isolated_defender: - defender.energy_reserves = maxf(0.0, snappedf(defender.energy_reserves - 10.0, 0.01)) - else: - defender.energy_reserves = maxf(0.0, snappedf(defender.energy_reserves - 10.0, 0.01)) - defender.defense_tier = maxi(0, defender.defense_tier - 1) - elif is_isolated_defender and att_force >= def_force * 0.45: - defender.energy_reserves = maxf(0.0, snappedf(defender.energy_reserves - 10.0, 0.01)) - defender.defense_tier = maxi(0, defender.defense_tier - 1) - - var siege_flow := EvoTypes.HexEdgeFlow.new() - siege_flow.from_hex_id = origin_cell.id - siege_flow.to_hex_id = target_cell.id - siege_flow.flow_type = "siege" - siege_flow.intensity = 0.90 - siege_flow.from_deme_id = attacker.id - siege_flow.to_deme_id = defender.id - state.active_flows.append(siege_flow) - geo_flows.append(siege_flow) - - var siege_params := { - "defender": defender.name, - "attacker": attacker.name, - "hex": target_cell.id, - "casualties": str(att_loss + def_loss) - } - var siege_entry := EvoTypes.ChronicleEntry.new() - siege_entry.id = "chronicle-siege-%d-%s-%s" % [state.generation, defender.id, attacker.id] - siege_entry.generation = state.generation - siege_entry.event_type = "siege" - siege_entry.title = EvoI18n.t(lang, "chronicle_siege_title", siege_params) - siege_entry.causal_explanation = EvoI18n.t(lang, "chronicle_siege_desc", siege_params) - siege_entry.hex_id = target_cell.id - siege_entry.deme_id = defender.id - siege_entry.lineage_id = defender.lineage_id - state.chronicle.append(siege_entry) - - var params := { - "defender": defender.name, - "attacker": attacker.name, - "hex": target_cell.id, - "casualties": str(att_loss + def_loss) - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-war-repel-%d-%s-%s" % [state.generation, defender.id, attacker.id] - entry.generation = state.generation - entry.event_type = "war" - entry.title = EvoI18n.t(lang, "chronicle_war_repelled_title", params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_war_repelled_desc", params) - entry.hex_id = target_cell.id - entry.deme_id = defender.id - entry.lineage_id = defender.lineage_id - state.chronicle.append(entry) - - -static func run_demography_and_migration_phase( - state: EvoTypes.WorldState, - lang: String, - initial_pop_by_deme: Dictionary, - effective_vec_by_deme: Dictionary, - hawk_penalty_by_deme: Dictionary, - extracted_energy_by_deme: Dictionary, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow] = [] -) -> void: - var is_en := lang == "en" - var had_lineage_contact: Dictionary = {} - var newly_created_demes: Dictionary = {} - var pre_fission_pop_by_deme: Dictionary = {} - - var cell_pop_by_hex: Dictionary = {} - for cid in state.cells.keys(): - var c: EvoTypes.HexCell = state.cells[cid] - var c_pop: int = 0 - for r_id in c.deme_ids: - if state.demes.has(r_id): - c_pop += (state.demes[r_id] as EvoTypes.Deme).population - cell_pop_by_hex[cid] = c_pop - - for did in state.demes.keys(): - var deme: EvoTypes.Deme = state.demes[did] - if not state.cells.has(deme.hex_id): - continue - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] - var v_eff: EvoTypes.BiophysicalVector = effective_vec_by_deme[deme.id] - var hawk_mult: float = float(hawk_penalty_by_deme[deme.id]) if hawk_penalty_by_deme.has(deme.id) else 1.0 - var extracted: float = float(extracted_energy_by_deme[deme.id]) if extracted_energy_by_deme.has(deme.id) else 0.0 - - var metabolic_cost := ( - float(deme.population) - * 0.42 - * ( - 1.0 - + 0.55 * v_eff.metabolic_cost - + 0.20 * absf(cell.temperature) * maxf(0.0, 1.0 - maxf(0.0, v_eff.spatial_mobility)) - ) - * hawk_mult - ) - - var fort_upkeep: float = [0.0, 1.5, 3.0, 5.0][clampi(deme.defense_tier, 0, 3)] - var delta_e := extracted - metabolic_cost - fort_upkeep - var prev_reserves := deme.energy_reserves - var reserve_draw := minf(-delta_e, prev_reserves * 0.50) if (delta_e < 0.0 and prev_reserves > 0.30 * float(deme.population)) else 0.0 - var effective_delta_e := delta_e + reserve_draw - - deme.last_energy_delta = snappedf(delta_e, 0.01) - deme.energy_reserves = maxf(0.0, snappedf(prev_reserves + delta_e, 0.01)) - if deme.energy_reserves <= 0.0 and delta_e < -8.0 and deme.defense_tier > 0: - deme.defense_tier = maxi(0, deme.defense_tier - 1) - - var raw_growth := (effective_delta_e / maxf(10.0, float(deme.population))) * 0.45 - if deme.biological_trait_ids.has("bio-r-strategy") and raw_growth > 0.0: - var cell_pop: int = int(cell_pop_by_hex[cell.id]) - var crowding := float(cell_pop) / maxf(10.0, cell.carrying_capacity) - if crowding < 0.55: - raw_growth *= 1.28 - elif crowding >= 0.65: - raw_growth *= 1.05 - else: - raw_growth *= 1.16 - var growth_rate := clampf(raw_growth, -0.45, 0.35) - if deme.biological_trait_ids.has("bio-k-strategy"): - growth_rate = clampf(growth_rate * 0.88, -0.45, 0.35) - - var next_pop := int(roundf(float(deme.population) * (1.0 + growth_rate))) - deme.population = (deme.population - 1) if (effective_delta_e < 0.0 and next_pop >= deme.population) else next_pop - pre_fission_pop_by_deme[deme.id] = deme.population - - var p_explore: float = 0.0 - var p_cooperate: float = 0.0 - var p_migrate_d: float = 0.0 - var p_dispute_d: float = 0.0 - for dec_id in deme.last_decision_probabilities.keys(): - var prob := float(deme.last_decision_probabilities[dec_id]) - var arch := (state.decisions_catalog[dec_id] as EvoTypes.DecisionDef).archetype if state.decisions_catalog.has(dec_id) else "" - if arch != "forage" and prob >= 0.35: - var cur_c: int = int(deme.baldwin_counters[dec_id]) if deme.baldwin_counters.has(dec_id) else 0 - deme.baldwin_counters[dec_id] = cur_c + 1 - if arch == "innovate": - p_explore += prob - elif arch == "cooperate": - p_cooperate += prob - elif arch == "migrate": - p_migrate_d += prob - elif arch == "dispute": - p_dispute_d += prob - deme.set_meta("_turn_p_mig", p_migrate_d) - deme.set_meta("_turn_p_disp", p_dispute_d) - - if EvoCatalog.deme_has_cultural_capacity(deme, state.traits_catalog): - var p_innovate := p_explore + 0.35 * p_cooperate - deme.innovation_counter = minf(2.0, snappedf(deme.innovation_counter + p_innovate * 0.45, 0.0001)) - - var pre_fission_ids := state.demes.keys() - var rival_dist_cache: Dictionary = {} - for did in pre_fission_ids: - if not state.demes.has(did): - continue - var deme: EvoTypes.Deme = state.demes[did] - if deme.population < 20 or not state.cells.has(deme.hex_id): - continue - var current_cell: EvoTypes.HexCell = state.cells[deme.hex_id] - var v_eff: EvoTypes.BiophysicalVector = effective_vec_by_deme[deme.id] - var p_migrate := float(deme.get_meta("_turn_p_mig", 0.0)) - var p_dispute := float(deme.get_meta("_turn_p_disp", 0.0)) - var extracted := float(extracted_energy_by_deme.get(deme.id, 0.0)) - var needed := maxf(1.0, float(deme.population) * 0.42 * (1.0 + 0.55 * v_eff.metabolic_cost)) - var adequacy := extracted / needed - var k_ratio := current_cell.carrying_capacity / maxf(10.0, current_cell.carrying_capacity_max) - - var has_martial_profile := ( - deme.cultural_trait_ids.has("cul-warrior-expansion") - or (p_dispute >= 0.24 and v_eff.extraction_efficiency > 0.25 and v_eff.social_cohesion <= 0.15) - ) - var has_martial_pressure := ( - adequacy < 1.25 - or state.season == "dry_cold" - or deme.energy_reserves < 70.0 - or k_ratio < 0.85 - or deme.population >= 78 - ) - var is_martial_expander := has_martial_profile and has_martial_pressure - var martial_trigger := ( - is_martial_expander - and deme.population >= 55 - and deme.energy_reserves >= 40.0 - and ( - p_migrate + p_dispute >= 0.30 - or (deme.cultural_trait_ids.has("cul-warrior-expansion") and deme.population >= 75) - ) - ) - - if martial_trigger: - var warband_pop := int(round(float(deme.population) * 0.48)) - var best_target_cell: EvoTypes.HexCell = null - var best_defender: EvoTypes.Deme = null - var best_ratio := -1.0 - - for nid in HexMath.get_neighbor_ids(current_cell.q, current_cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - if ncell.biome == "ocean" or ncell.altitude > 0.85: - continue - for oid in ncell.deme_ids: - if not state.demes.has(oid): - continue - var other: EvoTypes.Deme = state.demes[oid] - if other.lineage_id == deme.lineage_id or EvoTypes.is_pact_active(state, deme.id, other.id): - continue - var already_raided := false - for fl in state.active_flows: - if fl.flow_type in ["raid", "tribute"] and fl.from_deme_id == deme.id and fl.to_deme_id == other.id: - already_raided = true - break - if already_raided: - continue - var forces := _compute_combat_forces(deme, warband_pop, other, state) - if ( - forces.x >= forces.y * 0.82 - or (adequacy < 0.75 and forces.x >= forces.y * 0.68) - or (deme.cultural_trait_ids.has("cul-warrior-expansion") and forces.x >= forces.y * 0.45) - ): - var ratio := forces.x / maxf(1.0, forces.y) - if ratio > best_ratio: - best_ratio = ratio - best_target_cell = ncell - best_defender = other - - if best_target_cell != null and best_defender != null: - _execute_territorial_invasion( - deme, - current_cell, - best_target_cell, - best_defender, - state, - lang, - turn_geopolitical_flows, - newly_created_demes, - initial_pop_by_deme, - pre_fission_pop_by_deme, - effective_vec_by_deme - ) - rival_dist_cache.clear() - continue - - var is_emergency := ( - current_cell.current_resources < float(deme.population) * 0.20 - and deme.energy_reserves < float(deme.population) * 0.35 - ) - var has_intentional_migration := ( - martial_trigger - or deme.last_dominant_decision_id == "dec-selective-migration" - or p_migrate >= 0.15 - or current_cell.biome == "coastal_shallows" - or is_emergency - ) - var eff_p_migrate := p_migrate - if martial_trigger: - eff_p_migrate = maxf(eff_p_migrate, 0.95) - elif is_emergency: - eff_p_migrate = maxf(eff_p_migrate, 0.35) - elif current_cell.biome == "coastal_shallows" or deme.last_dominant_decision_id == "dec-selective-migration": - eff_p_migrate = maxf(eff_p_migrate, 0.25) - var migrants := int(floor(float(deme.population) * eff_p_migrate * 0.45)) - var min_pop_for_forced_fission := 25 if (is_emergency or current_cell.biome == "coastal_shallows") else 55 - if has_intentional_migration and deme.population >= min_pop_for_forced_fission and migrants < 10: - migrants = 10 - - if not has_intentional_migration or migrants < 10 or (deme.population - migrants) < 10: - continue - - var cur_rival_dist := _min_distance_to_rival(current_cell, deme, state, rival_dist_cache) if is_martial_expander else 999 - var is_pure_martial_march := ( - martial_trigger - and deme.last_dominant_decision_id != "dec-selective-migration" - and current_cell.biome != "coastal_shallows" - and not is_emergency - ) - if is_pure_martial_march and _has_vanguard_closer_to_rival(deme, cur_rival_dist, state, rival_dist_cache): - continue - var v_eff_mobility := v_eff.spatial_mobility - var mig_mobility := clampf(v_eff_mobility + p_migrate * 0.25, -1.5, 1.5) - if is_emergency or current_cell.biome == "coastal_shallows": - mig_mobility = maxf(mig_mobility, 0.35) - if is_martial_expander: - mig_mobility = maxf(mig_mobility, 0.35) - var candidates: Array[EvoTypes.HexCell] = [] - var reinforce_candidates: Array[EvoTypes.HexCell] = [] - for nid in HexMath.get_neighbor_ids(current_cell.q, current_cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - if not is_hex_passable_for_deme(ncell, mig_mobility): - continue - if ncell.deme_ids.size() >= 3: - continue - var n_dist := _min_distance_to_rival(ncell, deme, state, rival_dist_cache) if is_martial_expander else 999 - if is_pure_martial_march and n_dist >= cur_rival_dist: - continue - if not is_emergency: - var has_compatible_kin := false - var kin_pop := 0 - for oid in ncell.deme_ids: - if state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - if other.lineage_id == deme.lineage_id and compute_deme_divergence(deme, other) <= 0.25: - has_compatible_kin = true - kin_pop += other.population - if has_compatible_kin: - if is_pure_martial_march and n_dist < cur_rival_dist and kin_pop < 75: - reinforce_candidates.append(ncell) - continue - candidates.append(ncell) - - if candidates.is_empty() and not reinforce_candidates.is_empty(): - candidates = reinforce_candidates - - if candidates.is_empty(): - continue - - var target_cell: EvoTypes.HexCell = candidates[0] - if candidates.size() > 1: - var best_score := -INF - for cand in candidates: - var sc := cand.current_resources / float(cand.deme_ids.size() + 1) - if is_martial_expander and cur_rival_dist < 999: - var dc := _min_distance_to_rival(cand, deme, state, rival_dist_cache) - if dc < cur_rival_dist: - sc += 45.0 + float(maxi(0, 10 - dc)) * 8.0 - if sc > best_score: - best_score = sc - target_cell = cand - var original_pop := deme.population - var energy_fraction := deme.energy_reserves * (float(migrants) / float(original_pop)) - var transferred_energy := snappedf(energy_fraction * 0.85, 0.01) - - deme.population -= migrants - deme.energy_reserves = maxf(0.0, snappedf(deme.energy_reserves - energy_fraction, 0.01)) - - var root_id := _strip_migration_suffix(deme.id) - var daughter_id := "%s-m%d-%d_%d" % [root_id, state.generation, target_cell.q, target_cell.r] - var dup_idx := 2 - while state.demes.has(daughter_id): - daughter_id = "%s-m%d-%d_%d-%d" % [root_id, state.generation, target_cell.q, target_cell.r, dup_idx] - dup_idx += 1 - var base_name := _strip_coord_suffix(deme.name) - var daughter_name := ("%s (%s)" % [base_name, target_cell.id]).substr(0, 60) - - var daughter := EvoTypes.Deme.new() - daughter.id = daughter_id - daughter.name = daughter_name - daughter.lineage_id = deme.lineage_id - daughter.strategy_id = deme.strategy_id - daughter.color = deme.color - daughter.hex_id = target_cell.id - daughter.population = migrants - daughter.energy_reserves = transferred_energy - daughter.last_energy_delta = 0.0 - daughter.biological_trait_ids = deme.biological_trait_ids.duplicate() - daughter.cultural_trait_ids = deme.cultural_trait_ids.duplicate() - daughter.baldwin_counters = {} - daughter.innovation_counter = 0.0 - daughter.isolation_turns = 0 - daughter.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(daughter.id) - daughter.last_confidence = deme.last_confidence - daughter.last_decision_probabilities = deme.last_decision_probabilities.duplicate() - daughter.last_dominant_decision_id = deme.last_dominant_decision_id - daughter.last_justification = deme.last_justification - if deme.has_meta("base_w_key"): - daughter.set_meta("base_w_key", deme.get_meta("base_w_key")) - daughter.set_meta("base_w_probs", deme.get_meta("base_w_probs")) - daughter.set_meta("base_w_elig", deme.get_meta("base_w_elig")) - daughter.set_meta("base_w_vec", deme.get_meta("base_w_vec")) - - state.demes[daughter_id] = daughter - newly_created_demes[daughter_id] = true - target_cell.deme_ids.append(daughter_id) - rival_dist_cache.clear() - initial_pop_by_deme[daughter_id] = migrants - pre_fission_pop_by_deme[daughter_id] = migrants - effective_vec_by_deme[daughter_id] = effective_vec_by_deme[deme.id] - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = current_cell.id - flow.to_hex_id = target_cell.id - flow.flow_type = "migration" - flow.intensity = snappedf(clampf(float(migrants) / float(original_pop), 0.05, 1.0), 0.01) - flow.from_deme_id = deme.id - flow.to_deme_id = daughter_id - state.active_flows.append(flow) - - var post_fission_ids := state.demes.keys() - for did in post_fission_ids: - if not state.demes.has(did): - continue - var deme: EvoTypes.Deme = state.demes[did] - if deme.population < 10: - if state.cells.has(deme.hex_id): - (state.cells[deme.hex_id] as EvoTypes.HexCell).deme_ids.erase(deme.id) - state.demes.erase(deme.id) - var extinct_pacts: Array[String] = [] - for pk_var in state.active_pacts.keys(): - var pk := str(pk_var) - var sep := "__" if pk.contains("__") else "|" - var parts := pk.split(sep) - if parts.size() == 2 and (parts[0] == deme.id or parts[1] == deme.id): - extinct_pacts.append(pk) - for pk in extinct_pacts: - state.active_pacts.erase(pk) - if not state.recent_extinction_hexes.has(deme.hex_id): - state.recent_extinction_hexes.append(deme.hex_id) - - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-extinct-%d-%s" % [state.generation, deme.id] - entry.generation = state.generation - entry.event_type = "extinction" - entry.title = ("%s Extinct" if is_en else "%s Extinguió") % deme.name - entry.causal_explanation = ( - "%s fell below minimum viable population (%d) in hex (%s)." % [deme.name, deme.population, deme.hex_id] - if is_en - else "%s cayó por debajo de la población mínima viable (%d) en el hexágono (%s)." % [deme.name, deme.population, deme.hex_id] - ) - entry.hex_id = deme.hex_id - entry.deme_id = deme.id - entry.lineage_id = deme.lineage_id - state.chronicle.append(entry) - - var has_relatives := false - for rem_id in state.demes.keys(): - var rem: EvoTypes.Deme = state.demes[rem_id] - if rem.lineage_id == deme.lineage_id: - has_relatives = true - break - if not has_relatives and state.lineage_tree.has(deme.lineage_id): - (state.lineage_tree[deme.lineage_id] as EvoTypes.LineageNode).extinct_generation = state.generation - continue - - var init_pop: int = int(initial_pop_by_deme[deme.id]) if initial_pop_by_deme.has(deme.id) else deme.population - var pre_fission_pop: int = int(pre_fission_pop_by_deme[deme.id]) if pre_fission_pop_by_deme.has(deme.id) else deme.population - var lost_half := float(pre_fission_pop) <= float(init_pop) * 0.50 - var small_bottleneck := pre_fission_pop < 50 and pre_fission_pop < init_pop - - if not newly_created_demes.has(deme.id) and (lost_half or small_bottleneck) and not deme.cultural_trait_ids.is_empty(): - var highest_tid := "" - var highest_thresh := -1.0 - for tid in deme.cultural_trait_ids: - var thresh: float = (state.traits_catalog[tid] as EvoTypes.TraitDef).vector.transmission_threshold if state.traits_catalog.has(tid) else 0.0 - if thresh > highest_thresh: - highest_thresh = thresh - highest_tid = tid - if highest_tid != "": - deme.cultural_trait_ids.erase(highest_tid) - var lost_name := EvoI18n.trait_name(lang, state.traits_catalog[highest_tid]) if state.traits_catalog.has(highest_tid) else highest_tid - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-culloss-%d-%s-%s" % [state.generation, deme.id, highest_tid] - entry.generation = state.generation - entry.event_type = "cultural_loss" - entry.title = ("Cultural Loss: %s" if is_en else "Pérdida Cultural: %s") % lost_name - entry.causal_explanation = ( - "%s experienced a demographic bottleneck (pop %d) and lost %s." % [deme.name, deme.population, lost_name] - if is_en - else "%s sufrió un cuello de botella demográfico (pob. %d) y perdió %s." % [deme.name, deme.population, lost_name] - ) - entry.hex_id = deme.hex_id - entry.deme_id = deme.id - entry.lineage_id = deme.lineage_id - entry.trait_id = highest_tid - state.chronicle.append(entry) - - for cid in state.cells.keys(): - var cell: EvoTypes.HexCell = state.cells[cid] - var i := 0 - while i < cell.deme_ids.size(): - var j := i + 1 - while j < cell.deme_ids.size(): - var id_a := cell.deme_ids[i] - var id_b := cell.deme_ids[j] - if state.demes.has(id_a) and state.demes.has(id_b): - var deme_a: EvoTypes.Deme = state.demes[id_a] - var deme_b: EvoTypes.Deme = state.demes[id_b] - var same_lineage_merge := deme_a.lineage_id == deme_b.lineage_id and compute_deme_divergence(deme_a, deme_b) <= 0.25 - var pact_synoecism := int(state.active_pacts.get(EvoTypes.make_pair_key(deme_a.id, deme_b.id), 0)) >= 3 - if same_lineage_merge or pact_synoecism: - _merge_deme_into( - state, - deme_a, - deme_b, - cell, - newly_created_demes, - had_lineage_contact, - pact_synoecism, - lang - ) - continue - j += 1 - i += 1 - - var pact_keys := state.active_pacts.keys() - for pk_var in pact_keys: - var pk := str(pk_var) - if not state.active_pacts.has(pk): - continue - var pact_val := int(state.active_pacts[pk]) - if pact_val < 3: - continue - var sep := "__" if pk.contains("__") else "|" - var parts := pk.split(sep) - if parts.size() != 2: - continue - var id_1 := parts[0] - var id_2 := parts[1] - if not state.demes.has(id_1) or not state.demes.has(id_2): - continue - var d1: EvoTypes.Deme = state.demes[id_1] - var d2: EvoTypes.Deme = state.demes[id_2] - if not state.cells.has(d1.hex_id) or not state.cells.has(d2.hex_id): - continue - var c1: EvoTypes.HexCell = state.cells[d1.hex_id] - var c2: EvoTypes.HexCell = state.cells[d2.hex_id] - if c1.biome == "ocean" or c1.altitude > 0.85 or c2.biome == "ocean" or c2.altitude > 0.85: - continue - var dist := HexMath.hex_distance(c1.q, c1.r, c2.q, c2.r) - if dist != 1: - continue - var has_threat_or_fort := d1.defense_tier >= 1 or d2.defense_tier >= 1 - if not has_threat_or_fort: - for c_chk in [c1, c2]: - for nid in HexMath.get_neighbor_ids(c_chk.q, c_chk.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - for oid in ncell.deme_ids: - if state.demes.has(oid): - var od: EvoTypes.Deme = state.demes[oid] - if od.id != d1.id and od.id != d2.id and od.lineage_id != d1.lineage_id and od.lineage_id != d2.lineage_id and not EvoTypes.is_pact_active(state, d1.id, od.id): - has_threat_or_fort = true - break - if pact_val < 4 and not has_threat_or_fort: - continue - var p1_coop := ( - EvoEcologyInteraction.get_archetype_prob(d1, state.decisions_catalog, "cooperate") - + EvoEcologyInteraction.get_archetype_prob(d1, state.decisions_catalog, "exchange") - ) - var p2_coop := ( - EvoEcologyInteraction.get_archetype_prob(d2, state.decisions_catalog, "cooperate") - + EvoEcologyInteraction.get_archetype_prob(d2, state.decisions_catalog, "exchange") - ) - if p1_coop < 0.30 or p2_coop < 0.30: - continue - if float(d1.population + d2.population) > maxf(c1.carrying_capacity_max, c2.carrying_capacity_max) * 1.25: - continue - var d1_hosts := ( - d1.defense_tier > d2.defense_tier - or (d1.defense_tier == d2.defense_tier and d1.population >= d2.population) - ) - var host_deme := d1 if d1_hosts else d2 - var joiner_deme := d2 if d1_hosts else d1 - var host_cell := c1 if d1_hosts else c2 - _merge_deme_into( - state, - host_deme, - joiner_deme, - host_cell, - newly_created_demes, - had_lineage_contact, - true, - lang - ) - - var conn_cache: Dictionary = {} - for did in state.demes.keys(): - var deme: EvoTypes.Deme = state.demes[did] - if not state.cells.has(deme.hex_id): - continue - if had_lineage_contact.has(deme.id): - deme.isolation_turns = 0 - continue - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] - var founder_hex_id := "" - if state.lineage_tree.has(deme.lineage_id): - founder_hex_id = (state.lineage_tree[deme.lineage_id] as EvoTypes.LineageNode).founder_hex_id - - if founder_hex_id != "" and deme.hex_id == founder_hex_id: - deme.isolation_turns = 0 - continue - - var v_eff: EvoTypes.BiophysicalVector = effective_vec_by_deme[deme.id] if effective_vec_by_deme.has(deme.id) else EvoEcologyInteraction.compute_effective_vector(deme, state) - var c_key := "%s|%s|%.2f" % [cell.id, deme.lineage_id, v_eff.spatial_mobility] - var is_conn: bool - if conn_cache.has(c_key): - is_conn = bool(conn_cache[c_key]) - else: - is_conn = _is_connected_to_founder(cell, founder_hex_id, v_eff.spatial_mobility, deme, state) - conn_cache[c_key] = is_conn - if is_conn: - deme.isolation_turns = 0 - else: - deme.isolation_turns += 1 - - var dispute_flow_pairs: Dictionary = {} - var social_flow_pairs: Dictionary = {} - var friendly_flow_pairs: Dictionary = {} - for fl in state.active_flows: - var pair_ab := "%s|%s" % [fl.from_deme_id, fl.to_deme_id] - var pair_ba := "%s|%s" % [fl.to_deme_id, fl.from_deme_id] - var hex_ab := ("%s|%s" % [fl.from_hex_id, fl.to_hex_id]) if fl.from_hex_id <= fl.to_hex_id else ("%s|%s" % [fl.to_hex_id, fl.from_hex_id]) - if fl.flow_type in ["dispute", "war", "raid", "betrayal", "siege"]: - dispute_flow_pairs[pair_ab] = true - dispute_flow_pairs[pair_ba] = true - social_flow_pairs[pair_ab] = true - social_flow_pairs[pair_ba] = true - if fl.from_hex_id != fl.to_hex_id: - dispute_flow_pairs[hex_ab] = true - social_flow_pairs[hex_ab] = true - elif fl.flow_type in ["exchange", "cooperate", "pact", "tribute", "merge"]: - social_flow_pairs[pair_ab] = true - social_flow_pairs[pair_ba] = true - friendly_flow_pairs[pair_ab] = true - friendly_flow_pairs[pair_ba] = true - if fl.from_hex_id != fl.to_hex_id: - social_flow_pairs[hex_ab] = true - - var surviving_demes: Array[EvoTypes.Deme] = [] - var surviving_cells: Array[EvoTypes.HexCell] = [] - var surv_disp: Array[float] = [] - var surv_over: Array[float] = [] - var surv_coop: Array[float] = [] - for did in state.demes.keys(): - var d: EvoTypes.Deme = state.demes[did] - if not state.cells.has(d.hex_id): - continue - surviving_demes.append(d) - surviving_cells.append(state.cells[d.hex_id]) - var pd: float = 0.0 - var po: float = 0.0 - var pc: float = 0.0 - for dec_id in d.last_decision_probabilities.keys(): - if state.decisions_catalog.has(dec_id): - var arch := (state.decisions_catalog[dec_id] as EvoTypes.DecisionDef).archetype - var prob := float(d.last_decision_probabilities[dec_id]) - if arch == "dispute": - pd += prob - elif arch == "overexploit": - po += prob - elif arch == "cooperate": - pc += prob - surv_disp.append(pd) - surv_over.append(po) - surv_coop.append(pc) - - var s_count := surviving_demes.size() - for idx_a in range(s_count): - var deme_a := surviving_demes[idx_a] - var id_a := deme_a.id - var cell_a := surviving_cells[idx_a] - var qa := cell_a.q - var ra := cell_a.r - var bad_a := cell_a.altitude > 0.85 or cell_a.biome == "ocean" - var p_disp_a := surv_disp[idx_a] - var p_over_a := surv_over[idx_a] - var p_coop_a := surv_coop[idx_a] - var coop_ok_a := p_coop_a >= 0.14 or deme_a.last_dominant_decision_id == "dec-cooperate-store" - - for idx_b in range(idx_a + 1, s_count): - var cell_b := surviving_cells[idx_b] - var dq := qa - cell_b.q - var dr := ra - cell_b.r - if absi(dq) > 1 or absi(dr) > 1 or absi(dq + dr) > 1: - continue - var same_hex := dq == 0 and dr == 0 - if not same_hex and (bad_a or cell_b.altitude > 0.85 or cell_b.biome == "ocean"): - continue - - var deme_b := surviving_demes[idx_b] - var id_b := deme_b.id - var p_disp_b := surv_disp[idx_b] - var p_over_b := surv_over[idx_b] - var p_coop_b := surv_coop[idx_b] - var coop_ok_b := p_coop_b >= 0.14 or deme_b.last_dominant_decision_id == "dec-cooperate-store" - - var pair_key := "%s|%s" % [id_a, id_b] - var pact_key := ("%s__%s" % [id_a, id_b]) if id_a < id_b else ("%s__%s" % [id_b, id_a]) - var is_rival := deme_a.lineage_id != deme_b.lineage_id and int(state.active_pacts.get(pact_key, 0)) < 2 - var has_conflict_pressure := ( - (p_disp_a >= 0.12 or p_disp_b >= 0.12 or p_over_a >= 0.35 or p_over_b >= 0.35) - if same_hex - else (p_disp_a >= 0.25 or p_disp_b >= 0.25) - ) - if is_rival and has_conflict_pressure: - if not dispute_flow_pairs.has(pair_key): - dispute_flow_pairs[pair_key] = true - social_flow_pairs[pair_key] = true - var hex_pair_key := ("%s|%s" % [cell_a.id, cell_b.id]) if cell_a.id <= cell_b.id else ("%s|%s" % [cell_b.id, cell_a.id]) - if same_hex or not dispute_flow_pairs.has(hex_pair_key): - if not same_hex: - dispute_flow_pairs[hex_pair_key] = true - var disp_flow := EvoTypes.HexEdgeFlow.new() - disp_flow.from_hex_id = cell_a.id - disp_flow.to_hex_id = cell_b.id - disp_flow.flow_type = "dispute" - disp_flow.intensity = snappedf( - clampf(maxf(maxf(p_disp_a, p_disp_b), (maxf(p_over_a, p_over_b) * 0.85) if same_hex else 0.0), 0.35, 0.90), - 0.01 - ) - disp_flow.from_deme_id = id_a - disp_flow.to_deme_id = id_b - state.active_flows.append(disp_flow) - elif coop_ok_a and coop_ok_b: - if not social_flow_pairs.has(pair_key): - social_flow_pairs[pair_key] = true - friendly_flow_pairs[pair_key] = true - friendly_flow_pairs["%s|%s" % [id_b, id_a]] = true - var hex_pair_key := ("%s|%s" % [cell_a.id, cell_b.id]) if cell_a.id <= cell_b.id else ("%s|%s" % [cell_b.id, cell_a.id]) - if same_hex or not social_flow_pairs.has(hex_pair_key): - if not same_hex: - social_flow_pairs[hex_pair_key] = true - var coop_flow := EvoTypes.HexEdgeFlow.new() - coop_flow.from_hex_id = cell_a.id - coop_flow.to_hex_id = cell_b.id - coop_flow.flow_type = "cooperate" - coop_flow.intensity = snappedf(clampf((p_coop_a + p_coop_b) * 0.85, 0.30, 0.85), 0.01) - coop_flow.from_deme_id = id_a - coop_flow.to_deme_id = id_b - state.active_flows.append(coop_flow) - - var filtered_disused: Array[Dictionary] = [] - for conn in state.disused_connections: - var fa := str(conn["from_deme_id"]) if conn.has("from_deme_id") else "" - var fb := str(conn["to_deme_id"]) if conn.has("to_deme_id") else "" - if fa == fb or not state.demes.has(fa) or not state.demes.has(fb): - continue - if not friendly_flow_pairs.has("%s|%s" % [fa, fb]): - var conn_age := int(conn.get("age", 0)) - if conn_age <= 0: - conn_age = 1 - conn["age"] = 1 - if conn_age <= 8: - filtered_disused.append(conn) - state.disused_connections = filtered_disused - - -static func _merge_deme_into( - state: EvoTypes.WorldState, - deme_a: EvoTypes.Deme, - deme_b: EvoTypes.Deme, - cell_a: EvoTypes.HexCell, - newly_created_demes: Dictionary, - had_lineage_contact: Dictionary, - is_synoecism: bool, - lang: String -) -> void: - var old_name_a := deme_a.name - var old_name_b := deme_b.name - var from_hex_id := deme_b.hex_id - var total_pop := deme_a.population + deme_b.population - var a_new := newly_created_demes.has(deme_a.id) - var b_new := newly_created_demes.has(deme_b.id) - var w_a := ( - 1.0 - if (b_new and not a_new) - else (0.0 if (a_new and not b_new) else float(deme_a.population) / float(maxi(1, total_pop))) - ) - var w_b := 1.0 - w_a - - deme_a.innovation_counter = minf( - 2.0, - snappedf(deme_a.innovation_counter * w_a + deme_b.innovation_counter * w_b, 0.0001) - ) - - var all_keys: Dictionary = {} - for k in deme_a.baldwin_counters.keys(): - all_keys[k] = true - for k in deme_b.baldwin_counters.keys(): - all_keys[k] = true - var merged_counters: Dictionary = {} - for k in all_keys.keys(): - var ca: int = int(deme_a.baldwin_counters[k]) if deme_a.baldwin_counters.has(k) else 0 - var cb: int = int(deme_b.baldwin_counters[k]) if deme_b.baldwin_counters.has(k) else 0 - merged_counters[k] = int(roundf(float(ca) * w_a + float(cb) * w_b)) - deme_a.baldwin_counters = merged_counters - - deme_a.population = total_pop - deme_a.energy_reserves = minf(280.0, snappedf(deme_a.energy_reserves + deme_b.energy_reserves, 0.01)) - for tid in deme_b.biological_trait_ids: - if EvoEcologyInteraction._can_acquire_trait(deme_a, tid, state.traits_catalog): - deme_a.biological_trait_ids.append(tid) - for tid in deme_b.cultural_trait_ids: - if EvoEcologyInteraction._can_acquire_trait(deme_a, tid, state.traits_catalog): - deme_a.cultural_trait_ids.append(tid) - - if state.lineage_tree.has(deme_a.lineage_id): - var lin_node: EvoTypes.LineageNode = state.lineage_tree[deme_a.lineage_id] - for tid in deme_a.biological_trait_ids: - if not lin_node.biological_trait_ids.has(tid): - lin_node.biological_trait_ids.append(tid) - for tid in deme_a.cultural_trait_ids: - if not lin_node.cultural_trait_ids.has(tid): - lin_node.cultural_trait_ids.append(tid) - - var combined_tier := maxi(deme_a.defense_tier, deme_b.defense_tier) - if is_synoecism and deme_a.population >= 90 and combined_tier < 3: - combined_tier = mini(3, maxi(2, combined_tier + 1)) - deme_a.defense_tier = combined_tier - - if is_synoecism: - deme_a.merged_count += 1 + deme_b.merged_count - if deme_b.color != "" and deme_b.color != deme_a.color: - deme_a.secondary_color = deme_b.color - elif deme_a.secondary_color == "": - deme_a.secondary_color = deme_b.secondary_color if deme_b.secondary_color != "" else deme_b.color - cell_a.carrying_capacity = minf(cell_a.carrying_capacity_max * 1.35, cell_a.carrying_capacity + 35.0) - var merge_params := { - "deme_a": old_name_a, - "deme_b": old_name_b, - "hex": cell_a.id, - "pop": str(deme_a.population) - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-merge-%d-%s-%s" % [state.generation, deme_a.id, deme_b.id] - entry.generation = state.generation - entry.event_type = "merge" - entry.title = EvoI18n.t(lang, "chronicle_merge_title", merge_params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_merge_desc", merge_params) - entry.hex_id = cell_a.id - entry.deme_id = deme_a.id - entry.lineage_id = deme_a.lineage_id - state.chronicle.append(entry) - - var kept_flows: Array[EvoTypes.HexEdgeFlow] = [] - for fl in state.active_flows: - if fl.to_deme_id == deme_b.id: - fl.to_deme_id = deme_a.id - if fl.from_deme_id == deme_b.id: - fl.from_deme_id = deme_a.id - if fl.from_deme_id == fl.to_deme_id and fl.from_hex_id == fl.to_hex_id and not (fl.flow_type in ["migration", "merge"]): - continue - kept_flows.append(fl) - state.active_flows = kept_flows - - if not state.active_pacts.is_empty(): - var pacts_to_erase: Array[String] = [] - var pacts_to_set: Dictionary = {} - for pk_var in state.active_pacts.keys(): - var pk := str(pk_var) - if not pk.contains(deme_b.id): - continue - var sep := "__" if pk.contains("__") else "|" - var parts := pk.split(sep) - if parts.size() == 2 and (parts[0] == deme_b.id or parts[1] == deme_b.id): - pacts_to_erase.append(pk) - var partner_id := parts[1] if parts[0] == deme_b.id else parts[0] - if partner_id != deme_a.id and not b_new: - var new_pk := EvoTypes.make_pair_key(deme_a.id, partner_id) - var existing_val: int = int(state.active_pacts[new_pk]) if state.active_pacts.has(new_pk) else 0 - var b_val: int = int(state.active_pacts[pk]) - pacts_to_set[new_pk] = maxi(existing_val, b_val) - for pk in pacts_to_erase: - state.active_pacts.erase(pk) - for new_pk in pacts_to_set.keys(): - state.active_pacts[new_pk] = pacts_to_set[new_pk] - - if not b_new and not state.disused_connections.is_empty(): - var kept_disused: Array[Dictionary] = [] - for dc in state.disused_connections: - if str(dc.get("from_deme_id", "")) == deme_b.id: - dc["from_deme_id"] = deme_a.id - if str(dc.get("to_deme_id", "")) == deme_b.id: - dc["to_deme_id"] = deme_a.id - if str(dc.get("from_deme_id", "")) == str(dc.get("to_deme_id", "")): - continue - kept_disused.append(dc) - state.disused_connections = kept_disused - - var merge_flow := EvoTypes.HexEdgeFlow.new() - merge_flow.from_hex_id = from_hex_id - merge_flow.to_hex_id = cell_a.id - merge_flow.flow_type = "merge" - merge_flow.intensity = 0.85 if is_synoecism else 0.65 - merge_flow.from_deme_id = deme_b.id - merge_flow.to_deme_id = deme_a.id - state.active_flows.append(merge_flow) - - if state.cells.has(from_hex_id): - (state.cells[from_hex_id] as EvoTypes.HexCell).deme_ids.erase(deme_b.id) - cell_a.deme_ids.erase(deme_b.id) - state.demes.erase(deme_b.id) - had_lineage_contact[deme_a.id] = true diff --git a/src/engine/biophys/demography_migration.gd.uid b/src/engine/biophys/demography_migration.gd.uid deleted file mode 100644 index 19756d6..0000000 --- a/src/engine/biophys/demography_migration.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://duth56obk5t5g diff --git a/src/engine/biophys/diplomacy_phase.gd b/src/engine/biophys/diplomacy_phase.gd new file mode 100644 index 0000000..f4fbc29 --- /dev/null +++ b/src/engine/biophys/diplomacy_phase.gd @@ -0,0 +1,137 @@ +class_name EvoDiplomacyPhase +extends RefCounted + +## Resolves peaceful interactions chosen this turn: aid, pacts, tribute and unions. + + +static func adjacent_target(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> EvoTypes.Deme: + var target_id := str(deme.chosen_action["target"]) + if not state.demes.has(target_id): + return null + var other: EvoTypes.Deme = state.demes[target_id] + var a: EvoTypes.HexCell = state.cells[deme.hex_id] + var b: EvoTypes.HexCell = state.cells[other.hex_id] + return other if HexMath.hex_distance(a.q, a.r, b.q, b.r) == 1 else null + + +static func resolve(state: EvoTypes.WorldState, lang: String) -> void: + for did in state.demes.keys(): + if not state.demes.has(did): + continue + var deme: EvoTypes.Deme = state.demes[did] + var action_type := str(deme.chosen_action["type"]) + if action_type not in ["aid", "offer_pact", "accept_pact", "demand_tribute", "pay_tribute", "unite", "accept_union"]: + continue + var other := adjacent_target(deme, state) + if other == null: + deme.last_outcome = "Your %s could not happen: the target is no longer next to you." % action_type.replace("_", " ") + continue + match action_type: + "aid": + _aid(deme, other, state) + "offer_pact": + _offer(deme, other, state, "pact", lang) + "accept_pact": + if EvoRelations.has_open_proposal(state, other.id, deme.id, "pact"): + _form_pact(deme, other, state, lang) + "demand_tribute": + _demand_tribute(deme, other, state) + "pay_tribute": + if EvoRelations.has_open_proposal(state, other.id, deme.id, "tribute"): + _pay_tribute(deme, other, state, lang) + "unite": + _offer(deme, other, state, "union", lang) + "accept_union": + if EvoRelations.has_open_proposal(state, other.id, deme.id, "union"): + _accept_union(deme, other, state, lang) + _emit_pact_flows(state) + + +static func _aid(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState) -> void: + var amount := minf(EvoPayoffs.aid_amount(deme), deme.energy_reserves) + deme.energy_reserves -= amount + other.energy_reserves += amount + var rel := EvoRelations.record(state, deme, other, 0.25, "%s gave %d food to %s" % [deme.name, int(amount), other.name]) + EvoRelations.adjust_reputation(deme, 0.05) + deme.last_outcome = "You gave %d food to %s." % [int(amount), other.name] + EvoRelations.remember(other, state, "%s gave you %d food." % [deme.name, int(amount)]) + EvoSettlementPhase.push_flow(state, deme, other, "cooperate", 0.6) + if rel.proposal_from == other.id and rel.proposal_kind == "pact": + _form_pact(deme, other, state, "en") + + +static func _offer(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState, kind: String, lang: String) -> void: + var rel := EvoTypes.get_relation(state, deme.id, other.id) + if rel.feud_turns > 0: + deme.last_outcome = "%s refuses to talk while the feud lasts." % other.name + return + var mutual := rel.proposal_from == other.id and rel.proposal_kind == kind + if mutual and kind == "pact": + _form_pact(deme, other, state, lang) + return + if mutual and kind == "union": + _accept_union(deme, other, state, lang) + return + if kind == "pact" and (other.lineage_id == deme.lineage_id or rel.trust >= 0.30): + _form_pact(deme, other, state, lang) + return + EvoRelations.record(state, deme, other, 0.10, "%s proposed %s to %s" % [deme.name, kind, other.name]) + EvoRelations.propose(state, deme, other, kind, 0.0) + deme.last_outcome = "You proposed %s to %s; they will answer next turn." % ["an alliance" if kind == "pact" else "a union", other.name] + + +static func _form_pact(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState, lang: String) -> void: + var rel := EvoRelations.record(state, deme, other, 0.2, "%s and %s sealed an alliance" % [deme.name, other.name]) + rel.pact_turns = 1 + EvoRelations.clear_proposal(rel) + deme.last_outcome = "You are now allied with %s." % other.name + EvoRelations.remember(other, state, "%s accepted your alliance." % deme.name) + EvoTypes.push_event(state, "pact", deme.hex_id, other.hex_id, deme.id, other.id, 1.0, 0) + var params := {"deme_a": deme.name, "deme_b": other.name, "hex": "%s <-> %s" % [deme.hex_id, other.hex_id]} + EvoSettlementPhase.chronicle(state, "chronicle-pact-%d-%s" % [state.generation, EvoTypes.make_pair_key(deme.id, other.id)], "pact", lang, "chronicle_pact", params, deme.hex_id, deme) + + +static func _demand_tribute(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState) -> void: + var rel := EvoTypes.get_relation(state, deme.id, other.id) + if rel.pact_turns > 0 or rel.truce_turns > 0: + deme.last_outcome = "You cannot demand tribute from %s now." % other.name + return + var amount := EvoPayoffs.tribute_amount(other) + EvoRelations.propose(state, deme, other, "tribute", amount) + deme.last_outcome = "You demanded %d food from %s; they answer next turn." % [int(amount), other.name] + + +static func _pay_tribute(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState, lang: String) -> void: + var rel := EvoRelations.record(state, deme, other, 0.05, "%s paid tribute to %s" % [deme.name, other.name]) + var amount := minf(rel.proposal_amount, deme.energy_reserves) + deme.energy_reserves -= amount + other.energy_reserves += amount + rel.truce_turns = EvoPayoffs.TRUCE_AFTER_TRIBUTE + EvoRelations.clear_proposal(rel) + deme.last_outcome = "You paid %d food to %s for a %d-turn truce." % [int(amount), other.name, EvoPayoffs.TRUCE_AFTER_TRIBUTE] + EvoRelations.remember(other, state, "%s paid you %d food in tribute." % [deme.name, int(amount)]) + EvoSettlementPhase.push_flow(state, deme, other, "tribute", 0.75) + EvoTypes.push_event(state, "tribute", deme.hex_id, other.hex_id, deme.id, other.id, amount, 0) + var params := {"raider": other.name, "victim": deme.name, "loot": str(int(amount)), "hex": deme.hex_id} + EvoSettlementPhase.chronicle(state, "chronicle-tribute-%d-%s-%s" % [state.generation, deme.id, other.id], "tribute", lang, "chronicle_tribute", params, deme.hex_id, deme) + + +static func _accept_union(deme: EvoTypes.Deme, other: EvoTypes.Deme, state: EvoTypes.WorldState, lang: String) -> void: + EvoRelations.clear_proposal(EvoTypes.get_relation(state, deme.id, other.id)) + deme.last_outcome = "You united with %s." % other.name + EvoRelations.remember(other, state, "%s accepted your union." % deme.name) + EvoSettlementPhase.unite(deme, other, state, lang) + + +static func _emit_pact_flows(state: EvoTypes.WorldState) -> void: + for pk in state.relations.keys(): + var rel: EvoTypes.Relation = state.relations[pk] + if rel.pact_turns <= 0: + continue + var ids := str(pk).split("__") + var a: EvoTypes.Deme = state.demes[ids[0]] + var b: EvoTypes.Deme = state.demes[ids[1]] + var ca: EvoTypes.HexCell = state.cells[a.hex_id] + var cb: EvoTypes.HexCell = state.cells[b.hex_id] + if HexMath.hex_distance(ca.q, ca.r, cb.q, cb.r) == 1: + EvoSettlementPhase.push_flow(state, a, b, "pact", 0.8) diff --git a/src/engine/biophys/diplomacy_phase.gd.uid b/src/engine/biophys/diplomacy_phase.gd.uid new file mode 100644 index 0000000..d3e3ebe --- /dev/null +++ b/src/engine/biophys/diplomacy_phase.gd.uid @@ -0,0 +1 @@ +uid://taksg1v4igxq diff --git a/src/engine/biophys/ecology_interaction.gd b/src/engine/biophys/ecology_interaction.gd deleted file mode 100644 index 0e51865..0000000 --- a/src/engine/biophys/ecology_interaction.gd +++ /dev/null @@ -1,1107 +0,0 @@ -class_name EvoEcologyInteraction -extends RefCounted - -const DEFENSE_TIER_BONUS: Array[float] = [0.0, 0.45, 0.95, 1.55] - - -static func compute_effective_vector( - deme: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> EvoTypes.BiophysicalVector: - var cost: float = 0.0 - var extract: float = 0.0 - var eco: float = 0.0 - var mobility: float = 0.0 - var cohesion: float = 0.0 - var threshold: float = 0.0 - - for tid in deme.biological_trait_ids: - if state.traits_catalog.has(tid): - var v := (state.traits_catalog[tid] as EvoTypes.TraitDef).vector - cost += v.metabolic_cost - extract += v.extraction_efficiency - eco += v.ecological_impact - mobility += v.spatial_mobility - cohesion += v.social_cohesion - threshold += v.transmission_threshold - - for tid in deme.cultural_trait_ids: - if state.traits_catalog.has(tid): - var v := (state.traits_catalog[tid] as EvoTypes.TraitDef).vector - cost += v.metabolic_cost - extract += v.extraction_efficiency - eco += v.ecological_impact - mobility += v.spatial_mobility - cohesion += v.social_cohesion - threshold += v.transmission_threshold - - for dec_id in deme.last_decision_probabilities.keys(): - var prob: float = float(deme.last_decision_probabilities[dec_id]) - if prob > 0.0 and state.decisions_catalog.has(dec_id): - var v := (state.decisions_catalog[dec_id] as EvoTypes.DecisionDef).vector - cost += prob * v.metabolic_cost - extract += prob * v.extraction_efficiency - eco += prob * v.ecological_impact - mobility += prob * v.spatial_mobility - cohesion += prob * v.social_cohesion - threshold += prob * v.transmission_threshold - - return EvoTypes.BiophysicalVector.new( - snappedf(clampf(cost, -1.5, 1.5), 0.0001), - snappedf(clampf(extract, -1.5, 1.5), 0.0001), - snappedf(clampf(eco, -1.5, 1.5), 0.0001), - snappedf(clampf(mobility, -1.5, 1.5), 0.0001), - snappedf(clampf(cohesion, -1.5, 1.5), 0.0001), - snappedf(clampf(threshold, 0.0, 1.5), 0.0001) - ) - - -static func get_archetype_prob( - deme: EvoTypes.Deme, - decisions_catalog: Dictionary, - archetype: String -) -> float: - var total: float = 0.0 - for dec_id in deme.last_decision_probabilities.keys(): - if decisions_catalog.has(dec_id): - var dec: EvoTypes.DecisionDef = decisions_catalog[dec_id] - if dec.archetype == archetype: - total += float(deme.last_decision_probabilities[dec_id]) - return total - - -static func _can_acquire_trait( - deme: EvoTypes.Deme, - trait_id: String, - traits_catalog: Dictionary -) -> bool: - if deme.cultural_trait_ids.has(trait_id) or deme.biological_trait_ids.has(trait_id): - return false - if not traits_catalog.has(trait_id): - return false - var tdef: EvoTypes.TraitDef = traits_catalog[trait_id] - for existing_id in deme.biological_trait_ids: - if tdef.mutually_exclusive_with.has(existing_id): - return false - if traits_catalog.has(existing_id) and (traits_catalog[existing_id] as EvoTypes.TraitDef).mutually_exclusive_with.has(trait_id): - return false - for existing_id in deme.cultural_trait_ids: - if tdef.mutually_exclusive_with.has(existing_id): - return false - if traits_catalog.has(existing_id) and (traits_catalog[existing_id] as EvoTypes.TraitDef).mutually_exclusive_with.has(trait_id): - return false - return true - - -static func compute_pact_defense_bonus( - victim: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> float: - if state.active_pacts.is_empty() or not state.cells.has(victim.hex_id): - return 0.0 - var v_cell: EvoTypes.HexCell = state.cells[victim.hex_id] - var bonus: float = 0.0 - for ally_id in v_cell.deme_ids: - if ally_id != victim.id and state.demes.has(ally_id) and EvoTypes.is_pact_active(state, victim.id, ally_id): - var ally: EvoTypes.Deme = state.demes[ally_id] - bonus += 0.25 * clampf(float(ally.population) / maxf(20.0, float(victim.population)), 0.4, 1.5) + 0.08 * float(ally.defense_tier) - for nid in HexMath.get_neighbor_ids(v_cell.q, v_cell.r, state.cells): - var a_cell: EvoTypes.HexCell = state.cells[nid] - for ally_id in a_cell.deme_ids: - if ally_id != victim.id and state.demes.has(ally_id) and EvoTypes.is_pact_active(state, victim.id, ally_id): - var ally: EvoTypes.Deme = state.demes[ally_id] - bonus += 0.25 * clampf(float(ally.population) / maxf(20.0, float(victim.population)), 0.4, 1.5) + 0.08 * float(ally.defense_tier) - return clampf(bonus, 0.0, 1.10) - - -static func _compute_caloric_need( - deme: EvoTypes.Deme, - v_eff: EvoTypes.BiophysicalVector -) -> float: - return maxf(1.0, float(deme.population) * 0.42 * (1.0 + 0.55 * v_eff.metabolic_cost)) - - -static func _is_deme_stressed_for_betrayal( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState, - v_eff: EvoTypes.BiophysicalVector, - extracted: float -) -> bool: - var needed := _compute_caloric_need(deme, v_eff) - var adequacy := extracted / needed - var has_resource_deficit := ( - adequacy < 0.98 - or deme.energy_reserves < 45.0 - or cell.carrying_capacity < cell.carrying_capacity_max * 0.72 - ) - if state.season == "dry_cold": - return has_resource_deficit or deme.energy_reserves < 60.0 - return adequacy < 0.85 or deme.energy_reserves < 35.0 - - -static func _is_opportunistic_betrayer( - deme: EvoTypes.Deme, - v_eff: EvoTypes.BiophysicalVector, - p_coop: float, - p_exch: float, - p_disp: float, - p_over: float, - formal_pact_active: bool, - victim_defense_tier: int = 0 -) -> bool: - if not formal_pact_active: - return false - if victim_defense_tier >= 2 and p_disp + p_over <= p_coop + p_exch + 0.18: - return false - var has_coop_culture := ( - deme.cultural_trait_ids.has("cul-communal-conservation") - or deme.cultural_trait_ids.has("cul-interdeme-barter") - ) - if p_disp + p_over > p_coop + p_exch + 0.08 and (v_eff.social_cohesion < 0.55 or not has_coop_culture): - return true - var has_aggressive_cul := ( - deme.cultural_trait_ids.has("cul-warrior-expansion") - or deme.cultural_trait_ids.has("cul-extraction-tools") - ) - if has_aggressive_cul and not has_coop_culture and p_disp + p_over >= p_coop + p_exch: - return true - return false - - -static func _execute_betrayal( - betrayer: EvoTypes.Deme, - victim: EvoTypes.Deme, - c_betrayer: EvoTypes.HexCell, - c_victim: EvoTypes.HexCell, - pair_key: String, - state: EvoTypes.WorldState, - lang: String, - extracted_energy_by_deme: Dictionary, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow] -) -> void: - var ext_victim := float(extracted_energy_by_deme.get(victim.id, 0.0)) - var desired_loot := snappedf(clampf(victim.energy_reserves * 0.30 + ext_victim * 0.25, 10.0, 45.0), 0.01) - var from_reserves := minf(victim.energy_reserves, desired_loot * 0.70) - var from_ext := minf(ext_victim, desired_loot - from_reserves) - if from_reserves + from_ext < desired_loot: - from_reserves = minf(victim.energy_reserves, from_reserves + (desired_loot - (from_reserves + from_ext))) - var loot := snappedf(from_reserves + from_ext, 0.01) - victim.energy_reserves = maxf(0.0, snappedf(victim.energy_reserves - from_reserves, 0.01)) - extracted_energy_by_deme[victim.id] = maxf(0.0, snappedf(ext_victim - from_ext, 0.01)) - betrayer.energy_reserves = minf(220.0, snappedf(betrayer.energy_reserves + loot, 0.01)) - - var casualties := maxi(3, int(round(float(victim.population) * 0.14))) - victim.population = maxi(5, victim.population - casualties) - - state.active_pacts[pair_key] = -4 - - if c_betrayer.id != c_victim.id: - var updated_existing := false - for conn in state.disused_connections: - var ca := str(conn.get("from_deme_id", "")) - var cb := str(conn.get("to_deme_id", "")) - if (ca == betrayer.id and cb == victim.id) or (ca == victim.id and cb == betrayer.id): - conn["from_hex_id"] = c_betrayer.id - conn["to_hex_id"] = c_victim.id - conn["age"] = 1 - conn["broken_gen"] = state.generation - updated_existing = true - break - if not updated_existing: - state.disused_connections.append({ - "from_hex_id": c_betrayer.id, - "to_hex_id": c_victim.id, - "from_deme_id": betrayer.id, - "to_deme_id": victim.id, - "age": 1, - "broken_gen": state.generation - }) - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = c_betrayer.id - flow.to_hex_id = c_victim.id - flow.flow_type = "betrayal" - flow.intensity = 1.0 - flow.from_deme_id = betrayer.id - flow.to_deme_id = victim.id - state.active_flows.append(flow) - turn_geopolitical_flows.append(flow) - - var params := { - "betrayer": betrayer.name, - "victim": victim.name, - "loot": str(int(round(loot))), - "hex": c_victim.id - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-betrayal-%d-%s-%s" % [state.generation, betrayer.id, victim.id] - entry.generation = state.generation - entry.event_type = "betrayal" - entry.title = EvoI18n.t(lang, "chronicle_betrayal_title", params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_betrayal_desc", params) - entry.hex_id = c_victim.id - entry.deme_id = betrayer.id - entry.lineage_id = betrayer.lineage_id - state.chronicle.append(entry) - - -static func _run_pacts_and_betrayals( - state: EvoTypes.WorldState, - lang: String, - effective_vec_by_deme: Dictionary, - extracted_energy_by_deme: Dictionary, - symbiosis_bonus_applied: Dictionary, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow], - betrayed_pairs_this_turn: Dictionary, - arch_probs: Dictionary, - adjacent_pairs: Array, - existing_flows_by_key: Dictionary -) -> void: - var has_warrior: Dictionary = {} - for did in state.demes.keys(): - var d: EvoTypes.Deme = state.demes[did] - has_warrior[d.id] = d.cultural_trait_ids.has("cul-warrior-expansion") - - var warrior_threat_by_deme: Dictionary = {} - for pair in adjacent_pairs: - var da: EvoTypes.Deme = pair[0] - var db: EvoTypes.Deme = pair[2] - if da.lineage_id != db.lineage_id and not EvoTypes.is_pact_active(state, da.id, db.id): - if bool(has_warrior[da.id]): - warrior_threat_by_deme[db.id] = true - if bool(has_warrior[db.id]): - warrior_threat_by_deme[da.id] = true - - var logged_pact_hex_pairs: Dictionary = {} - - for pair in adjacent_pairs: - var d1: EvoTypes.Deme = pair[0] - var c1: EvoTypes.HexCell = pair[1] - var d2: EvoTypes.Deme = pair[2] - var c2: EvoTypes.HexCell = pair[3] - var pair_key: String = pair[4] - - var pact_turns := int(state.active_pacts.get(pair_key, 0)) - if pact_turns < 0: - pact_turns += 1 - if pact_turns == 0: - state.active_pacts.erase(pair_key) - else: - state.active_pacts[pair_key] = pact_turns - continue - - var ap1: Array = arch_probs[d1.id] - var p_coop_1: float = float(ap1[0]) - var p_exch_1: float = float(ap1[1]) - var p_disp_1: float = float(ap1[2]) - var p_over_1: float = float(ap1[3]) - - var formal_pact := pact_turns >= 2 - - var v1: EvoTypes.BiophysicalVector = effective_vec_by_deme[d1.id] - var v2: EvoTypes.BiophysicalVector = effective_vec_by_deme[d2.id] - var ext_1 := float(extracted_energy_by_deme.get(d1.id, 0.0)) - var ext_2 := float(extracted_energy_by_deme.get(d2.id, 0.0)) - - var ap2: Array = arch_probs[d2.id] - var p_coop_2: float = float(ap2[0]) - var p_exch_2: float = float(ap2[1]) - var p_disp_2: float = float(ap2[2]) - var p_over_2: float = float(ap2[3]) - - if formal_pact: - var d1_betrays := ( - _is_deme_stressed_for_betrayal(d1, c1, state, v1, ext_1) - and _is_opportunistic_betrayer(d1, v1, p_coop_1, p_exch_1, p_disp_1, p_over_1, formal_pact, d2.defense_tier) - ) - var d2_betrays := ( - _is_deme_stressed_for_betrayal(d2, c2, state, v2, ext_2) - and _is_opportunistic_betrayer(d2, v2, p_coop_2, p_exch_2, p_disp_2, p_over_2, formal_pact, d1.defense_tier) - ) - if d1_betrays or d2_betrays: - betrayed_pairs_this_turn[pair_key] = true - if d1_betrays: - _execute_betrayal(d1, d2, c1, c2, pair_key, state, lang, extracted_energy_by_deme, turn_geopolitical_flows) - else: - _execute_betrayal(d2, d1, c2, c1, pair_key, state, lang, extracted_energy_by_deme, turn_geopolitical_flows) - continue - - var both_strong_coop := ( - p_coop_1 + p_exch_1 >= 0.36 and p_disp_1 < 0.18 - and p_coop_2 + p_exch_2 >= 0.36 and p_disp_2 < 0.18 - ) - var has_warrior_threat := bool(warrior_threat_by_deme.get(d1.id, false)) or bool(warrior_threat_by_deme.get(d2.id, false)) - var coop_thresh := 0.22 if has_warrior_threat else 0.30 - var coop_1: bool = p_coop_1 + p_exch_1 >= coop_thresh and p_disp_1 < 0.22 and (not bool(has_warrior[d1.id]) or both_strong_coop) - var coop_2: bool = p_coop_2 + p_exch_2 >= coop_thresh and p_disp_2 < 0.22 and (not bool(has_warrior[d2.id]) or both_strong_coop) - if coop_1 and coop_2: - var next_turns := pact_turns + 1 - state.active_pacts[pair_key] = next_turns - var hex_pair_key := ("%s|%s" % [c1.id, c2.id]) if c1.id <= c2.id else ("%s|%s" % [c2.id, c1.id]) - if next_turns == 2 and not logged_pact_hex_pairs.has(hex_pair_key): - logged_pact_hex_pairs[hex_pair_key] = true - var hex_label := c1.id if c1.id == c2.id else "%s <-> %s" % [c1.id, c2.id] - var params := { - "deme_a": d1.name, - "deme_b": d2.name, - "hex": hex_label - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-pact-%d-%s" % [state.generation, pair_key] - entry.generation = state.generation - entry.event_type = "pact" - entry.title = EvoI18n.t(lang, "chronicle_pact_title", params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_pact_desc", params) - entry.hex_id = c1.id - entry.deme_id = d1.id - entry.lineage_id = d1.lineage_id - state.chronicle.append(entry) - - if next_turns >= 2: - if not symbiosis_bonus_applied.has(d1.id): - extracted_energy_by_deme[d1.id] = snappedf(ext_1 * 1.15, 0.0001) - symbiosis_bonus_applied[d1.id] = true - if not symbiosis_bonus_applied.has(d2.id): - extracted_energy_by_deme[d2.id] = snappedf(ext_2 * 1.15, 0.0001) - symbiosis_bonus_applied[d2.id] = true - - var need_1 := _compute_caloric_need(d1, v1) - var need_2 := _compute_caloric_need(d2, v2) - var d1_distress := d1.energy_reserves < 35.0 or float(extracted_energy_by_deme[d1.id]) < need_1 - var d2_distress := d2.energy_reserves < 35.0 or float(extracted_energy_by_deme[d2.id]) < need_2 - - if d1_distress and not d2_distress and d2.energy_reserves > 60.0: - var transfer := snappedf(minf(14.0, (d2.energy_reserves - 50.0) * 0.25), 0.01) - d2.energy_reserves = maxf(0.0, snappedf(d2.energy_reserves - transfer, 0.01)) - d1.energy_reserves = snappedf(d1.energy_reserves + transfer, 0.01) - elif d2_distress and not d1_distress and d1.energy_reserves > 60.0: - var transfer := snappedf(minf(14.0, (d1.energy_reserves - 50.0) * 0.25), 0.01) - d1.energy_reserves = maxf(0.0, snappedf(d1.energy_reserves - transfer, 0.01)) - d2.energy_reserves = snappedf(d2.energy_reserves + transfer, 0.01) - - var flow_key := pair_key if c1.id == c2.id else hex_pair_key - if existing_flows_by_key.has(flow_key): - var ex_flow: EvoTypes.HexEdgeFlow = existing_flows_by_key[flow_key] - ex_flow.flow_type = "pact" - ex_flow.intensity = 0.80 - else: - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = c1.id - flow.to_hex_id = c2.id - flow.flow_type = "pact" - flow.intensity = 0.80 - flow.from_deme_id = d1.id - flow.to_deme_id = d2.id - state.active_flows.append(flow) - turn_geopolitical_flows.append(flow) - existing_flows_by_key[flow_key] = flow - elif pact_turns > 0 and ((p_coop_1 + p_exch_1 < 0.18 and p_coop_2 + p_exch_2 < 0.18) or p_disp_1 >= 0.28 or p_disp_2 >= 0.28): - var decayed := pact_turns - 1 - if decayed <= 0: - state.active_pacts.erase(pair_key) - else: - state.active_pacts[pair_key] = decayed - - -static func _run_defense_fortification_phase( - state: EvoTypes.WorldState, - lang: String, - arch_probs: Dictionary -) -> void: - for did in state.demes.keys(): - var deme: EvoTypes.Deme = state.demes[did] - if deme.defense_tier >= 3 or not state.cells.has(deme.hex_id): - continue - var req_pop: int = 38 if deme.defense_tier == 0 else (65 if deme.defense_tier == 1 else 100) - if deme.population < req_pop: - continue - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] - var req_res: float = 58.0 if deme.defense_tier == 0 else (92.0 if deme.defense_tier == 1 else 130.0) - var req_cell_res: float = 35.0 if deme.defense_tier == 0 else (55.0 if deme.defense_tier == 1 else 75.0) - if cell.current_resources < req_cell_res or deme.energy_reserves < req_res - 35.0: - continue - var ap: Array = arch_probs.get(deme.id, [0.0, 0.0, 0.0, 0.0]) - var p_coop_exch := float(ap[0]) + float(ap[1]) - var has_fort_trait := ( - deme.cultural_trait_ids.has("cul-symbolic-storage") - or deme.cultural_trait_ids.has("cul-communal-conservation") - or deme.biological_trait_ids.has("bio-k-strategy") - ) - var has_rival := false - var allies: Array[EvoTypes.Deme] = [] - if not (p_coop_exch >= 0.25 or has_fort_trait) or (deme.energy_reserves < req_res and not state.active_pacts.is_empty()): - for oid in cell.deme_ids: - if oid != deme.id and state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - if EvoTypes.is_pact_active(state, deme.id, other.id): - allies.append(other) - elif other.lineage_id != deme.lineage_id: - has_rival = true - for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - for oid in ncell.deme_ids: - if oid != deme.id and state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - if EvoTypes.is_pact_active(state, deme.id, other.id): - allies.append(other) - elif other.lineage_id != deme.lineage_id: - has_rival = true - - if not (p_coop_exch >= 0.25 or has_fort_trait or has_rival): - continue - - var cost_res: float = 25.0 if deme.defense_tier == 0 else (45.0 if deme.defense_tier == 1 else 65.0) - var cost_cell: float = 15.0 if deme.defense_tier == 0 else (25.0 if deme.defense_tier == 1 else 35.0) - - if has_fort_trait: - cost_res = snappedf(cost_res * 0.85, 0.01) - cost_cell = snappedf(cost_cell * 0.85, 0.01) - - if deme.energy_reserves < req_res and (req_res - deme.energy_reserves) <= 35.0 and not allies.is_empty(): - for ally in allies: - if ally.energy_reserves >= 80.0: - var needed := req_res - deme.energy_reserves - var contrib := snappedf(minf(needed, minf(30.0, ally.energy_reserves - 55.0)), 0.01) - if contrib > 0.0: - ally.energy_reserves = maxf(0.0, snappedf(ally.energy_reserves - contrib, 0.01)) - deme.energy_reserves = snappedf(deme.energy_reserves + contrib, 0.01) - if deme.energy_reserves >= req_res: - break - - if deme.energy_reserves < req_res: - continue - - deme.energy_reserves = maxf(0.0, snappedf(deme.energy_reserves - cost_res, 0.01)) - cell.current_resources = maxf(0.0, snappedf(cell.current_resources - cost_cell, 0.01)) - deme.defense_tier += 1 - - if deme.defense_tier >= 2: - var params := { - "deme": deme.name, - "tier_name": EvoI18n.t(lang, "inspector_defense_%d" % deme.defense_tier), - "hex": cell.id - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-fortify-%d-%s-%d" % [state.generation, deme.id, deme.defense_tier] - entry.generation = state.generation - entry.event_type = "fortify" - entry.title = EvoI18n.t(lang, "chronicle_fortify_title", params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_fortify_desc", params) - entry.hex_id = cell.id - entry.deme_id = deme.id - entry.lineage_id = deme.lineage_id - state.chronicle.append(entry) - - -static func _can_attempt_raid( - raider: EvoTypes.Deme, - victim: EvoTypes.Deme, - c_raider: EvoTypes.HexCell, - state: EvoTypes.WorldState, - vec_r: EvoTypes.BiophysicalVector, - extracted_energy_by_deme: Dictionary, - ap_r: Array = [] -) -> bool: - if victim.defense_tier >= 3: - return false - if _has_recent_raid_chronicle(state, raider.id, victim.id): - return false - var p_disp := float(ap_r[2]) if ap_r.size() >= 4 else get_archetype_prob(raider, state.decisions_catalog, "dispute") - var p_over := float(ap_r[3]) if ap_r.size() >= 4 else get_archetype_prob(raider, state.decisions_catalog, "overexploit") - if p_disp + p_over < 0.28: - return false - - var ext_r := float(extracted_energy_by_deme.get(raider.id, 0.0)) - var need_r := _compute_caloric_need(raider, vec_r) - var adequacy_r := ext_r / need_r - var k_ratio := c_raider.carrying_capacity / maxf(10.0, c_raider.carrying_capacity_max) - var env_pressure := ( - adequacy_r < 1.12 - or raider.energy_reserves < 65.0 - or state.season == "dry_cold" - or k_ratio < 0.80 - ) - if not env_pressure: - return false - - var has_raiding_capability := ( - vec_r.extraction_efficiency > 0.20 - or vec_r.spatial_mobility > 0.20 - or raider.cultural_trait_ids.has("cul-warrior-expansion") - or raider.cultural_trait_ids.has("cul-extraction-tools") - ) - if not has_raiding_capability: - return false - - var ext_v := float(extracted_energy_by_deme.get(victim.id, 0.0)) - return victim.energy_reserves >= 28.0 or ext_v >= 25.0 - - -static func _has_recent_raid_chronicle( - state: EvoTypes.WorldState, - raider_id: String, - victim_id: String -) -> bool: - var suffix_ab := "-%s-%s" % [raider_id, victim_id] - var suffix_ba := "-%s-%s" % [victim_id, raider_id] - for entry in state.chronicle: - if entry.event_type == "raid" and entry.generation >= state.generation - 2: - if entry.id.ends_with(suffix_ab) or entry.id.ends_with(suffix_ba): - return true - return false - - -static func _execute_raid_if_successful( - raider: EvoTypes.Deme, - victim: EvoTypes.Deme, - c_raider: EvoTypes.HexCell, - c_victim: EvoTypes.HexCell, - state: EvoTypes.WorldState, - lang: String, - vec_r: EvoTypes.BiophysicalVector, - vec_v: EvoTypes.BiophysicalVector, - extracted_energy_by_deme: Dictionary, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow], - ap_r: Array = [], - ap_v: Array = [] -) -> bool: - var p_r_disp := float(ap_r[2]) if ap_r.size() >= 4 else get_archetype_prob(raider, state.decisions_catalog, "dispute") - var p_r_over := float(ap_r[3]) if ap_r.size() >= 4 else get_archetype_prob(raider, state.decisions_catalog, "overexploit") - var p_v_disp := float(ap_v[2]) if ap_v.size() >= 4 else get_archetype_prob(victim, state.decisions_catalog, "dispute") - var p_v_coop := float(ap_v[0]) if ap_v.size() >= 4 else get_archetype_prob(victim, state.decisions_catalog, "cooperate") - var p_v_exch := float(ap_v[1]) if ap_v.size() >= 4 else get_archetype_prob(victim, state.decisions_catalog, "exchange") - - var warrior_r := 0.35 if raider.cultural_trait_ids.has("cul-warrior-expansion") else 0.0 - var warrior_v := 0.30 if victim.cultural_trait_ids.has("cul-warrior-expansion") else 0.0 - var pact_defense_bonus := compute_pact_defense_bonus(victim, state) - var fort_bonus: float = DEFENSE_TIER_BONUS[clampi(victim.defense_tier, 0, 3)] - - var raider_score := ( - float(raider.population) - * (0.8 + 0.5 * vec_r.extraction_efficiency + 0.4 * vec_r.spatial_mobility + warrior_r) - * (0.25 + p_r_disp + 0.5 * p_r_over) - ) - var defense_score := ( - float(victim.population) - * (0.85 + 0.65 * vec_v.social_cohesion + pact_defense_bonus + warrior_v + fort_bonus) - * (0.5 + p_v_disp + 0.5 * p_v_coop) - ) - - if raider_score <= defense_score * 0.92: - return false - - if victim.defense_tier < 2 and victim.energy_reserves >= 55.0 and (p_v_exch >= 0.28 or (p_v_coop + p_v_exch >= 0.65 and p_v_disp < 0.10)): - var tribute := snappedf(clampf(victim.energy_reserves * 0.18, 10.0, 28.0), 0.1) - victim.energy_reserves = maxf(0.0, snappedf(victim.energy_reserves - tribute, 0.01)) - raider.energy_reserves = minf(220.0, snappedf(raider.energy_reserves + tribute, 0.01)) - state.active_pacts[EvoTypes.make_pair_key(raider.id, victim.id)] = 1 - - var t_flow := EvoTypes.HexEdgeFlow.new() - t_flow.from_hex_id = c_victim.id - t_flow.to_hex_id = c_raider.id - t_flow.flow_type = "tribute" - t_flow.intensity = 0.75 - t_flow.from_deme_id = raider.id - t_flow.to_deme_id = victim.id - state.active_flows.append(t_flow) - turn_geopolitical_flows.append(t_flow) - - var t_params := { - "raider": raider.name, - "victim": victim.name, - "loot": str(int(round(tribute))), - "hex": c_victim.id - } - var t_entry := EvoTypes.ChronicleEntry.new() - t_entry.id = "chronicle-tribute-%d-%s-%s" % [state.generation, victim.id, raider.id] - t_entry.generation = state.generation - t_entry.event_type = "tribute" - t_entry.title = EvoI18n.t(lang, "chronicle_tribute_title", t_params) - t_entry.causal_explanation = EvoI18n.t(lang, "chronicle_tribute_desc", t_params) - t_entry.hex_id = c_victim.id - t_entry.deme_id = victim.id - t_entry.lineage_id = victim.lineage_id - state.chronicle.append(t_entry) - return true - - var tier_idx := clampi(victim.defense_tier, 0, 3) - var loot_scale: float = [1.0, 0.75, 0.40, 0.0][tier_idx] - var cas_scale: float = [1.0, 0.75, 0.50, 0.25][tier_idx] - var ext_v := float(extracted_energy_by_deme.get(victim.id, 0.0)) - var force_ratio := clampf(raider_score / maxf(1.0, defense_score), 0.7, 1.5) - var desired_loot := clampf((victim.energy_reserves * 0.28 + ext_v * 0.24) * force_ratio, 10.0, 42.0) * loot_scale - - var from_reserves := minf(victim.energy_reserves, desired_loot * 0.65) - var from_ext := minf(ext_v, desired_loot - from_reserves) - if from_reserves + from_ext < desired_loot: - var rem := desired_loot - (from_reserves + from_ext) - from_reserves = minf(victim.energy_reserves, from_reserves + rem) - var loot := snappedf(from_reserves + from_ext, 0.01) - - victim.energy_reserves = maxf(0.0, snappedf(victim.energy_reserves - from_reserves, 0.01)) - extracted_energy_by_deme[victim.id] = maxf(0.0, snappedf(ext_v - from_ext, 0.01)) - extracted_energy_by_deme[raider.id] = snappedf(float(extracted_energy_by_deme.get(raider.id, 0.0)) + from_ext, 0.01) - raider.energy_reserves = minf(220.0, snappedf(raider.energy_reserves + from_reserves, 0.01)) - - var r_loss := maxi(1, int(round(float(raider.population) * 0.025))) - var v_loss := maxi(1, int(round(float(victim.population) * 0.04 * cas_scale))) - raider.population = maxi(10, raider.population - r_loss) - victim.population = maxi(8, victim.population - v_loss) - - var pk := EvoTypes.make_pair_key(raider.id, victim.id) - if int(state.active_pacts.get(pk, 0)) < 2: - state.active_pacts[pk] = mini(-2, int(state.active_pacts.get(pk, 0))) - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = c_raider.id - flow.to_hex_id = c_victim.id - flow.flow_type = "raid" - flow.intensity = snappedf(clampf(loot / 32.0, 0.50, 1.0), 0.01) - flow.from_deme_id = raider.id - flow.to_deme_id = victim.id - state.active_flows.append(flow) - turn_geopolitical_flows.append(flow) - - if not _has_recent_raid_chronicle(state, raider.id, victim.id): - var params := { - "raider": raider.name, - "victim": victim.name, - "loot": str(int(round(loot))), - "hex": c_victim.id - } - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-raid-%d-%s-%s" % [state.generation, raider.id, victim.id] - entry.generation = state.generation - entry.event_type = "raid" - entry.title = EvoI18n.t(lang, "chronicle_raid_title", params) - entry.causal_explanation = EvoI18n.t(lang, "chronicle_raid_desc", params) - entry.hex_id = c_victim.id - entry.deme_id = raider.id - entry.lineage_id = raider.lineage_id - state.chronicle.append(entry) - - return true - - -static func _run_raids( - state: EvoTypes.WorldState, - lang: String, - effective_vec_by_deme: Dictionary, - extracted_energy_by_deme: Dictionary, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow], - betrayed_pairs_this_turn: Dictionary, - arch_probs: Dictionary, - adjacent_pairs: Array -) -> void: - var any_raider := false - for did in arch_probs.keys(): - var ap: Array = arch_probs[did] - if float(ap[2]) + float(ap[3]) >= 0.28: - any_raider = true - break - if not any_raider: - return - - var raided_pairs: Dictionary = {} - for pair in adjacent_pairs: - var d1: EvoTypes.Deme = pair[0] - var c1: EvoTypes.HexCell = pair[1] - var d2: EvoTypes.Deme = pair[2] - var c2: EvoTypes.HexCell = pair[3] - var pair_key: String = pair[4] - - if betrayed_pairs_this_turn.has(pair_key) or raided_pairs.has(pair_key): - continue - if int(state.active_pacts.get(pair_key, 0)) >= 2: - continue - if d1.lineage_id == d2.lineage_id and EvoDemographyMigration.compute_deme_divergence(d1, d2) <= 0.35: - continue - - var v1: EvoTypes.BiophysicalVector = effective_vec_by_deme[d1.id] - var v2: EvoTypes.BiophysicalVector = effective_vec_by_deme[d2.id] - var ap1: Array = arch_probs[d1.id] - var ap2: Array = arch_probs[d2.id] - - if _can_attempt_raid(d1, d2, c1, state, v1, extracted_energy_by_deme, ap1): - if _execute_raid_if_successful(d1, d2, c1, c2, state, lang, v1, v2, extracted_energy_by_deme, turn_geopolitical_flows, ap1, ap2): - raided_pairs[pair_key] = true - continue - - if _can_attempt_raid(d2, d1, c2, state, v2, extracted_energy_by_deme, ap2): - if _execute_raid_if_successful(d2, d1, c2, c1, state, lang, v2, v1, extracted_energy_by_deme, turn_geopolitical_flows, ap2, ap1): - raided_pairs[pair_key] = true - - -static func run_resource_and_symbiosis_phase( - state: EvoTypes.WorldState, - lang: String, - effective_vec_by_deme: Dictionary, - hawk_penalty_by_deme: Dictionary, - extracted_energy_by_deme: Dictionary, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow] = [] -) -> void: - var is_en := lang == "en" - var symbiosis_bonus_applied: Dictionary = {} - var existing_flows_by_key: Dictionary = {} - var initial_cultural_traits_by_deme: Dictionary = {} - var arch_probs: Dictionary = {} - var valid_demes: Array[EvoTypes.Deme] = [] - var valid_cells: Array[EvoTypes.HexCell] = [] - for did in state.demes.keys(): - var d: EvoTypes.Deme = state.demes[did] - initial_cultural_traits_by_deme[did] = d.cultural_trait_ids.duplicate() - var p_coop: float = 0.0 - var p_exch: float = 0.0 - var p_disp: float = 0.0 - var p_over: float = 0.0 - for dec_id in d.last_decision_probabilities.keys(): - if state.decisions_catalog.has(dec_id): - var arch := (state.decisions_catalog[dec_id] as EvoTypes.DecisionDef).archetype - var prob := float(d.last_decision_probabilities[dec_id]) - if arch == "cooperate": - p_coop += prob - elif arch == "exchange": - p_exch += prob - elif arch == "dispute": - p_disp += prob - elif arch == "overexploit": - p_over += prob - arch_probs[d.id] = [p_coop, p_exch, p_disp, p_over] - if state.cells.has(d.hex_id): - valid_demes.append(d) - valid_cells.append(state.cells[d.hex_id]) - - var tragedy_hexes: Dictionary = {} - for entry in state.chronicle: - if entry.event_type == "tragedy_of_commons": - tragedy_hexes[entry.hex_id] = true - - for cid in state.cells.keys(): - var cell: EvoTypes.HexCell = state.cells[cid] - var resident_demes: Array[EvoTypes.Deme] = [] - for did in cell.deme_ids: - if state.demes.has(did): - resident_demes.append(state.demes[did]) - - if resident_demes.is_empty(): - continue - - var demand_by_deme: Dictionary = {} - for deme in resident_demes: - var v_eff: EvoTypes.BiophysicalVector = effective_vec_by_deme[deme.id] - demand_by_deme[deme.id] = maxf( - 0.0, - float(deme.population) * 0.50 * (1.0 + 0.60 * v_eff.extraction_efficiency) - ) - - var lineages_in_hex: Dictionary = {} - for deme in resident_demes: - lineages_in_hex[deme.lineage_id] = true - var has_rival_lineages := lineages_in_hex.size() >= 2 - - var hawks: Array[EvoTypes.Deme] = [] - var doves: Array[EvoTypes.Deme] = [] - var exchangers: Array[EvoTypes.Deme] = [] - - for deme in resident_demes: - var ap: Array = arch_probs[deme.id] - var is_active_hawk := has_rival_lineages and float(ap[2]) >= 0.25 - if is_active_hawk: - hawks.append(deme) - else: - doves.append(deme) - if float(ap[1]) >= 0.20: - exchangers.append(deme) - - var hawk_lineages: Dictionary = {} - for hawk in hawks: - hawk_lineages[hawk.lineage_id] = true - var has_rival_hawks := hawk_lineages.size() >= 2 - - if has_rival_lineages: - if has_rival_hawks: - for i in range(hawks.size()): - var hawk := hawks[i] - var opp := hawks[(i + 1) % hawks.size()] - for candidate_opp in hawks: - if candidate_opp.lineage_id != hawk.lineage_id: - opp = candidate_opp - break - hawk.population = maxi(10, int(floor(float(hawk.population) * 0.85))) - hawk_penalty_by_deme[hawk.id] = 1.25 - var v_eff: EvoTypes.BiophysicalVector = effective_vec_by_deme[hawk.id] - demand_by_deme[hawk.id] = maxf( - 0.0, - float(hawk.population) * 0.50 * (1.0 + 0.60 * v_eff.extraction_efficiency) - ) - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = cell.id - flow.to_hex_id = cell.id - flow.flow_type = "dispute" - flow.intensity = 0.75 - flow.from_deme_id = hawk.id - flow.to_deme_id = opp.id - state.active_flows.append(flow) - elif hawks.size() >= 1 and doves.size() >= 1: - for hawk in hawks: - var rival_doves: Array[EvoTypes.Deme] = [] - for dove in doves: - if dove.lineage_id != hawk.lineage_id: - rival_doves.append(dove) - if rival_doves.is_empty(): - continue - var sum_dove_social: float = 0.0 - for dove in rival_doves: - sum_dove_social += (effective_vec_by_deme[dove.id] as EvoTypes.BiophysicalVector).social_cohesion - var avg_dove_social := sum_dove_social / float(rival_doves.size()) - - var hawk_social := (effective_vec_by_deme[hawk.id] as EvoTypes.BiophysicalVector).social_cohesion - var capture_mult := 0.25 * (1.0 + maxf(0.0, hawk_social - avg_dove_social)) - var hawk_demand := float(demand_by_deme[hawk.id]) - var req_bonus := hawk_demand * capture_mult - var total_dove_demand: float = 0.0 - for dove in rival_doves: - total_dove_demand += float(demand_by_deme[dove.id]) - var actual_captured := minf(total_dove_demand, req_bonus) - - demand_by_deme[hawk.id] = hawk_demand + actual_captured - if total_dove_demand > 0.0: - for dove in rival_doves: - var cur_d := float(demand_by_deme[dove.id]) - var share := cur_d / total_dove_demand - demand_by_deme[dove.id] = maxf(0.0, cur_d - actual_captured * share) - - for dove in rival_doves: - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = cell.id - flow.to_hex_id = cell.id - flow.flow_type = "dispute" - flow.intensity = 0.65 - flow.from_deme_id = hawk.id - flow.to_deme_id = dove.id - state.active_flows.append(flow) - - var total_demand: float = 0.0 - for deme in resident_demes: - total_demand += float(demand_by_deme[deme.id]) - var total_extracted := minf(cell.current_resources, total_demand) - - for deme in resident_demes: - var d_dem := float(demand_by_deme[deme.id]) - var ext := (total_extracted * (d_dem / total_demand)) if total_demand > 0.0 else 0.0 - extracted_energy_by_deme[deme.id] = ext - - cell.current_resources = maxf(0.0, snappedf(cell.current_resources - total_extracted, 0.01)) - - if exchangers.size() >= 2: - var logged_symbiosis_in_cell := false - for i in range(exchangers.size()): - var ex := exchangers[i] - var partner := exchangers[(i + 1) % exchangers.size()] - var base_ext := float(extracted_energy_by_deme[ex.id]) - extracted_energy_by_deme[ex.id] = snappedf(base_ext * 1.20, 0.0001) - symbiosis_bonus_applied[ex.id] = true - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = cell.id - flow.to_hex_id = cell.id - flow.flow_type = "exchange" - flow.intensity = 0.60 - flow.from_deme_id = ex.id - flow.to_deme_id = partner.id - state.active_flows.append(flow) - var intra_pk := (ex.id + "__" + partner.id) if ex.id < partner.id else (partner.id + "__" + ex.id) - existing_flows_by_key[intra_pk] = flow - - for donor in exchangers: - var donor_initial_traits: Array = initial_cultural_traits_by_deme.get(donor.id, []) - for receiver in exchangers: - if donor.id == receiver.id: - continue - if not EvoCatalog.deme_has_cultural_capacity(receiver, state.traits_catalog): - continue - var receiver_social := (effective_vec_by_deme[receiver.id] as EvoTypes.BiophysicalVector).social_cohesion - for tid_var in donor_initial_traits: - var tid := str(tid_var) - if not _can_acquire_trait(receiver, tid, state.traits_catalog): - continue - var tdef: EvoTypes.TraitDef = state.traits_catalog[tid] - if receiver_social >= tdef.vector.transmission_threshold: - receiver.cultural_trait_ids.append(tid) - effective_vec_by_deme[receiver.id] = compute_effective_vector(receiver, state) - if not logged_symbiosis_in_cell: - logged_symbiosis_in_cell = true - var tname := EvoI18n.trait_name(lang, tdef) - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-symbiosis-%d-%s-%s" % [state.generation, cell.id, receiver.id] - entry.generation = state.generation - entry.event_type = "symbiosis" - entry.title = ("Cultural Symbiosis: %s" if is_en else "Simbiosis Cultural: %s") % tname - entry.causal_explanation = ( - "%s acquired %s through symbiotic exchange with %s in hex (%s)." % [receiver.name, tname, donor.name, cell.id] - if is_en - else "%s adquirió %s mediante intercambio simbiótico con %s en el hexágono (%s)." % [receiver.name, tname, donor.name, cell.id] - ) - entry.hex_id = cell.id - entry.deme_id = receiver.id - entry.lineage_id = receiver.lineage_id - entry.trait_id = tid - state.chronicle.append(entry) - - var gamma_season := 1.20 if state.season == "wet_warm" else 0.75 - var natural_regen := cell.carrying_capacity * cell.seasonal_regen_rate * gamma_season - var sum_eco_impact: float = 0.0 - var has_intensive_overexploit := false - for deme in resident_demes: - sum_eco_impact += (effective_vec_by_deme[deme.id] as EvoTypes.BiophysicalVector).ecological_impact - if float((arch_probs[deme.id] as Array)[3]) >= 0.30: - has_intensive_overexploit = true - - var already_logged_tragedy := tragedy_hexes.has(cell.id) - - if (total_extracted > natural_regen or (cell.current_resources <= 0.0 and total_demand > natural_regen)) and (sum_eco_impact < -0.15 or (has_intensive_overexploit and sum_eco_impact < -0.10)): - var degraded_k := maxf(10.0, snappedf(cell.carrying_capacity * (1.0 + 0.18 * sum_eco_impact), 0.01)) - cell.carrying_capacity = degraded_k - if cell.current_resources > degraded_k: - cell.current_resources = degraded_k - - var primary_deme := resident_demes[0] - if not already_logged_tragedy and degraded_k < 0.60 * cell.carrying_capacity_max: - tragedy_hexes[cell.id] = true - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-tragedy-%d-%s" % [state.generation, cell.id] - entry.generation = state.generation - entry.event_type = "tragedy_of_commons" - entry.title = ("Tragedy of the Commons in (%s)" if is_en else "Tragedia de los Comunes en (%s)") % cell.id - entry.causal_explanation = ( - "Intensive overexploitation degraded local carrying capacity to %.1f (below 60%% of maximum %.0f)." % [degraded_k, cell.carrying_capacity_max] - if is_en - else "La sobreexplotación intensiva degradó la capacidad de carga local a %.1f (por debajo del 60%% del máximo %.0f)." % [degraded_k, cell.carrying_capacity_max] - ) - entry.hex_id = cell.id - entry.deme_id = primary_deme.id - entry.lineage_id = primary_deme.lineage_id - state.chronicle.append(entry) - elif sum_eco_impact > 0.10 and not has_intensive_overexploit: - cell.carrying_capacity = snappedf( - clampf(cell.carrying_capacity * (1.0 + 0.05 * sum_eco_impact), 10.0, cell.carrying_capacity_max), - 0.01 - ) - - var exchange_flow_pairs: Dictionary = {} - for donor_id in state.demes.keys(): - var donor: EvoTypes.Deme = state.demes[donor_id] - if float((arch_probs[donor.id] as Array)[1]) < 0.20: - continue - if not state.cells.has(donor.hex_id): - continue - var donor_cell: EvoTypes.HexCell = state.cells[donor.hex_id] - if donor_cell.biome == "ocean" or donor_cell.altitude > 0.85: - continue - var donor_initial_traits: Array = initial_cultural_traits_by_deme.get(donor.id, []) - for nid in HexMath.get_neighbor_ids(donor_cell.q, donor_cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - if ncell.biome == "ocean" or ncell.altitude > 0.85: - continue - for ndeme_id in ncell.deme_ids: - if not state.demes.has(ndeme_id): - continue - var receiver: EvoTypes.Deme = state.demes[ndeme_id] - if float((arch_probs[receiver.id] as Array)[1]) < 0.20: - continue - - if not symbiosis_bonus_applied.has(donor.id): - var base_ext := float(extracted_energy_by_deme[donor.id]) - var trade_mult := ( - 1.15 - + (0.12 if donor_cell.biome != ncell.biome else 0.0) - + (0.08 if donor.cultural_trait_ids != receiver.cultural_trait_ids else 0.0) - ) - extracted_energy_by_deme[donor.id] = snappedf(base_ext * trade_mult, 0.0001) - symbiosis_bonus_applied[donor.id] = true - - var hex_pair_key := ("%s|%s" % [donor_cell.id, ncell.id]) if donor_cell.id <= ncell.id else ("%s|%s" % [ncell.id, donor_cell.id]) - if not exchange_flow_pairs.has(hex_pair_key): - exchange_flow_pairs[hex_pair_key] = true - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = donor_cell.id - flow.to_hex_id = ncell.id - flow.flow_type = "exchange" - flow.intensity = 0.55 - flow.from_deme_id = donor.id - flow.to_deme_id = receiver.id - state.active_flows.append(flow) - existing_flows_by_key[hex_pair_key] = flow - - if EvoCatalog.deme_has_cultural_capacity(receiver, state.traits_catalog): - var receiver_social := (effective_vec_by_deme[receiver.id] as EvoTypes.BiophysicalVector).social_cohesion - for tid_var in donor_initial_traits: - var tid := str(tid_var) - if not _can_acquire_trait(receiver, tid, state.traits_catalog): - continue - var tdef: EvoTypes.TraitDef = state.traits_catalog[tid] - if receiver_social >= tdef.vector.transmission_threshold: - receiver.cultural_trait_ids.append(tid) - effective_vec_by_deme[receiver.id] = compute_effective_vector(receiver, state) - var tname := EvoI18n.trait_name(lang, tdef) - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-symbiosis-%d-%s-%s-%s" % [state.generation, ncell.id, receiver.id, tid] - entry.generation = state.generation - entry.event_type = "symbiosis" - entry.title = ("Cultural Symbiosis: %s" if is_en else "Simbiosis Cultural: %s") % tname - entry.causal_explanation = ( - "%s acquired %s through inter-deme exchange with %s across hexes (%s -> %s)." % [receiver.name, tname, donor.name, donor_cell.id, ncell.id] - if is_en - else "%s adquirió %s mediante intercambio inter-deme con %s entre hexágonos (%s -> %s)." % [receiver.name, tname, donor.name, donor_cell.id, ncell.id] - ) - entry.hex_id = ncell.id - entry.deme_id = receiver.id - entry.lineage_id = receiver.lineage_id - entry.trait_id = tid - state.chronicle.append(entry) - - var adjacent_pairs: Array = [] - var v_count := valid_demes.size() - for i in range(v_count): - var d1 := valid_demes[i] - var c1 := valid_cells[i] - var q1 := c1.q - var r1 := c1.r - var bad1 := c1.biome == "ocean" or c1.altitude > 0.85 - for j in range(i + 1, v_count): - var c2 := valid_cells[j] - var dq := q1 - c2.q - var dr := r1 - c2.r - if absi(dq) > 1 or absi(dr) > 1 or absi(dq + dr) > 1: - continue - if (dq != 0 or dr != 0) and (bad1 or c2.biome == "ocean" or c2.altitude > 0.85): - continue - var d2 := valid_demes[j] - var pk := (d1.id + "__" + d2.id) if d1.id < d2.id else (d2.id + "__" + d1.id) - adjacent_pairs.append([d1, c1, d2, c2, pk]) - - var betrayed_pairs_this_turn: Dictionary = {} - _run_pacts_and_betrayals( - state, - lang, - effective_vec_by_deme, - extracted_energy_by_deme, - symbiosis_bonus_applied, - turn_geopolitical_flows, - betrayed_pairs_this_turn, - arch_probs, - adjacent_pairs, - existing_flows_by_key - ) - _run_raids( - state, - lang, - effective_vec_by_deme, - extracted_energy_by_deme, - turn_geopolitical_flows, - betrayed_pairs_this_turn, - arch_probs, - adjacent_pairs - ) - _run_defense_fortification_phase(state, lang, arch_probs) diff --git a/src/engine/biophys/ecology_interaction.gd.uid b/src/engine/biophys/ecology_interaction.gd.uid deleted file mode 100644 index d5603b7..0000000 --- a/src/engine/biophys/ecology_interaction.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c407vulwxy4y7 diff --git a/src/engine/biophys/economy_phase.gd b/src/engine/biophys/economy_phase.gd new file mode 100644 index 0000000..f015043 --- /dev/null +++ b/src/engine/biophys/economy_phase.gd @@ -0,0 +1,136 @@ +class_name EvoEconomyPhase +extends RefCounted + +## Harvest (forage/intensify, fields, pacts, reciprocal trade), self-investment +## (fortify, innovate) and metabolism (food need, births, famine, salubrity). + + +static func harvest(state: EvoTypes.WorldState, lang: String) -> Dictionary: + EvoRuralHinterland.claim_hinterlands(state) + var income_by_deme: Dictionary = {} + for deme: EvoTypes.Deme in state.demes.values(): + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var action_type := str(deme.chosen_action["type"]) + var home := EvoPayoffs.home_harvest(deme, cell, state, action_type) + cell.current_resources -= home + if action_type == "intensify": + _degrade_land(deme, cell, state, lang) + var income := (home + EvoRuralHinterland.harvest_rural(deme, state)) * EvoPayoffs.pact_multiplier(deme, state) + income_by_deme[deme.id] = income + if action_type in ["forage", "intensify"]: + deme.last_outcome = "You %s and harvested %d food." % ["foraged" if action_type == "forage" else "overexploited the land", int(income)] + + var trade_gain_by_deme: Dictionary = {} + for deme: EvoTypes.Deme in state.demes.values(): + if str(deme.chosen_action["type"]) != "trade": + continue + var other := EvoDiplomacyPhase.adjacent_target(deme, state) + if other == null: + deme.last_outcome = "Your trade partner is gone." + continue + var reciprocated := str(other.chosen_action["key"]) == "trade:" + deme.id + var gain := EvoPayoffs.trade_gain(deme, other, state) if reciprocated else EvoPayoffs.unreciprocated_trade_gain(deme, other, state) + trade_gain_by_deme[deme.id] = gain + var rel := EvoRelations.record(state, deme, other, 0.12 if reciprocated else 0.08, "%s traded with %s" % [deme.name, other.name]) + EvoCulture.diffuse(other, deme, state, lang) + if rel.proposal_from == other.id and rel.proposal_kind == "pact": + EvoDiplomacyPhase._form_pact(deme, other, state, lang) + if reciprocated: + deme.last_outcome = "You traded with %s: +%d food." % [other.name, int(gain)] + if deme.id < other.id: + EvoSettlementPhase.push_flow(state, deme, other, "exchange", 0.65) + EvoRelations.adjust_reputation(deme, 0.03) + EvoRelations.adjust_reputation(other, 0.03) + else: + deme.last_outcome = "You traded with %s (+%d food)." % [other.name, int(gain)] + EvoSettlementPhase.push_flow(state, deme, other, "exchange", 0.45) + EvoRelations.adjust_reputation(deme, 0.02) + EvoRelations.remember(other, state, "%s traded with you (+trust)." % deme.name) + for did in trade_gain_by_deme.keys(): + income_by_deme[did] = float(income_by_deme[did]) + float(trade_gain_by_deme[did]) + + for deme: EvoTypes.Deme in state.demes.values(): + deme.energy_reserves += float(income_by_deme[deme.id]) + return income_by_deme + + +static func _degrade_land(deme: EvoTypes.Deme, cell: EvoTypes.HexCell, state: EvoTypes.WorldState, lang: String) -> void: + var was_healthy := cell.carrying_capacity >= 0.60 * cell.carrying_capacity_max + cell.carrying_capacity = maxf(10.0, cell.carrying_capacity * (1.0 - EvoPayoffs.INTENSIFY_K_LOSS)) + cell.current_resources = minf(cell.current_resources, cell.carrying_capacity) + if was_healthy and cell.carrying_capacity < 0.60 * cell.carrying_capacity_max: + var is_en := lang == "en" + var entry := EvoTypes.ChronicleEntry.new() + entry.id = "chronicle-tragedy-%d-%s" % [state.generation, cell.id] + entry.generation = state.generation + entry.event_type = "tragedy_of_commons" + entry.title = ("Tragedy of the Commons in (%s)" if is_en else "Tragedia de los Comunes en (%s)") % cell.id + entry.causal_explanation = ( + "Overexploitation by %s degraded carrying capacity below 60%% of its maximum." % deme.name + if is_en + else "La sobreexplotación de %s degradó la capacidad de carga por debajo del 60%% de su máximo." % deme.name + ) + entry.hex_id = cell.id + entry.deme_id = deme.id + entry.lineage_id = deme.lineage_id + state.chronicle.append(entry) + + +static func invest(state: EvoTypes.WorldState, lang: String) -> void: + for deme: EvoTypes.Deme in state.demes.values(): + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + match str(deme.chosen_action["type"]): + "fortify": + if not EvoPayoffs.can_fortify(deme, cell): + deme.last_outcome = "You could not afford to fortify." + continue + deme.energy_reserves -= EvoPayoffs.FORTIFY_COST_RESERVES[deme.defense_tier] + cell.current_resources -= EvoPayoffs.FORTIFY_COST_HEX[deme.defense_tier] + deme.defense_tier += 1 + deme.last_outcome = "You built %s." % EvoOutcomeForecast.WALL_NAMES[deme.defense_tier] + EvoTypes.push_event(state, "fortify", cell.id, cell.id, deme.id, "", float(deme.defense_tier), 0) + if deme.defense_tier >= 2: + var params := {"deme": deme.name, "tier_name": EvoI18n.t(lang, "inspector_defense_%d" % deme.defense_tier), "hex": cell.id} + EvoSettlementPhase.chronicle(state, "chronicle-fortify-%d-%s" % [state.generation, deme.id], "fortify", lang, "chronicle_fortify", params, cell.id, deme) + "innovate": + var step := EvoPayoffs.innovation_step(deme, state) + deme.innovation_counter = minf(2.0, deme.innovation_counter + step) + deme.last_outcome = "You experimented: innovation now %.2f of 1.0." % deme.innovation_counter + + +static func metabolize(state: EvoTypes.WorldState, income_by_deme: Dictionary, lang: String) -> void: + for deme: EvoTypes.Deme in state.demes.values(): + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var start_pop := deme.population + var need := EvoPayoffs.food_need(deme, cell, state) + var income := float(income_by_deme.get(deme.id, 0.0)) + deme.last_food_intake = snappedf(income, 0.01) + deme.last_food_need = snappedf(need, 0.01) + deme.last_energy_delta = snappedf(income - need, 0.01) + deme.energy_reserves -= need + EvoEpidemicSalubrity.compute_deme_salubrity(deme, cell, state) + if deme.energy_reserves < 0.0: + _famine(deme, cell, state, lang) + else: + var births := mini( + int(roundf(float(deme.population) * EvoPayoffs.growth_rate(deme))), + int(floor(maxf(0.0, deme.energy_reserves - need) / EvoPayoffs.BIRTH_COST)) + ) + deme.population += births + deme.energy_reserves -= float(births) * EvoPayoffs.BIRTH_COST + deme.energy_reserves = snappedf(minf(deme.energy_reserves, EvoPayoffs.storage_cap(deme)), 0.01) + deme.last_population_delta = deme.population - start_pop + + +static func _famine(deme: EvoTypes.Deme, cell: EvoTypes.HexCell, state: EvoTypes.WorldState, lang: String) -> void: + var deaths := mini(int(ceil(-deme.energy_reserves / 1.2)), int(ceil(float(deme.population) * 0.25))) + deme.population -= deaths + deme.energy_reserves = 0.0 + if deme.defense_tier > 0 and deaths > deme.population / 8: + deme.defense_tier -= 1 + EvoRelations.remember(deme, state, "Famine killed %d of your people." % deaths) + EvoTypes.push_event(state, "famine", cell.id, cell.id, deme.id, deme.id, float(deaths), deaths) + if EvoEpidemicSalubrity._has_recent_famine_chronicle(state, deme.id): + return + var params := {"deme": deme.name, "hex": cell.id, "casualties": str(deaths)} + EvoSettlementPhase.chronicle(state, "chronicle-famine-%d-%s" % [state.generation, deme.id], "famine", lang, "chronicle_famine", params, cell.id, deme) diff --git a/src/engine/biophys/economy_phase.gd.uid b/src/engine/biophys/economy_phase.gd.uid new file mode 100644 index 0000000..3295d0c --- /dev/null +++ b/src/engine/biophys/economy_phase.gd.uid @@ -0,0 +1 @@ +uid://c2t1vcvigtvkv diff --git a/src/engine/biophys/epidemic_salubrity.gd b/src/engine/biophys/epidemic_salubrity.gd new file mode 100644 index 0000000..d5ee329 --- /dev/null +++ b/src/engine/biophys/epidemic_salubrity.gd @@ -0,0 +1,168 @@ +class_name EvoEpidemicSalubrity +extends RefCounted + + +static func compute_deme_salubrity( + deme: EvoTypes.Deme, + cell: EvoTypes.HexCell, + state: EvoTypes.WorldState +) -> float: + var union_partners: int = 0 + if deme.walled_union_id != "": + for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + for p_id in ncell.deme_ids: + if p_id != deme.id and state.demes.has(p_id): + var partner: EvoTypes.Deme = state.demes[p_id] + if partner.population > 0 and partner.walled_union_id == deme.walled_union_id: + union_partners += 1 + + var sal_crowding := ( + float(deme.population) / maxf(20.0, cell.carrying_capacity) + + 0.16 * float(maxi(0, deme.get_village_tier() - 2)) + + 0.14 * float(union_partners) + ) + var sanitation := ( + (0.20 if deme.cultural_trait_ids.has("cul-communal-conservation") else 0.0) + + (0.14 if deme.biological_trait_ids.has("bio-k-strategy") else 0.0) + + 0.08 * maxf(0.0, cell.humidity) + ) + var target_salubrity := clampf(1.12 - 0.48 * maxf(0.0, sal_crowding - 0.60) + sanitation, 0.05, 1.0) + deme.salubrity = snappedf(lerpf(deme.salubrity, target_salubrity, 0.45), 0.0001) + return deme.salubrity + + +static func _has_recent_famine_chronicle(state: EvoTypes.WorldState, deme_id: String) -> bool: + var cutoff := state.generation - 2 + for i in range(state.chronicle.size() - 1, -1, -1): + var entry := state.chronicle[i] + if entry.generation < cutoff: + break + if entry.event_type == "famine" and entry.deme_id == deme_id: + return true + return false + + +static func _run_epidemic_phase( + state: EvoTypes.WorldState, + lang: String, + turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow] +) -> void: + var infected_sources: Array[EvoTypes.Deme] = [] + for did in state.demes.keys(): + var d: EvoTypes.Deme = state.demes[did] + if d.infection_turns > 0 and state.cells.has(d.hex_id): + infected_sources.append(d) + + var newly_infected_by_target: Dictionary = {} + if not infected_sources.is_empty(): + var contact_pairs: Dictionary = {} + for fl in state.active_flows: + if fl.flow_type in ["exchange", "cooperate", "pact", "tribute", "raid", "war", "siege"] and fl.from_deme_id != "" and fl.to_deme_id != "": + contact_pairs[EvoTypes.make_pair_key(fl.from_deme_id, fl.to_deme_id)] = true + for fl in turn_geopolitical_flows: + if fl.flow_type in ["exchange", "cooperate", "pact", "tribute", "raid", "war", "siege"] and fl.from_deme_id != "" and fl.to_deme_id != "": + contact_pairs[EvoTypes.make_pair_key(fl.from_deme_id, fl.to_deme_id)] = true + + for source_deme in infected_sources: + for tid in state.demes.keys(): + if tid == source_deme.id or newly_infected_by_target.has(tid): + continue + var target_deme: EvoTypes.Deme = state.demes[tid] + if target_deme.infection_turns > 0 or target_deme.immunity_turns > 0 or target_deme.salubrity >= 0.70 or target_deme.population < 40: + continue + var pk := EvoTypes.make_pair_key(source_deme.id, target_deme.id) + if EvoTypes.is_pact_active(state, source_deme.id, target_deme.id) or contact_pairs.has(pk): + newly_infected_by_target[target_deme.id] = source_deme.id + + var was_active_or_immune: Dictionary = {} + for did in state.demes.keys(): + var deme: EvoTypes.Deme = state.demes[did] + if deme.infection_turns > 0 or deme.immunity_turns > 0: + was_active_or_immune[deme.id] = true + if deme.immunity_turns > 0: + deme.immunity_turns -= 1 + elif deme.infection_turns > 0: + var loss := maxi(4, int(round(float(deme.population) * 0.06))) + deme.population = maxi(10, deme.population - loss) + deme.infection_turns -= 1 + if deme.infection_turns == 0: + deme.immunity_turns = 6 + deme.salubrity = minf(1.0, snappedf(deme.salubrity + 0.25, 0.0001)) + EvoTypes.push_event(state, "epidemic_recovered", deme.hex_id, deme.hex_id, deme.id, deme.id, deme.salubrity, 0) + var rec_params := { + "deme": deme.name, + "hex": deme.hex_id + } + var rec_entry := EvoTypes.ChronicleEntry.new() + rec_entry.id = "chronicle-epi-rec-%d-%s" % [state.generation, deme.id] + rec_entry.generation = state.generation + rec_entry.event_type = "epidemic_recovered" + rec_entry.title = EvoI18n.t(lang, "chronicle_epidemic_recovered_title", rec_params) + rec_entry.causal_explanation = EvoI18n.t(lang, "chronicle_epidemic_recovered_desc", rec_params) + rec_entry.hex_id = deme.hex_id + rec_entry.deme_id = deme.id + rec_entry.lineage_id = deme.lineage_id + state.chronicle.append(rec_entry) + + for did in state.demes.keys(): + var deme: EvoTypes.Deme = state.demes[did] + if was_active_or_immune.has(deme.id): + continue + if deme.infection_turns == 0 and deme.immunity_turns == 0 and deme.salubrity < 0.35 and deme.population >= 130: + var epi_loss := maxi(10, int(round(float(deme.population) * 0.15))) + deme.population = maxi(10, deme.population - epi_loss) + deme.infection_turns = 2 + EvoTypes.push_event(state, "epidemic", deme.hex_id, deme.hex_id, deme.id, deme.id, deme.salubrity, epi_loss) + var epi_params := { + "deme": deme.name, + "hex": deme.hex_id, + "casualties": str(epi_loss) + } + var epi_entry := EvoTypes.ChronicleEntry.new() + epi_entry.id = "chronicle-epidemic-%d-%s" % [state.generation, deme.id] + epi_entry.generation = state.generation + epi_entry.event_type = "epidemic" + epi_entry.title = EvoI18n.t(lang, "chronicle_epidemic_title", epi_params) + epi_entry.causal_explanation = EvoI18n.t(lang, "chronicle_epidemic_desc", epi_params) + epi_entry.hex_id = deme.hex_id + epi_entry.deme_id = deme.id + epi_entry.lineage_id = deme.lineage_id + state.chronicle.append(epi_entry) + + for target_id in newly_infected_by_target.keys(): + var source_id := str(newly_infected_by_target[target_id]) + if not state.demes.has(target_id) or not state.demes.has(source_id): + continue + var target_deme: EvoTypes.Deme = state.demes[target_id] + var source_deme: EvoTypes.Deme = state.demes[source_id] + if target_deme.infection_turns == 0 and target_deme.immunity_turns == 0: + var spread_loss := maxi(5, int(round(float(target_deme.population) * 0.08))) + target_deme.population = maxi(10, target_deme.population - spread_loss) + target_deme.infection_turns = 2 + EvoTypes.push_event( + state, + "epidemic_spread", + source_deme.hex_id, + target_deme.hex_id, + source_deme.id, + target_deme.id, + target_deme.salubrity, + spread_loss + ) + var sp_params := { + "deme": target_deme.name, + "source": source_deme.name, + "hex": target_deme.hex_id, + "casualties": str(spread_loss) + } + var sp_entry := EvoTypes.ChronicleEntry.new() + sp_entry.id = "chronicle-epi-spread-%d-%s-%s" % [state.generation, source_deme.id, target_deme.id] + sp_entry.generation = state.generation + sp_entry.event_type = "epidemic_spread" + sp_entry.title = EvoI18n.t(lang, "chronicle_epidemic_spread_title", sp_params) + sp_entry.causal_explanation = EvoI18n.t(lang, "chronicle_epidemic_spread_desc", sp_params) + sp_entry.hex_id = target_deme.hex_id + sp_entry.deme_id = target_deme.id + sp_entry.lineage_id = target_deme.lineage_id + state.chronicle.append(sp_entry) diff --git a/src/engine/biophys/epidemic_salubrity.gd.uid b/src/engine/biophys/epidemic_salubrity.gd.uid new file mode 100644 index 0000000..4a6bb59 --- /dev/null +++ b/src/engine/biophys/epidemic_salubrity.gd.uid @@ -0,0 +1 @@ +uid://b5gewlvmpxo4h diff --git a/src/engine/biophys/payoffs.gd b/src/engine/biophys/payoffs.gd new file mode 100644 index 0000000..b052741 --- /dev/null +++ b/src/engine/biophys/payoffs.gd @@ -0,0 +1,206 @@ +class_name EvoPayoffs +extends RefCounted + +## Pure economic and military formulas. The outcome forecasts shown to the decision +## model and the turn resolution both call these functions, so what a settlement is +## told it will get is exactly what the engine computes. + +const WALL_DEFENSE: Array[float] = [0.0, 0.5, 1.0, 1.6] +const WALL_LOOT_SHIELD: Array[float] = [0.0, 0.25, 0.5, 0.75] +const WALL_UPKEEP: Array[float] = [0.0, 1.5, 3.0, 5.0] +const FORTIFY_COST_RESERVES: Array[float] = [20.0, 40.0, 65.0] +const FORTIFY_COST_HEX: Array[float] = [12.0, 22.0, 32.0] +const FORTIFY_MIN_POP: Array[int] = [35, 65, 100] + +const COMBAT_EXPONENT := 2.5 +const RAID_TROOP_SHARE := 0.45 +const CONQUEST_TROOP_SHARE := 0.55 +const RAID_MOBILIZATION := 0.35 +const CONQUEST_MOBILIZATION := 0.65 +const SURPRISE_MOBILIZATION := 0.22 +const ALLY_SUPPORT_SHARE := 0.22 + +const FORAGE_BONUS := 0.20 +const INTENSIFY_BONUS := 0.55 +const INTENSIFY_K_LOSS := 0.07 +const PACT_HARVEST_BONUS := 0.12 +const TRADE_SHARE := 0.18 +const BIRTH_COST := 2.0 +const BASE_GROWTH_RATE := 0.10 +const SETTLE_MIN_POP := 80 +const MIN_VIABLE_POP := 10 +const INNOVATION_STEP := 0.35 + +const FEUD_AFTER_RAID := 4 +const FEUD_AFTER_WAR := 6 +const FEUD_AFTER_BETRAYAL := 8 +const FEUD_AFTER_REFUSED_TRIBUTE := 2 +const TRUCE_AFTER_TRIBUTE := 3 + + +static func vector(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> EvoTypes.BiophysicalVector: + return EvoCatalog.compute_deme_vector(deme, state.traits_catalog) + + +# --- Economy ----------------------------------------------------------------- + +static func food_need(deme: EvoTypes.Deme, cell: EvoTypes.HexCell, state: EvoTypes.WorldState) -> float: + var v := vector(deme, state) + var climate_stress := 0.20 * absf(cell.temperature) * maxf(0.0, 1.0 - maxf(0.0, v.spatial_mobility)) + return float(deme.population) * 0.42 * (1.0 + 0.55 * v.metabolic_cost + climate_stress) + WALL_UPKEEP[deme.defense_tier] + + +static func home_demand(deme: EvoTypes.Deme, state: EvoTypes.WorldState, action_type: String) -> float: + var v := vector(deme, state) + var bonus := FORAGE_BONUS if action_type == "forage" else (INTENSIFY_BONUS if action_type == "intensify" else 0.0) + return float(deme.population) * 0.5 * (1.0 + 0.4 * v.extraction_efficiency) * (1.0 + bonus) + + +static func home_harvest(deme: EvoTypes.Deme, cell: EvoTypes.HexCell, state: EvoTypes.WorldState, action_type: String) -> float: + return minf(cell.current_resources, home_demand(deme, state, action_type)) + + +static func expected_income(deme: EvoTypes.Deme, state: EvoTypes.WorldState, action_type: String) -> float: + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var base := home_harvest(deme, cell, state, action_type) + EvoRuralHinterland.rural_yield_estimate(deme, state) + return base * pact_multiplier(deme, state) + + +static func pact_multiplier(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var pacts := 0 + for pk in state.relations.keys(): + if EvoTypes.pair_partner(str(pk), deme.id) != "" and (state.relations[pk] as EvoTypes.Relation).pact_turns > 0: + pacts += 1 + return 1.0 + PACT_HARVEST_BONUS * float(mini(pacts, 3)) + + +static func trade_gain(a: EvoTypes.Deme, b: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var ca: EvoTypes.HexCell = state.cells[a.hex_id] + var cb: EvoTypes.HexCell = state.cells[b.hex_id] + var complementarity := 1.0 + (0.4 if ca.biome != cb.biome else 0.0) + (0.2 if a.cultural_trait_ids != b.cultural_trait_ids else 0.0) + var volume := minf(expected_income(a, state, "none"), expected_income(b, state, "none")) + return TRADE_SHARE * volume * complementarity + + +static func unreciprocated_trade_gain(a: EvoTypes.Deme, b: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + return (0.75 if EvoTypes.is_pact_active(state, a.id, b.id) else 0.55) * trade_gain(a, b, state) + + +static func aid_amount(deme: EvoTypes.Deme) -> float: + return roundf(clampf(deme.energy_reserves * 0.2, 5.0, 20.0)) + + +static func tribute_amount(target: EvoTypes.Deme) -> float: + return roundf(clampf(target.energy_reserves * 0.25, 8.0, 40.0)) + + +static func storage_cap(deme: EvoTypes.Deme) -> float: + return 80.0 + float(deme.population) + + +static func growth_rate(deme: EvoTypes.Deme) -> float: + var life_history := 1.35 if deme.biological_trait_ids.has("bio-r-strategy") else (0.85 if deme.biological_trait_ids.has("bio-k-strategy") else 1.0) + return BASE_GROWTH_RATE * (0.3 + 0.7 * deme.salubrity) * life_history + + +static func settlers(deme: EvoTypes.Deme) -> int: + return clampi(int(roundf(float(deme.population) * 0.30)), 24, deme.population - 50) + + +static func can_fortify(deme: EvoTypes.Deme, cell: EvoTypes.HexCell) -> bool: + var tier := deme.defense_tier + return ( + tier < 3 + and deme.population >= FORTIFY_MIN_POP[tier] + and deme.energy_reserves >= FORTIFY_COST_RESERVES[tier] + and cell.current_resources >= FORTIFY_COST_HEX[tier] + ) + + +static func innovation_step(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + return INNOVATION_STEP + 0.2 * vector(deme, state).transmission_threshold + + +# --- Combat ------------------------------------------------------------------ + +static func attack_multiplier(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var v := vector(deme, state) + var martial := 0.30 if deme.cultural_trait_ids.has("cul-warrior-expansion") else 0.0 + return 1.0 + 0.6 * maxf(0.0, v.spatial_mobility) + 0.2 * maxf(0.0, v.extraction_efficiency) + martial + + +static func cohesion_multiplier(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + return 1.0 + 0.6 * maxf(0.0, vector(deme, state).social_cohesion) + + +static func defense_power( + defender: EvoTypes.Deme, + attacker_id: String, + mobilized_share: float, + with_allies: bool, + state: EvoTypes.WorldState +) -> float: + var power := float(defender.population) * mobilized_share * cohesion_multiplier(defender, state) * (1.0 + WALL_DEFENSE[defender.defense_tier]) + if not with_allies: + return power + var dcell: EvoTypes.HexCell = state.cells[defender.hex_id] + for nid in HexMath.get_neighbor_ids(dcell.q, dcell.r, state.cells): + for oid in (state.cells[nid] as EvoTypes.HexCell).deme_ids: + if oid == attacker_id or oid == defender.id: + continue + var other: EvoTypes.Deme = state.demes[oid] + var same_union := defender.walled_union_id != "" and other.walled_union_id == defender.walled_union_id + if same_union or EvoTypes.is_pact_active(state, defender.id, oid): + power += ALLY_SUPPORT_SHARE * float(other.population) * cohesion_multiplier(other, state) + return power + + +static func win_probability(attack: float, defense: float) -> float: + var a := pow(maxf(attack, 0.0), COMBAT_EXPONENT) + var d := pow(maxf(defense, 0.0), COMBAT_EXPONENT) + return a / (a + d) if a + d > 0.0 else 0.0 + + +## Odds and stakes of a raid (or a surprise raid on an ally when `surprise` is true). +static func raid_odds(attacker: EvoTypes.Deme, defender: EvoTypes.Deme, state: EvoTypes.WorldState, surprise: bool) -> Dictionary: + if not surprise and defender.defense_tier >= 3: + return { + "p_win": 0.0, + "troops": int(roundf(float(attacker.population) * RAID_TROOP_SHARE)), + "loot": 0.0, + "attacker_loss_win": 0, + "defender_loss_win": 0, + "attacker_loss_lose": int(roundf(float(attacker.population) * RAID_TROOP_SHARE * 0.30)), + "defender_loss_lose": 1 + } + var troops := int(roundf(float(attacker.population) * RAID_TROOP_SHARE)) + var attack := float(troops) * attack_multiplier(attacker, state) + var mobilized := SURPRISE_MOBILIZATION if surprise else RAID_MOBILIZATION + var defense := defense_power(defender, attacker.id, mobilized, not surprise, state) + var loot_share := 0.75 if surprise else 0.45 + var shield := 0.25 * WALL_LOOT_SHIELD[defender.defense_tier] if surprise else WALL_LOOT_SHIELD[defender.defense_tier] + var cap_mult := 3.0 if surprise else 2.2 + var loot := minf(defender.energy_reserves * loot_share, float(troops) * cap_mult) * (1.0 - shield) + return { + "p_win": win_probability(attack, defense), + "troops": troops, + "loot": roundf(loot), + "attacker_loss_win": int(roundf(float(troops) * 0.05)), + "defender_loss_win": int(roundf(float(troops) * 0.15)), + "attacker_loss_lose": int(roundf(float(troops) * 0.25)), + "defender_loss_lose": int(roundf(float(troops) * 0.04)) + } + + +static func conquest_odds(attacker: EvoTypes.Deme, defender: EvoTypes.Deme, state: EvoTypes.WorldState) -> Dictionary: + var troops := int(roundf(float(attacker.population) * CONQUEST_TROOP_SHARE)) + var attack := float(troops) * attack_multiplier(attacker, state) + var defense := defense_power(defender, attacker.id, CONQUEST_MOBILIZATION, true, state) + return { + "p_win": win_probability(attack, defense), + "troops": troops, + "attacker_loss_win": int(roundf(float(troops) * 0.15)), + "defender_loss_win": int(roundf(float(defender.population) * 0.30)), + "attacker_loss_lose": int(roundf(float(troops) * 0.40)), + "defender_loss_lose": int(roundf(float(defender.population) * 0.06)) + } diff --git a/src/engine/biophys/payoffs.gd.uid b/src/engine/biophys/payoffs.gd.uid new file mode 100644 index 0000000..8f17106 --- /dev/null +++ b/src/engine/biophys/payoffs.gd.uid @@ -0,0 +1 @@ +uid://b23o5wo56ckxy diff --git a/src/engine/biophys/relations_memory.gd b/src/engine/biophys/relations_memory.gd new file mode 100644 index 0000000..38c416e --- /dev/null +++ b/src/engine/biophys/relations_memory.gd @@ -0,0 +1,125 @@ +class_name EvoRelations +extends RefCounted + +## Bilateral trust, feuds, truces, proposals, public reputation and per-settlement memory. + +const MEMORY_LENGTH := 8 + + +static func remember(deme: EvoTypes.Deme, state: EvoTypes.WorldState, text: String) -> void: + deme.memory.push_front("T%d: %s" % [state.generation, text]) + if deme.memory.size() > MEMORY_LENGTH: + deme.memory.resize(MEMORY_LENGTH) + + +static func record(state: EvoTypes.WorldState, a: EvoTypes.Deme, b: EvoTypes.Deme, trust_delta: float, event_text: String) -> EvoTypes.Relation: + var rel := EvoTypes.get_relation(state, a.id, b.id) + rel.trust = clampf(rel.trust + trust_delta, -1.0, 1.0) + rel.last_event = "T%d %s" % [state.generation, event_text] + return rel + + +static func start_feud(rel: EvoTypes.Relation, turns: int) -> void: + rel.feud_turns = maxi(rel.feud_turns, turns) + rel.pact_turns = 0 + rel.truce_turns = 0 + rel.proposal_from = "" + rel.proposal_kind = "" + + +static func adjust_reputation(deme: EvoTypes.Deme, delta: float) -> void: + deme.reputation = clampf(deme.reputation + delta, -1.0, 1.0) + + +static func propose(state: EvoTypes.WorldState, from: EvoTypes.Deme, to: EvoTypes.Deme, kind: String, amount: float) -> void: + var rel := EvoTypes.get_relation(state, from.id, to.id) + rel.proposal_from = from.id + rel.proposal_kind = kind + rel.proposal_amount = amount + rel.proposal_gen = state.generation + + +## A proposal that `responder` may answer this turn. +static func has_open_proposal(state: EvoTypes.WorldState, proposer_id: String, responder_id: String, kind: String) -> bool: + var key := EvoTypes.make_pair_key(proposer_id, responder_id) + if not state.relations.has(key): + return false + var rel: EvoTypes.Relation = state.relations[key] + return rel.proposal_from == proposer_id and rel.proposal_kind == kind + + +static func clear_proposal(rel: EvoTypes.Relation) -> void: + rel.proposal_from = "" + rel.proposal_kind = "" + rel.proposal_amount = 0.0 + rel.proposal_gen = -1 + + +## End-of-turn bookkeeping: timers advance and unanswered proposals expire. +static func tick(state: EvoTypes.WorldState) -> void: + for pk in state.relations.keys(): + var rel: EvoTypes.Relation = state.relations[pk] + if rel.pact_turns > 0: + rel.pact_turns += 1 + rel.feud_turns = maxi(0, rel.feud_turns - 1) + rel.truce_turns = maxi(0, rel.truce_turns - 1) + if rel.proposal_from != "" and rel.proposal_gen < state.generation: + if rel.proposal_kind == "tribute": + _refuse_tribute(state, str(pk), rel) + clear_proposal(rel) + + var friendly_pairs: Dictionary = {} + for fl in state.active_flows: + if fl.flow_type in ["exchange", "cooperate", "pact"]: + friendly_pairs[EvoTypes.make_pair_key(fl.from_deme_id, fl.to_deme_id)] = true + var updated_disused: Array[Dictionary] = [] + for conn in state.disused_connections: + var pk := EvoTypes.make_pair_key(str(conn["from_deme_id"]), str(conn["to_deme_id"])) + if friendly_pairs.has(pk): + continue + if int(conn["age"]) == 0: + conn["age"] = 1 + updated_disused.append(conn) + state.disused_connections = updated_disused + + +static func _refuse_tribute(state: EvoTypes.WorldState, pair_key: String, rel: EvoTypes.Relation) -> void: + var demander_id := rel.proposal_from + var target_id := EvoTypes.pair_partner(pair_key, demander_id) + if not state.demes.has(demander_id) or not state.demes.has(target_id): + return + var demander: EvoTypes.Deme = state.demes[demander_id] + var target: EvoTypes.Deme = state.demes[target_id] + rel.feud_turns = maxi(rel.feud_turns, EvoPayoffs.FEUD_AFTER_REFUSED_TRIBUTE) + rel.trust = clampf(rel.trust - 0.2, -1.0, 1.0) + rel.last_event = "T%d %s refused to pay tribute to %s" % [state.generation, target.name, demander.name] + remember(demander, state, "%s refused your tribute demand." % target.name) + remember(target, state, "You refused %s's tribute demand." % demander.name) + var ca: EvoTypes.HexCell = state.cells[demander.hex_id] + var cb: EvoTypes.HexCell = state.cells[target.hex_id] + if HexMath.hex_distance(ca.q, ca.r, cb.q, cb.r) == 1 and target.defense_tier < 3: + EvoConflictPhase._raid(demander, target, rel, state, "en", false) + + +static func trust_word(trust: float) -> String: + if trust >= 0.5: + return "high" + if trust >= 0.15: + return "good" + if trust > -0.15: + return "neutral" + if trust > -0.5: + return "low" + return "hostile" + + +static func reputation_word(reputation: float) -> String: + if reputation >= 0.3: + return "generous and reliable" + if reputation >= 0.1: + return "reliable" + if reputation > -0.15: + return "unremarkable" + if reputation > -0.4: + return "aggressive" + return "treacherous" diff --git a/src/engine/biophys/relations_memory.gd.uid b/src/engine/biophys/relations_memory.gd.uid new file mode 100644 index 0000000..bf10fc7 --- /dev/null +++ b/src/engine/biophys/relations_memory.gd.uid @@ -0,0 +1 @@ +uid://b3bsmsdkpd3tf diff --git a/src/engine/biophys/rural_hinterland.gd b/src/engine/biophys/rural_hinterland.gd new file mode 100644 index 0000000..acdd5b0 --- /dev/null +++ b/src/engine/biophys/rural_hinterland.gd @@ -0,0 +1,122 @@ +class_name EvoRuralHinterland +extends RefCounted + + +static func get_max_rural_quota(tier: int) -> int: + return 0 if tier < 2 else (1 if tier == 2 else (2 if tier == 3 else 4)) + + +static func _release_all_deme_rural_hexes(state: EvoTypes.WorldState, deme_id: String) -> void: + for cid in state.cells.keys(): + var cell: EvoTypes.HexCell = state.cells[cid] + if cell.owner_deme_id == deme_id: + cell.owner_deme_id = "" + cell.land_use = "wild" + + +static func _score_rural_candidate(cell: EvoTypes.HexCell) -> float: + return cell.current_resources + + +static func enforce_rural_hinterland_quotas(state: EvoTypes.WorldState) -> void: + for cid in state.cells.keys(): + var cell: EvoTypes.HexCell = state.cells[cid] + if not cell.deme_ids.is_empty() or cell.biome in ["ocean", "high_mountain"] or cell.altitude > 0.85: + cell.land_use = "wild" + cell.owner_deme_id = "" + if not cell.deme_ids.is_empty() or cell.biome == "ocean": + cell.has_ruins = false + elif cell.owner_deme_id != "": + if not state.demes.has(cell.owner_deme_id): + cell.land_use = "wild" + cell.owner_deme_id = "" + else: + var owner: EvoTypes.Deme = state.demes[cell.owner_deme_id] + if not state.cells.has(owner.hex_id): + cell.land_use = "wild" + cell.owner_deme_id = "" + else: + var ocell: EvoTypes.HexCell = state.cells[owner.hex_id] + if HexMath.hex_distance(cell.q, cell.r, ocell.q, ocell.r) != 1: + cell.land_use = "wild" + cell.owner_deme_id = "" + + for did in state.demes.keys(): + var deme: EvoTypes.Deme = state.demes[did] + if not state.cells.has(deme.hex_id): + continue + var dcell: EvoTypes.HexCell = state.cells[deme.hex_id] + var tier := deme.get_village_tier() + var max_rural: int = get_max_rural_quota(tier) + if dcell.biome in ["ocean", "high_mountain"] or dcell.altitude > 0.85: + max_rural = 0 + + var owned_neighbors: Array[EvoTypes.HexCell] = [] + for nid in HexMath.get_neighbor_ids(dcell.q, dcell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if ncell.owner_deme_id == deme.id: + owned_neighbors.append(ncell) + + if owned_neighbors.size() > max_rural: + owned_neighbors.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: + return _score_rural_candidate(a) < _score_rural_candidate(b) + ) + while owned_neighbors.size() > max_rural: + var rel_cell: EvoTypes.HexCell = owned_neighbors.pop_front() + rel_cell.owner_deme_id = "" + rel_cell.land_use = "wild" + + +static func claim_hinterlands(state: EvoTypes.WorldState) -> void: + enforce_rural_hinterland_quotas(state) + for did in state.demes.keys(): + var deme: EvoTypes.Deme = state.demes[did] + var dcell: EvoTypes.HexCell = state.cells[deme.hex_id] + var max_rural := get_max_rural_quota(deme.get_village_tier()) + var owned := owned_hexes(deme, state) + if owned.size() >= max_rural: + continue + var unclaimed: Array[EvoTypes.HexCell] = [] + for nid in HexMath.get_neighbor_ids(dcell.q, dcell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if ncell.deme_ids.is_empty() and ncell.owner_deme_id == "" and ncell.biome not in ["ocean", "high_mountain"] and ncell.altitude <= 0.85: + unclaimed.append(ncell) + unclaimed.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: + return _score_rural_candidate(a) > _score_rural_candidate(b) + ) + for ncell in unclaimed.slice(0, max_rural - owned.size()): + ncell.owner_deme_id = deme.id + ncell.has_ruins = false + ncell.land_use = "pasture" if ncell.biome in ["arid_steppe", "tundra"] else "crops" + + +static func owned_hexes(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Array[EvoTypes.HexCell]: + var dcell: EvoTypes.HexCell = state.cells[deme.hex_id] + var owned: Array[EvoTypes.HexCell] = [] + for nid in HexMath.get_neighbor_ids(dcell.q, dcell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if ncell.owner_deme_id == deme.id: + owned.append(ncell) + return owned + + +static func _hex_yield(deme: EvoTypes.Deme, ncell: EvoTypes.HexCell, state: EvoTypes.WorldState) -> float: + var extraction := EvoPayoffs.vector(deme, state).extraction_efficiency + var cap_mult := clampf(0.16 + 0.12 * maxf(0.0, extraction), 0.16, 0.30) + return minf(ncell.current_resources, float(deme.population) * cap_mult) * 0.65 + + +static func rural_yield_estimate(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var total := 0.0 + for ncell in owned_hexes(deme, state): + total += _hex_yield(deme, ncell, state) + return total + + +static func harvest_rural(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: + var total := 0.0 + for ncell in owned_hexes(deme, state): + var harvest := _hex_yield(deme, ncell, state) + ncell.current_resources = maxf(0.0, ncell.current_resources - harvest) + total += harvest + return total diff --git a/src/engine/biophys/rural_hinterland.gd.uid b/src/engine/biophys/rural_hinterland.gd.uid new file mode 100644 index 0000000..cf77362 --- /dev/null +++ b/src/engine/biophys/rural_hinterland.gd.uid @@ -0,0 +1 @@ +uid://hbtgcmfs2fvy diff --git a/src/engine/biophys/settlement_phase.gd b/src/engine/biophys/settlement_phase.gd new file mode 100644 index 0000000..96f891e --- /dev/null +++ b/src/engine/biophys/settlement_phase.gd @@ -0,0 +1,308 @@ +class_name EvoSettlementPhase +extends RefCounted + +## Settlement lifecycle: founding camps, occupying conquered hexes, synoecism and +## walled unions, extinction and isolation tracking for allopatric speciation. + + +static func strip_migration_suffix(id_str: String) -> String: + for i in range(1, id_str.length() - 2): + if id_str[i] == "-" and id_str[i + 1] in ["m", "w"] and id_str[i + 2].is_valid_int(): + return id_str.substr(0, i) + return id_str + + +static func strip_coord_suffix(name_str: String) -> String: + var open_idx := name_str.rfind(" (") + if open_idx > 0 and name_str.ends_with(")") and name_str.substr(open_idx).contains(","): + return name_str.substr(0, open_idx) + return name_str + + +static func compute_deme_divergence(deme_a: EvoTypes.Deme, deme_b: EvoTypes.Deme) -> float: + var set_a: Dictionary = {} + for tid in deme_a.biological_trait_ids + deme_a.cultural_trait_ids: + set_a[tid] = true + var set_b: Dictionary = {} + for tid in deme_b.biological_trait_ids + deme_b.cultural_trait_ids: + set_b[tid] = true + var union_size := set_a.size() + var shared := 0 + for tid in set_b.keys(): + if set_a.has(tid): + shared += 1 + else: + union_size += 1 + return 0.0 if union_size == 0 else snappedf(1.0 - float(shared) / float(union_size), 0.0001) + + +static func _new_daughter(parent: EvoTypes.Deme, marker: String, hex: EvoTypes.HexCell, state: EvoTypes.WorldState) -> EvoTypes.Deme: + var root_id := strip_migration_suffix(parent.id) + var daughter_id := "%s-%s%d-%d_%d" % [root_id, marker, state.generation, hex.q, hex.r] + var daughter := EvoTypes.Deme.new() + daughter.id = daughter_id + daughter.name = ("%s (%s)" % [strip_coord_suffix(parent.name), hex.id]).substr(0, 60) + daughter.lineage_id = parent.lineage_id + daughter.strategy_id = parent.strategy_id + daughter.color = parent.color + daughter.hex_id = hex.id + daughter.biological_trait_ids = parent.biological_trait_ids.duplicate() + daughter.cultural_trait_ids = parent.cultural_trait_ids.duplicate() + daughter.decision_model_tag = parent.decision_model_tag + daughter.reputation = parent.reputation + daughter.salubrity = parent.salubrity + hex.owner_deme_id = "" + hex.land_use = "wild" + hex.has_ruins = false + hex.deme_ids.append(daughter_id) + state.demes[daughter_id] = daughter + return daughter + + +static func resolve_settling(state: EvoTypes.WorldState) -> void: + for did in state.demes.keys(): + if not state.demes.has(did): + continue + var deme: EvoTypes.Deme = state.demes[did] + if str(deme.chosen_action["type"]) != "settle": + continue + var target_id := str(deme.chosen_action["target"]) + var valid_targets := EvoActionMenu.settle_targets(deme, state).filter(func(c: EvoTypes.HexCell) -> bool: return c.id == target_id) + if deme.population < EvoPayoffs.SETTLE_MIN_POP or valid_targets.is_empty(): + deme.last_outcome = "Your settlers could not leave: the land at %s is no longer free or you are too few." % target_id + continue + var daughter := found_camp(deme, valid_targets[0], state) + deme.last_outcome = "You founded a camp at %s with %d settlers." % [target_id, daughter.population] + + +static func found_camp(deme: EvoTypes.Deme, hex: EvoTypes.HexCell, state: EvoTypes.WorldState) -> EvoTypes.Deme: + var n := EvoPayoffs.settlers(deme) + var food := deme.energy_reserves * float(n) / float(deme.population) + var daughter := _new_daughter(deme, "m", hex, state) + daughter.population = n + daughter.energy_reserves = food + deme.population -= n + deme.energy_reserves -= food + EvoRelations.remember(daughter, state, "Founded by settlers from %s." % deme.name) + _push_flow(state, deme.hex_id, hex.id, "migration", 0.6, deme.id, daughter.id) + EvoTypes.push_event(state, "colonize", deme.hex_id, hex.id, deme.id, daughter.id, float(n), 0) + return daughter + + +## The defender leaves its hex to the conquering warband: it flees to the richest +## empty neighbor hex, or is absorbed (one third of survivors join) if surrounded. +static func occupy_conquered_hex( + attacker: EvoTypes.Deme, + defender: EvoTypes.Deme, + warband_pop: int, + loot: float, + captured_tier: int, + state: EvoTypes.WorldState +) -> EvoTypes.Deme: + var target_cell: EvoTypes.HexCell = state.cells[defender.hex_id] + var refuge: EvoTypes.HexCell = null + for nid in HexMath.get_neighbor_ids(target_cell.q, target_cell.r, state.cells): + var rcell: EvoTypes.HexCell = state.cells[nid] + if EvoActionMenu.is_passable(rcell) and rcell.deme_ids.is_empty() and (refuge == null or rcell.current_resources > refuge.current_resources): + refuge = rcell + var captured_fields: Array[EvoTypes.HexCell] = EvoRuralHinterland.owned_hexes(defender, state) + target_cell.deme_ids.erase(defender.id) + var absorbed := 0 + if refuge != null and defender.population >= EvoPayoffs.MIN_VIABLE_POP: + refuge.owner_deme_id = "" + refuge.land_use = "wild" + refuge.has_ruins = false + refuge.deme_ids.append(defender.id) + defender.hex_id = refuge.id + _push_flow(state, target_cell.id, refuge.id, "migration", 0.5, defender.id, defender.id) + else: + absorbed = defender.population / 3 + defender.population = 0 + defender.defense_tier = 0 + defender.walled_union_id = "" + + var warband := _new_daughter(attacker, "w", target_cell, state) + warband.population = warband_pop + absorbed + warband.energy_reserves = loot + warband.defense_tier = captured_tier + for field in captured_fields: + field.owner_deme_id = warband.id + return warband + + +static func unite(a: EvoTypes.Deme, b: EvoTypes.Deme, state: EvoTypes.WorldState, lang: String) -> void: + var ca: EvoTypes.HexCell = state.cells[a.hex_id] + var cb: EvoTypes.HexCell = state.cells[b.hex_id] + if EvoOutcomeForecast.union_kind(a, b) == "walled_union": + var union_id := a.walled_union_id if a.walled_union_id != "" else (b.walled_union_id if b.walled_union_id != "" else "union-%s" % EvoTypes.make_pair_key(a.id, b.id)) + a.walled_union_id = union_id + b.walled_union_id = union_id + a.defense_tier = maxi(a.defense_tier, 1) + b.defense_tier = maxi(b.defense_tier, 1) + EvoTypes.push_event(state, "walled_union", ca.id, cb.id, a.id, b.id, 1.0, 0) + _push_flow(state, ca.id, cb.id, "merge", 0.85, a.id, b.id) + var params := {"deme_a": a.name, "deme_b": b.name, "hex": "%s <-> %s" % [ca.id, cb.id]} + _chronicle(state, "chronicle-union-%d-%s-%s" % [state.generation, a.id, b.id], "merge", + EvoI18n.t(lang, "chronicle_walled_union_title", params), EvoI18n.t(lang, "chronicle_walled_union_desc", params), ca.id, a) + return + var a_hosts := a.get_village_tier() > b.get_village_tier() or (a.get_village_tier() == b.get_village_tier() and a.population >= b.population) + var host := a if a_hosts else b + var joiner := b if a_hosts else a + merge_into(host, joiner, state, lang) + + +static func merge_into(host: EvoTypes.Deme, joiner: EvoTypes.Deme, state: EvoTypes.WorldState, lang: String) -> void: + var host_cell: EvoTypes.HexCell = state.cells[host.hex_id] + var joiner_cell: EvoTypes.HexCell = state.cells[joiner.hex_id] + var params := {"deme_a": host.name, "deme_b": joiner.name, "hex": host_cell.id, "pop": str(host.population + joiner.population)} + host.population += joiner.population + host.energy_reserves = minf(EvoPayoffs.storage_cap(host), host.energy_reserves + joiner.energy_reserves) + host.defense_tier = maxi(host.defense_tier, joiner.defense_tier) + host.merged_count += 1 + joiner.merged_count + host.innovation_counter = maxf(host.innovation_counter, joiner.innovation_counter) + if joiner.color != host.color: + host.secondary_color = joiner.color + for tid in joiner.biological_trait_ids + joiner.cultural_trait_ids: + if EvoCulture.can_acquire_trait(host, tid, state.traits_catalog): + if (state.traits_catalog[tid] as EvoTypes.TraitDef).category == "biological": + host.biological_trait_ids.append(tid) + else: + host.cultural_trait_ids.append(tid) + host_cell.carrying_capacity = minf(host_cell.carrying_capacity_max * 1.25, host_cell.carrying_capacity + 20.0) + for field in EvoRuralHinterland.owned_hexes(joiner, state): + field.owner_deme_id = host.id if HexMath.hex_distance(field.q, field.r, host_cell.q, host_cell.r) == 1 else "" + joiner_cell.deme_ids.erase(joiner.id) + joiner_cell.owner_deme_id = host.id + joiner_cell.land_use = "pasture" if joiner_cell.biome in ["arid_steppe", "tundra"] else "crops" + for pk in state.relations.keys(): + var partner := EvoTypes.pair_partner(str(pk), joiner.id) + if partner != "" and partner != host.id and not state.relations.has(EvoTypes.make_pair_key(host.id, partner)): + state.relations[EvoTypes.make_pair_key(host.id, partner)] = state.relations[pk] + EvoTypes.erase_deme_relations(state, joiner.id) + state.demes.erase(joiner.id) + EvoTypes.push_event(state, "absorption", joiner_cell.id, host_cell.id, host.id, joiner.id, float(joiner.population), 0) + _push_flow(state, joiner_cell.id, host_cell.id, "merge", 0.85, host.id, host.id) + EvoRelations.remember(host, state, "%s joined your settlement." % joiner.name) + _chronicle(state, "chronicle-merge-%d-%s-%s" % [state.generation, host.id, joiner.id], "merge", + EvoI18n.t(lang, "chronicle_merge_title", params), EvoI18n.t(lang, "chronicle_merge_desc", params), host_cell.id, host) + + +static func remove_extinct(state: EvoTypes.WorldState, lang: String) -> void: + var is_en := lang == "en" + for did in state.demes.keys(): + var deme: EvoTypes.Deme = state.demes[did] + if deme.population >= EvoPayoffs.MIN_VIABLE_POP: + continue + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + cell.deme_ids.erase(deme.id) + if deme.population > 0: + cell.has_ruins = cell.biome != "ocean" + for field in EvoRuralHinterland.owned_hexes(deme, state): + field.owner_deme_id = "" + field.land_use = "wild" + field.has_ruins = true + EvoTypes.push_event(state, "extinction", deme.hex_id, deme.hex_id, deme.id, "", 0.0, deme.population) + EvoTypes.erase_deme_relations(state, deme.id) + state.demes.erase(deme.id) + if not state.recent_extinction_hexes.has(deme.hex_id): + state.recent_extinction_hexes.append(deme.hex_id) + var title := ("%s Extinct" if is_en else "%s Extinguió") % deme.name + var desc := ( + "%s fell below minimum viable population in hex (%s)." % [deme.name, deme.hex_id] + if is_en + else "%s cayó por debajo de la población mínima viable en el hexágono (%s)." % [deme.name, deme.hex_id] + ) + _chronicle(state, "chronicle-extinct-%d-%s" % [state.generation, deme.id], "extinction", title, desc, deme.hex_id, deme) + mark_lineage_if_extinct(state, deme.lineage_id) + + +static func mark_lineage_if_extinct(state: EvoTypes.WorldState, lineage_id: String) -> void: + for d: EvoTypes.Deme in state.demes.values(): + if d.lineage_id == lineage_id: + return + if state.lineage_tree.has(lineage_id): + (state.lineage_tree[lineage_id] as EvoTypes.LineageNode).extinct_generation = state.generation + + +static func _is_connected_to_founder(deme: EvoTypes.Deme, founder_hex_id: String, state: EvoTypes.WorldState) -> bool: + var mobility := EvoPayoffs.vector(deme, state).spatial_mobility + var visited: Dictionary = {deme.hex_id: 0} + var queue: Array[String] = [deme.hex_id] + var head := 0 + while head < queue.size(): + var cur_id := queue[head] + head += 1 + if int(visited[cur_id]) >= 3: + continue + var cur_cell: EvoTypes.HexCell = state.cells[cur_id] + for nid in HexMath.get_neighbor_ids(cur_cell.q, cur_cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if visited.has(nid) or ncell.biome == "ocean" or ncell.altitude > 0.85 or ncell.altitude - 0.50 > mobility: + continue + if nid == founder_hex_id: + return true + for oid in ncell.deme_ids: + if (state.demes[oid] as EvoTypes.Deme).lineage_id == deme.lineage_id: + return true + visited[nid] = int(visited[cur_id]) + 1 + queue.append(nid) + return false + + +static func update_isolation(state: EvoTypes.WorldState) -> void: + for d: EvoTypes.Deme in state.demes.values(): + var founder_hex := (state.lineage_tree[d.lineage_id] as EvoTypes.LineageNode).founder_hex_id + if d.hex_id == founder_hex or _is_connected_to_founder(d, founder_hex, state): + d.isolation_turns = 0 + else: + d.isolation_turns += 1 + + +static func revalidate_walled_unions(state: EvoTypes.WorldState) -> void: + var changed := true + while changed: + changed = false + for d: EvoTypes.Deme in state.demes.values(): + if d.walled_union_id == "": + continue + var has_partner := false + if d.get_village_tier() >= 2 and EvoActionMenu.is_passable(state.cells[d.hex_id]): + for other in EvoActionMenu.neighbor_demes(d, state): + if other.walled_union_id == d.walled_union_id and other.get_village_tier() >= 2 and not EvoTypes.is_vendetta_active(state, d.id, other.id): + has_partner = true + break + if not has_partner: + d.walled_union_id = "" + changed = true + + +static func _push_flow(state: EvoTypes.WorldState, from_hex: String, to_hex: String, flow_type: String, intensity: float, from_deme: String, to_deme: String) -> void: + var flow := EvoTypes.HexEdgeFlow.new() + flow.from_hex_id = from_hex + flow.to_hex_id = to_hex + flow.flow_type = flow_type + flow.intensity = intensity + flow.from_deme_id = from_deme + flow.to_deme_id = to_deme + state.active_flows.append(flow) + + +static func push_flow(state: EvoTypes.WorldState, a: EvoTypes.Deme, b: EvoTypes.Deme, flow_type: String, intensity: float) -> void: + _push_flow(state, a.hex_id, b.hex_id, flow_type, intensity, a.id, b.id) + + +static func _chronicle(state: EvoTypes.WorldState, entry_id: String, event_type: String, title: String, desc: String, hex_id: String, deme: EvoTypes.Deme) -> void: + var entry := EvoTypes.ChronicleEntry.new() + entry.id = entry_id + entry.generation = state.generation + entry.event_type = event_type + entry.title = title + entry.causal_explanation = desc + entry.hex_id = hex_id + entry.deme_id = deme.id + entry.lineage_id = deme.lineage_id + state.chronicle.append(entry) + + +static func chronicle(state: EvoTypes.WorldState, entry_id: String, event_type: String, lang: String, key: String, params: Dictionary, hex_id: String, deme: EvoTypes.Deme) -> void: + _chronicle(state, entry_id, event_type, EvoI18n.t(lang, key + "_title", params), EvoI18n.t(lang, key + "_desc", params), hex_id, deme) diff --git a/src/engine/biophys/settlement_phase.gd.uid b/src/engine/biophys/settlement_phase.gd.uid new file mode 100644 index 0000000..8721410 --- /dev/null +++ b/src/engine/biophys/settlement_phase.gd.uid @@ -0,0 +1 @@ +uid://d0db7mekogxhs diff --git a/src/engine/biophys_phase.gd b/src/engine/biophys_phase.gd index 6c4b9f8..19600a4 100644 --- a/src/engine/biophys_phase.gd +++ b/src/engine/biophys_phase.gd @@ -2,78 +2,24 @@ class_name EvoBiophysPhase extends RefCounted -static func compute_effective_vector( - deme: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> EvoTypes.BiophysicalVector: - return EvoEcologyInteraction.compute_effective_vector(deme, state) - - -static func get_archetype_prob( - deme: EvoTypes.Deme, - decisions_catalog: Dictionary, - archetype: String -) -> float: - return EvoEcologyInteraction.get_archetype_prob(deme, decisions_catalog, archetype) - - -static func is_hex_passable_for_deme(cell: EvoTypes.HexCell, spatial_mobility: float) -> bool: - return EvoDemographyMigration.is_hex_passable_for_deme(cell, spatial_mobility) - - -static func compute_deme_divergence(deme_a: EvoTypes.Deme, deme_b: EvoTypes.Deme) -> float: - return EvoDemographyMigration.compute_deme_divergence(deme_a, deme_b) - - -static func _build_edge_flows( - state: EvoTypes.WorldState, - turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow] -) -> void: - if turn_geopolitical_flows.is_empty(): - return - var active_set: Dictionary = {} - for fl in state.active_flows: - active_set[fl] = true - for geo_flow in turn_geopolitical_flows: - if geo_flow.from_deme_id == geo_flow.to_deme_id and geo_flow.from_hex_id == geo_flow.to_hex_id and geo_flow.flow_type != "migration": - continue - if not active_set.has(geo_flow): - active_set[geo_flow] = true - state.active_flows.append(geo_flow) - - static func run_biophysical_phase(state: EvoTypes.WorldState, lang: String = "es") -> void: - var initial_pop_by_deme: Dictionary = {} - var effective_vec_by_deme: Dictionary = {} - var hawk_penalty_by_deme: Dictionary = {} - var extracted_energy_by_deme: Dictionary = {} - var turn_geopolitical_flows: Array[EvoTypes.HexEdgeFlow] = [] - for did in state.demes.keys(): var deme: EvoTypes.Deme = state.demes[did] - if deme.last_decision_probabilities.is_empty() and state.cells.has(deme.hex_id): - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(deme, cell, state) - initial_pop_by_deme[deme.id] = deme.population - hawk_penalty_by_deme[deme.id] = 1.0 - extracted_energy_by_deme[deme.id] = 0.0 - effective_vec_by_deme[deme.id] = compute_effective_vector(deme, state) - - EvoEcologyInteraction.run_resource_and_symbiosis_phase( - state, - lang, - effective_vec_by_deme, - hawk_penalty_by_deme, - extracted_energy_by_deme, - turn_geopolitical_flows - ) - EvoDemographyMigration.run_demography_and_migration_phase( - state, - lang, - initial_pop_by_deme, - effective_vec_by_deme, - hawk_penalty_by_deme, - extracted_energy_by_deme, - turn_geopolitical_flows - ) - _build_edge_flows(state, turn_geopolitical_flows) + var action_type := str(deme.chosen_action["type"]) + if action_type != "forage": + var prev_cnt := int(deme.baldwin_counters[action_type]) if deme.baldwin_counters.has(action_type) else 0 + deme.baldwin_counters[action_type] = prev_cnt + 1 + + var income_by_deme := EvoEconomyPhase.harvest(state, lang) + EvoEconomyPhase.invest(state, lang) + EvoDiplomacyPhase.resolve(state, lang) + EvoConflictPhase.resolve(state, lang) + EvoSettlementPhase.remove_extinct(state, lang) + EvoSettlementPhase.resolve_settling(state) + EvoEconomyPhase.metabolize(state, income_by_deme, lang) + EvoEpidemicSalubrity._run_epidemic_phase(state, lang, []) + EvoSettlementPhase.remove_extinct(state, lang) + EvoSettlementPhase.revalidate_walled_unions(state) + EvoRuralHinterland.enforce_rural_hinterland_quotas(state) + EvoSettlementPhase.update_isolation(state) + EvoRelations.tick(state) diff --git a/src/engine/cognitive/heuristic_scorer.gd b/src/engine/cognitive/heuristic_scorer.gd deleted file mode 100644 index da51f0d..0000000 --- a/src/engine/cognitive/heuristic_scorer.gd +++ /dev/null @@ -1,1261 +0,0 @@ -class_name EvoHeuristicScorer -extends RefCounted - -const DEFAULT_SYSTEM_ONE_MODEL: String = EvoUserSettings.DEFAULT_PRIMARY_MODEL -const SECONDARY_SYSTEM_ONE_MODEL: String = EvoUserSettings.DEFAULT_SECONDARY_MODEL - - -static func resolve_deme_decision_model_tag( - deme_id: String, - primary_tag: String = "", - secondary_tag: String = "" -) -> String: - var p_tag := primary_tag if primary_tag != "" else EvoUserSettings.primary_model - var s_tag := secondary_tag if secondary_tag != "" else EvoUserSettings.secondary_model - if deme_id in ["deme-rift-west", "deme-island-1", "deme-island-3", "deme-darwin-founder"]: - return p_tag - if deme_id in ["deme-rift-east", "deme-island-2", "deme-darwin-rival"]: - return s_tag - var h: int = 0 - for i in range(deme_id.length()): - h = (h * 31 + deme_id.unicode_at(i)) & 0x7FFFFFFF - return p_tag if (h % 2 == 0) else s_tag - - -static func is_decision_eligible( - deme: EvoTypes.Deme, - dec: EvoTypes.DecisionDef, - traits_catalog: Dictionary = {}, - state: EvoTypes.WorldState = null -) -> bool: - if dec.id in ["dec-explore-innovate", "dec-exchange-symbiosis"]: - var has_cap := false - if not traits_catalog.is_empty(): - has_cap = EvoCatalog.deme_has_cultural_capacity(deme, traits_catalog) - else: - for tid in deme.biological_trait_ids: - if tid in ["bio-encephalization", "bio-social-instinct"]: - has_cap = true - break - if not has_cap: - return false - if dec.required_trait_ids == ["bio-encephalization", "bio-social-instinct"]: - return true - if state != null and dec.id == "dec-territorial-dispute": - if not _has_nearby_rival_deme(deme, state): - return false - if dec.required_trait_ids.is_empty(): - return true - for req_id in dec.required_trait_ids: - if deme.biological_trait_ids.has(req_id) or deme.cultural_trait_ids.has(req_id): - return true - return false - - -static func _has_nearby_rival_deme(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> bool: - if state == null or not state.cells.has(deme.hex_id): - return true - var geo := inspect_geopolitical_context(deme, state.cells[deme.hex_id], state) - return bool(geo["has_intra_rival"]) or bool(geo["has_adjacent_rival"]) - - -static func get_eligible_decisions( - deme: EvoTypes.Deme, - decisions_catalog: Dictionary, - traits_catalog: Dictionary = {}, - state: EvoTypes.WorldState = null -) -> Array[EvoTypes.DecisionDef]: - var effective_traits := traits_catalog if not traits_catalog.is_empty() else (state.traits_catalog if state != null else {}) - var result: Array[EvoTypes.DecisionDef] = [] - for dec_id in decisions_catalog.keys(): - var dec: EvoTypes.DecisionDef = decisions_catalog[dec_id] - if is_decision_eligible(deme, dec, effective_traits): - result.append(dec) - if state != null and result.size() > 1 and not _has_nearby_rival_deme(deme, state): - var filtered: Array[EvoTypes.DecisionDef] = [] - for dec in result: - if dec.id != "dec-territorial-dispute": - filtered.append(dec) - if not filtered.is_empty(): - result = filtered - return result - - -static func get_eligible_decision_ids( - deme: EvoTypes.Deme, - decisions_catalog: Dictionary, - traits_catalog: Dictionary = {}, - state: EvoTypes.WorldState = null -) -> Array[String]: - var eligible_decs := get_eligible_decisions(deme, decisions_catalog, traits_catalog, state) - var ids: Array[String] = [] - for dec in eligible_decs: - ids.append(dec.id) - return ids - - -static func pick_dominant_decision(probs: Dictionary) -> String: - var best_id := "dec-conservative-forage" - var best_prob := -1.0 - for dec_id in probs.keys(): - var p := float(probs[dec_id]) - if p > best_prob: - best_prob = p - best_id = str(dec_id) - return best_id - - -static func _get_cell_total_population(cell: EvoTypes.HexCell, state: EvoTypes.WorldState) -> int: - if cell == null or state == null: - return 0 - var total_pop: int = 0 - for oid in cell.deme_ids: - if state.demes.has(oid): - total_pop += (state.demes[oid] as EvoTypes.Deme).population - return total_pop - - -static func inspect_geopolitical_context( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Dictionary: - var has_pacts: bool = state != null and not state.active_pacts.is_empty() - var pacts_hash: int = state.active_pacts.hash() if has_pacts else 0 - var cache_key := "%d|%s|%d|%d|%d|%d|%d|%d|%d|%.1f" % [ - state.generation if state != null else -1, - cell.id if cell != null else "", - pacts_hash, - state.chronicle.size() if state != null else 0, - state.demes.size() if state != null else 0, - cell.deme_ids.size() if cell != null else 0, - deme.population, - deme.defense_tier, - deme.merged_count, - deme.energy_reserves - ] - if deme != null and deme.has_meta("geo_ctx_key") and str(deme.get_meta("geo_ctx_key")) == cache_key: - return deme.get_meta("geo_ctx_val") as Dictionary - - var has_intra_rival := false - var has_adjacent_rival := false - var has_nearby_deme := false - var neighbor_has_surplus := false - var neighbor_is_weaker := false - var has_active_pact := false - var active_pact_count: int = 0 - var active_vendettas: int = 0 - var adjacent_rivals_count: int = 0 - var nearby_horde_threat := false - var max_neighbor_reserves: float = 0.0 - - if has_pacts: - var pacts_key := "%d|%d" % [state.generation, pacts_hash] - var pact_counts: Dictionary - var vendetta_counts: Dictionary - if state.has_meta("pact_counts_key") and str(state.get_meta("pact_counts_key")) == pacts_key and state.has_meta("vendetta_counts_val"): - pact_counts = state.get_meta("pact_counts_val") as Dictionary - vendetta_counts = state.get_meta("vendetta_counts_val") as Dictionary - else: - pact_counts = {} - vendetta_counts = {} - for pk_var in state.active_pacts.keys(): - var p_val := int(state.active_pacts[pk_var]) - if p_val >= 2 or p_val < 0: - var pk := str(pk_var) - var sep := "__" if pk.contains("__") else "|" - var parts := pk.split(sep) - if parts.size() == 2 and state.demes.has(parts[0]) and state.demes.has(parts[1]): - if p_val >= 2: - pact_counts[parts[0]] = int(pact_counts.get(parts[0], 0)) + 1 - pact_counts[parts[1]] = int(pact_counts.get(parts[1], 0)) + 1 - else: - vendetta_counts[parts[0]] = int(vendetta_counts.get(parts[0], 0)) + 1 - vendetta_counts[parts[1]] = int(vendetta_counts.get(parts[1], 0)) + 1 - state.set_meta("pact_counts_key", pacts_key) - state.set_meta("pact_counts_val", pact_counts) - state.set_meta("vendetta_counts_val", vendetta_counts) - active_pact_count = int(pact_counts.get(deme.id, 0)) - active_vendettas = int(vendetta_counts.get(deme.id, 0)) - has_active_pact = active_pact_count > 0 - - var cell_pop: int = deme.population - if cell != null and state != null: - var sum_pop: int = 0 - for oid in cell.deme_ids: - if state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - sum_pop += other.population - if other.id != deme.id: - has_nearby_deme = true - max_neighbor_reserves = maxf(max_neighbor_reserves, other.energy_reserves) - if other.lineage_id != deme.lineage_id and (not has_pacts or not EvoTypes.is_pact_active(state, deme.id, other.id)): - has_intra_rival = true - adjacent_rivals_count += 1 - if other.energy_reserves > deme.energy_reserves * 1.15: - neighbor_has_surplus = true - if other.population < int(float(deme.population) * 0.85) or other.energy_reserves < 45.0: - neighbor_is_weaker = true - if other.population >= 55 and (other.cultural_trait_ids.has("cul-warrior-expansion") or other.id.contains("-w") or other.last_dominant_decision_id == "dec-territorial-dispute"): - nearby_horde_threat = true - cell_pop = maxi(deme.population, sum_pop) - - for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - for oid in ncell.deme_ids: - if state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - if other.id != deme.id: - has_nearby_deme = true - max_neighbor_reserves = maxf(max_neighbor_reserves, other.energy_reserves) - if other.lineage_id != deme.lineage_id and (not has_pacts or not EvoTypes.is_pact_active(state, deme.id, other.id)): - has_adjacent_rival = true - adjacent_rivals_count += 1 - if ncell.current_resources > cell.current_resources * 1.15 or other.energy_reserves > deme.energy_reserves * 1.15: - neighbor_has_surplus = true - if other.population < int(float(deme.population) * 0.85) or other.energy_reserves < 45.0: - neighbor_is_weaker = true - if ncell.biome != "ocean" and ncell.altitude <= 0.85 and other.population >= 55: - if other.cultural_trait_ids.has("cul-warrior-expansion") or other.id.contains("-w") or other.last_dominant_decision_id == "dec-territorial-dispute": - nearby_horde_threat = true - if not nearby_horde_threat and ncell.biome != "ocean" and ncell.altitude <= 0.85: - var horde_chk_key := "%d|%d" % [state.generation, state.demes.size()] - var world_has_horde: bool = false - if state.has_meta("world_horde_key") and str(state.get_meta("world_horde_key")) == horde_chk_key: - world_has_horde = bool(state.get_meta("world_horde_val")) - else: - for hd_id in state.demes.keys(): - var hd: EvoTypes.Deme = state.demes[hd_id] - if hd.population >= 55 and (hd.cultural_trait_ids.has("cul-warrior-expansion") or hd.id.contains("-w") or hd.last_dominant_decision_id == "dec-territorial-dispute"): - world_has_horde = true - break - state.set_meta("world_horde_key", horde_chk_key) - state.set_meta("world_horde_val", world_has_horde) - if world_has_horde: - for n2id in HexMath.get_neighbor_ids(ncell.q, ncell.r, state.cells): - if n2id == cell.id: - continue - var n2cell: EvoTypes.HexCell = state.cells[n2id] - if n2cell.deme_ids.is_empty() or n2cell.biome == "ocean" or n2cell.altitude > 0.85: - continue - for o2id in n2cell.deme_ids: - if state.demes.has(o2id): - var o2: EvoTypes.Deme = state.demes[o2id] - if o2.id != deme.id and o2.population >= 55 and o2.lineage_id != deme.lineage_id and (not has_pacts or not EvoTypes.is_pact_active(state, deme.id, o2.id)): - if o2.cultural_trait_ids.has("cul-warrior-expansion") or o2.id.contains("-w") or o2.last_dominant_decision_id == "dec-territorial-dispute": - nearby_horde_threat = true - break - if nearby_horde_threat: - break - - var mem_tokens: Array[String] = [] - if state != null and not state.chronicle.is_empty(): - var min_gen := state.generation - 6 - var idx := state.chronicle.size() - 1 - while idx >= 0 and mem_tokens.size() < 3: - var entry: EvoTypes.ChronicleEntry = state.chronicle[idx] - if entry.generation < min_gen: - break - if entry.deme_id == deme.id or entry.id.contains(deme.id) or entry.hex_id == deme.hex_id: - var et := entry.event_type - var tok := "" - if et == "raid": - tok = "raided@G%d" % entry.generation - elif et in ["war", "betrayal", "pact", "fortify", "merge", "tribute", "siege"]: - tok = "%s@G%d" % [et, entry.generation] - if tok != "" and not mem_tokens.has(tok): - mem_tokens.append(tok) - idx -= 1 - if active_vendettas > 0: - mem_tokens.append("vendettas=%d" % active_vendettas) - if nearby_horde_threat: - mem_tokens.append("horde_nearby") - var memory_digest := "; ".join(mem_tokens) if not mem_tokens.is_empty() else "quiet" - - var adequacy := (cell.current_resources if cell != null else 300.0) / maxf(1.0, float(cell_pop) * 0.85) - - var res := { - "has_intra_rival": has_intra_rival, - "has_adjacent_rival": has_adjacent_rival, - "has_nearby_deme": has_nearby_deme, - "neighbor_has_surplus": neighbor_has_surplus, - "neighbor_is_weaker": neighbor_is_weaker, - "has_active_pact": has_active_pact, - "active_pacts": active_pact_count, - "active_vendettas": active_vendettas, - "adjacent_rivals": adjacent_rivals_count, - "nearby_horde_threat": nearby_horde_threat, - "memory_digest": memory_digest, - "neighbor_max_reserves": max_neighbor_reserves, - "env_adequacy": adequacy, - "cell_pop": cell_pop - } - if deme != null: - deme.set_meta("geo_ctx_key", cache_key) - deme.set_meta("geo_ctx_val", res) - return res - - -static func compute_environmental_decision_weights( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Dictionary: - var traits_cat := state.traits_catalog if state != null else {} - var decs_cat := state.decisions_catalog if (state != null and not state.decisions_catalog.is_empty()) else EvoCatalog.create_base_decisions_catalog() - var b_count := deme.biological_trait_ids.size() - var c_count := deme.cultural_trait_ids.size() - var base_w_key := "%d|%d|%s|%s" % [ - b_count, - c_count, - deme.biological_trait_ids[0] if b_count > 0 else "", - deme.cultural_trait_ids[c_count - 1] if c_count > 0 else "" - ] - var base_probs: Dictionary - var base_eligible: Array[String] - var deme_vec: EvoTypes.BiophysicalVector - if deme.has_meta("base_w_key") and str(deme.get_meta("base_w_key")) == base_w_key: - base_probs = deme.get_meta("base_w_probs") as Dictionary - base_eligible = deme.get_meta("base_w_elig") as Array[String] - deme_vec = deme.get_meta("base_w_vec") as EvoTypes.BiophysicalVector - else: - base_probs = EvoCatalog.compute_baseline_decision_weights(deme, traits_cat)["probabilities"] as Dictionary - base_eligible = get_eligible_decision_ids(deme, decs_cat, traits_cat, null) - deme_vec = EvoCatalog.compute_deme_vector(deme, traits_cat) - deme.set_meta("base_w_key", base_w_key) - deme.set_meta("base_w_probs", base_probs) - deme.set_meta("base_w_elig", base_eligible) - deme.set_meta("base_w_vec", deme_vec) - - var weights: Dictionary = base_probs.duplicate() - var geo := inspect_geopolitical_context(deme, cell, state) if (cell != null and state != null) else {} - var eligible_ids: Array[String] = base_eligible - if state != null and base_eligible.size() > 1 and not bool(geo.get("has_intra_rival", true)) and not bool(geo.get("has_adjacent_rival", true)): - var filtered: Array[String] = [] - for dec_id in base_eligible: - if dec_id != "dec-territorial-dispute": - filtered.append(dec_id) - if not filtered.is_empty(): - eligible_ids = filtered - - for dec_id in eligible_ids: - if not weights.has(dec_id): - weights[dec_id] = 0.08 - - if cell == null or state == null: - return normalize_decision_probabilities(weights, eligible_ids)["probabilities"] - - var cell_pop := int(geo.get("cell_pop", deme.population)) - var adequacy := cell.current_resources / maxf(1.0, float(cell_pop) * 0.85) - var k_ratio := cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max) - var reserve_stress := clampf((60.0 - deme.energy_reserves) / 60.0, 0.0, 1.0) - var crowding := clampf(float(cell_pop) / maxf(1.0, cell.carrying_capacity), 0.0, 2.0) - - var has_adjacent_rival := bool(geo["has_adjacent_rival"]) - var neighbor_has_surplus := bool(geo["neighbor_has_surplus"]) - var neighbor_is_weaker := bool(geo["neighbor_is_weaker"]) - var has_active_pact := bool(geo["has_active_pact"]) - - if adequacy >= 1.25 and deme.energy_reserves >= 55.0 and state.season == "wet_warm" and crowding < 0.85: - if weights.has("dec-territorial-dispute"): - weights["dec-territorial-dispute"] = float(weights["dec-territorial-dispute"]) * 0.18 - if weights.has("dec-intensive-overexploit"): - weights["dec-intensive-overexploit"] = float(weights["dec-intensive-overexploit"]) * 0.45 - if weights.has("dec-conservative-forage"): - weights["dec-conservative-forage"] = float(weights["dec-conservative-forage"]) * 1.95 - if weights.has("dec-cooperate-store"): - weights["dec-cooperate-store"] = float(weights["dec-cooperate-store"]) * 1.35 - if weights.has("dec-exchange-symbiosis"): - weights["dec-exchange-symbiosis"] = float(weights["dec-exchange-symbiosis"]) * 1.35 - if weights.has("dec-explore-innovate"): - weights["dec-explore-innovate"] = float(weights["dec-explore-innovate"]) * 1.45 - elif adequacy < 1.05 or state.season == "dry_cold" or reserve_stress > 0.25 or k_ratio < 0.78 or crowding >= 0.85: - var env_pressure := clampf( - (1.15 - adequacy) * 0.6 - + reserve_stress * 0.5 - + (0.25 if state.season == "dry_cold" else 0.0) - + maxf(0.0, crowding - 0.75) * 0.5 - + maxf(0.0, 0.85 - k_ratio) * 0.5, - 0.0, - 2.0 - ) - var vec := deme_vec - var has_warrior := EvoCatalog.deme_has_trait(deme, "cul-warrior-expansion") - var is_martial := ( - has_warrior - or EvoCatalog.deme_has_trait(deme, "cul-extraction-tools") - or EvoCatalog.deme_has_trait(deme, "bio-r-strategy") - or vec.extraction_efficiency > 0.3 - or vec.social_cohesion < 0.0 - ) - if is_martial: - var opp_mult := 1.35 if (neighbor_has_surplus or neighbor_is_weaker) else 1.0 - var disp_boost := 1.0 + env_pressure * (1.8 if has_warrior else 1.1) * opp_mult - if weights.has("dec-territorial-dispute"): - weights["dec-territorial-dispute"] = float(weights["dec-territorial-dispute"]) * disp_boost - if weights.has("dec-intensive-overexploit"): - weights["dec-intensive-overexploit"] = float(weights["dec-intensive-overexploit"]) * (1.0 + env_pressure * 1.15) - if has_adjacent_rival and (has_warrior or crowding > 0.80) and weights.has("dec-selective-migration"): - weights["dec-selective-migration"] = float(weights["dec-selective-migration"]) * (1.0 + env_pressure * 1.25) - - var is_cooperative := ( - EvoCatalog.deme_has_trait(deme, "cul-communal-conservation") - or (EvoCatalog.deme_has_trait(deme, "bio-social-instinct") and not has_warrior and not EvoCatalog.deme_has_trait(deme, "bio-r-strategy")) - or has_active_pact - ) - if is_cooperative: - if weights.has("dec-cooperate-store"): - weights["dec-cooperate-store"] = float(weights["dec-cooperate-store"]) * (1.0 + env_pressure * 1.35) - if weights.has("dec-conservative-forage"): - weights["dec-conservative-forage"] = float(weights["dec-conservative-forage"]) * (1.0 + env_pressure * 0.90) - - var is_trade_mobile := ( - EvoCatalog.deme_has_trait(deme, "cul-interdeme-barter") - or EvoCatalog.deme_has_trait(deme, "cul-symbolic-exchange") - or EvoCatalog.deme_has_trait(deme, "bio-thermal-tolerance") - ) - if is_trade_mobile: - if weights.has("dec-exchange-symbiosis"): - weights["dec-exchange-symbiosis"] = float(weights["dec-exchange-symbiosis"]) * (1.0 + env_pressure * 1.40) - if weights.has("dec-selective-migration"): - weights["dec-selective-migration"] = float(weights["dec-selective-migration"]) * (1.0 + env_pressure * 1.30) - - var is_cognitive := ( - EvoCatalog.deme_has_trait(deme, "bio-encephalization") - or EvoCatalog.deme_has_trait(deme, "bio-digestive-spec") - or EvoCatalog.deme_has_trait(deme, "cul-fire-processing") - or EvoCatalog.deme_has_trait(deme, "cul-lithic-acheulean") - ) - if is_cognitive and weights.has("dec-explore-innovate"): - weights["dec-explore-innovate"] = float(weights["dec-explore-innovate"]) * (1.0 + env_pressure * 1.35) - - if bool(geo.get("nearby_horde_threat", false)) or int(geo.get("active_vendettas", 0)) > 0: - var has_warrior_trait := EvoCatalog.deme_has_trait(deme, "cul-warrior-expansion") - if not has_warrior_trait: - if weights.has("dec-cooperate-store"): - weights["dec-cooperate-store"] = float(weights["dec-cooperate-store"]) * 1.28 - if weights.has("dec-exchange-symbiosis"): - weights["dec-exchange-symbiosis"] = float(weights["dec-exchange-symbiosis"]) * 1.18 - if has_warrior_trait or deme.defense_tier >= 2: - if weights.has("dec-territorial-dispute"): - weights["dec-territorial-dispute"] = float(weights["dec-territorial-dispute"]) * 1.22 - - if weights.has("dec-exchange-symbiosis"): - if not bool(geo.get("has_nearby_deme", false)): - weights["dec-exchange-symbiosis"] = float(weights["dec-exchange-symbiosis"]) * 0.25 - - if weights.has("dec-explore-innovate"): - var emergent_cul_count: int = 0 - for tid in deme.cultural_trait_ids: - if traits_cat.has(tid) and (traits_cat[tid] as EvoTypes.TraitDef).origin == "emergent": - emergent_cul_count += 1 - if emergent_cul_count >= 2: - weights["dec-explore-innovate"] = float(weights["dec-explore-innovate"]) * 0.15 - - return normalize_decision_probabilities(weights, eligible_ids)["probabilities"] - - -static func modulate_deme_probabilities_for_turn( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> void: - if deme == null or cell == null or state == null: - return - var env_weights := compute_environmental_decision_weights(deme, cell, state) - deme.set_meta("env_w_key", "%d|%s|%d|%.1f" % [state.generation, cell.id, deme.population, deme.energy_reserves]) - deme.set_meta("env_w_val", env_weights) - var eligible_ids: Array[String] = [] - for k in env_weights.keys(): - eligible_ids.append(str(k)) - if eligible_ids.is_empty(): - return - - if deme.last_decision_probabilities.size() <= 1 and float(deme.last_decision_probabilities.get("dec-conservative-forage", 0.0)) >= 0.999: - EvoCatalog.compute_strategy_baseline_probabilities(deme, state.traits_catalog) - - var blended: Dictionary = {} - for dec_id in eligible_ids: - var env_w := float(env_weights.get(dec_id, 0.0)) - var cur_p := float(deme.last_decision_probabilities.get(dec_id, 0.0)) - blended[dec_id] = (0.30 * cur_p + 0.70 * env_w) if env_w > 0.0 else 0.0 - - var norm := normalize_decision_probabilities(blended, eligible_ids) - deme.last_decision_probabilities = norm["probabilities"] - deme.last_dominant_decision_id = pick_dominant_decision(deme.last_decision_probabilities) - - -static func normalize_decision_probabilities( - raw_scores: Dictionary, - eligible_ids: Array[String] -) -> Dictionary: - if eligible_ids.is_empty(): - return {"probabilities": {}, "dominant_decision_id": ""} - - var positive_scores: Dictionary = {} - var sum_scores: float = 0.0 - - for dec_id in eligible_ids: - var val: float = float(raw_scores[dec_id]) if raw_scores.has(dec_id) else 0.0 - var clean_score: float = val if (not is_nan(val) and not is_inf(val) and val > 0.0) else 0.0 - positive_scores[dec_id] = clean_score - sum_scores += clean_score - - if sum_scores <= 0.0: - var uniform_weight: float = 1.0 / float(eligible_ids.size()) - for dec_id in eligible_ids: - positive_scores[dec_id] = uniform_weight - sum_scores = 1.0 - - var max_decision_id: String = eligible_ids[0] - var max_score: float = -1.0 - - for dec_id in eligible_ids: - var s: float = float(positive_scores[dec_id]) - if s > max_score: - max_score = s - max_decision_id = dec_id - - var temp_probs: Dictionary = {} - var other_sum: float = 0.0 - var highest_other_id: String = "" - var highest_other_prob: float = -1.0 - - for dec_id in eligible_ids: - if dec_id == max_decision_id: - continue - var s: float = float(positive_scores[dec_id]) - var rounded: float = snappedf(s / sum_scores, 0.0001) - temp_probs[dec_id] = rounded - other_sum += rounded - if rounded > highest_other_prob: - highest_other_prob = rounded - highest_other_id = dec_id - - var complement: float = snappedf(maxf(0.0, 1.0 - other_sum), 0.0001) - if highest_other_id != "" and highest_other_prob > complement: - temp_probs[max_decision_id] = highest_other_prob - temp_probs[highest_other_id] = complement - else: - temp_probs[max_decision_id] = complement - - var probabilities: Dictionary = {} - probabilities[max_decision_id] = temp_probs[max_decision_id] - for dec_id in eligible_ids: - if dec_id != max_decision_id: - probabilities[dec_id] = temp_probs[dec_id] - - return { - "probabilities": probabilities, - "dominant_decision_id": max_decision_id - } - - -static func compute_baseline_mobility(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: - var mobility: float = 0.0 - for tid in deme.biological_trait_ids: - if state.traits_catalog.has(tid): - mobility += (state.traits_catalog[tid] as EvoTypes.TraitDef).vector.spatial_mobility - for tid in deme.cultural_trait_ids: - if state.traits_catalog.has(tid): - mobility += (state.traits_catalog[tid] as EvoTypes.TraitDef).vector.spatial_mobility - return clampf(mobility, -1.5, 1.5) - - -static func _format_signed_metric(val: float) -> String: - var r := snappedf(val, 0.01) - return ("+" if r >= 0.0 else "") + str(r) - - -static func _format_compact_trait(t: EvoTypes.TraitDef) -> String: - if t.has_meta("compact_str"): - return str(t.get_meta("compact_str")) - var v := t.vector - var s := "%s[cost:%s,extract:%s,eco:%s,mobility:%s,cohesion:%s]" % [ - t.name, - _format_signed_metric(v.metabolic_cost), - _format_signed_metric(v.extraction_efficiency), - _format_signed_metric(v.ecological_impact), - _format_signed_metric(v.spatial_mobility), - _format_signed_metric(v.social_cohesion) - ] - t.set_meta("compact_str", s) - return s - - -static func _compute_vitality_status( - energy_reserves: float, - last_energy_delta: float, - _resource_adequacy_ratio: float = 1.0 -) -> String: - if energy_reserves < 25.0 or last_energy_delta <= -10.0: - return "starving" - if (last_energy_delta >= 10.0 and energy_reserves >= 40.0) or energy_reserves >= 120.0: - return "thriving" - if last_energy_delta < -5.0: - return "declining" - return "stable" - - -static func _get_sorted_passable_neighbors( - deme: EvoTypes.Deme, - current_cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Array[Dictionary]: - var n_cache_key := "%d|%s|%d|%d" % [ - state.generation if state != null else -1, - current_cell.id if current_cell != null else "", - deme.biological_trait_ids.size(), - deme.cultural_trait_ids.size() - ] - if deme != null and deme.has_meta("pass_n_key") and str(deme.get_meta("pass_n_key")) == n_cache_key: - return deme.get_meta("pass_n_val") as Array[Dictionary] - - var mob_t_key := "%d|%d|%d" % [deme.biological_trait_ids.size(), deme.cultural_trait_ids.size(), state.decisions_catalog.size()] - var effective_mig_mobility: float - if deme != null and deme.has_meta("eff_mig_mob_key") and str(deme.get_meta("eff_mig_mob_key")) == mob_t_key: - effective_mig_mobility = float(deme.get_meta("eff_mig_mob_val")) - else: - var base_mobility := compute_baseline_mobility(deme, state) - var max_migrate_mob: float = 0.0 - for dec_id in state.decisions_catalog.keys(): - var dec: EvoTypes.DecisionDef = state.decisions_catalog[dec_id] - if dec.archetype == "migrate" and is_decision_eligible(deme, dec, state.traits_catalog): - max_migrate_mob = maxf(max_migrate_mob, dec.vector.spatial_mobility) - effective_mig_mobility = minf(1.5, base_mobility + max_migrate_mob) - if deme != null: - deme.set_meta("eff_mig_mob_key", mob_t_key) - deme.set_meta("eff_mig_mob_val", effective_mig_mobility) - var cell_n_key := "%d|%.2f" % [state.generation if state != null else -1, effective_mig_mobility] - if current_cell != null and current_cell.has_meta("cell_pass_n_key") and str(current_cell.get_meta("cell_pass_n_key")) == cell_n_key: - var cached_pass := current_cell.get_meta("cell_pass_n_val") as Array[Dictionary] - if deme != null: - deme.set_meta("pass_n_key", n_cache_key) - deme.set_meta("pass_n_val", cached_pass) - return cached_pass - - var passable: Array[Dictionary] = [] - for nid in HexMath.get_neighbor_ids(current_cell.q, current_cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - var is_passable := ncell.biome != "ocean" and ncell.altitude <= 0.85 and (ncell.altitude - 0.50) <= effective_mig_mobility - if not is_passable or ncell.deme_ids.size() >= 3: - continue - var total_pop: int = 0 - for oid in ncell.deme_ids: - if state.demes.has(oid): - total_pop += (state.demes[oid] as EvoTypes.Deme).population - passable.append({ - "id": ncell.id, - "biome": ncell.biome, - "current_resources": ncell.current_resources, - "demes_count": ncell.deme_ids.size(), - "total_population": total_pop, - "score": ncell.current_resources / maxf(10.0, float(total_pop)) - }) - - for i in range(1, passable.size()): - var key_item: Dictionary = passable[i] - var key_score: float = float(key_item["score"]) - var j: int = i - 1 - while j >= 0 and float(passable[j]["score"]) < key_score: - passable[j + 1] = passable[j] - j -= 1 - passable[j + 1] = key_item - - if passable.size() > 3: - var top3 := passable.slice(0, 3) - var has_occupied := false - for item in top3: - if int(item["demes_count"]) > 0: - has_occupied = true - break - if not has_occupied: - for i in range(3, passable.size()): - if int(passable[i]["demes_count"]) > 0: - top3[2] = passable[i] - if current_cell != null: - current_cell.set_meta("cell_pass_n_key", cell_n_key) - current_cell.set_meta("cell_pass_n_val", top3) - if deme != null: - deme.set_meta("pass_n_key", n_cache_key) - deme.set_meta("pass_n_val", top3) - return top3 - if current_cell != null: - current_cell.set_meta("cell_pass_n_key", cell_n_key) - current_cell.set_meta("cell_pass_n_val", passable) - if deme != null: - deme.set_meta("pass_n_key", n_cache_key) - deme.set_meta("pass_n_val", passable) - return passable - - -static func _get_primary_cell_occupant(cell: EvoTypes.HexCell, state: EvoTypes.WorldState) -> EvoTypes.Deme: - if cell == null or state == null or cell.deme_ids.is_empty(): - return null - var best: EvoTypes.Deme = null - for oid in cell.deme_ids: - if state.demes.has(oid): - var d: EvoTypes.Deme = state.demes[oid] - if best == null or d.population > best.population: - best = d - return best - - -static func _format_neighbor_occupant_label( - deme: EvoTypes.Deme, - other: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> String: - if other == null: - return "empty" - var rel_tag := "rival" - if other.lineage_id == deme.lineage_id: - rel_tag = "kin" - elif EvoTypes.is_pact_active(state, deme.id, other.id): - rel_tag = "ally" - elif EvoTypes.is_vendetta_active(state, deme.id, other.id): - rel_tag = "vendetta" - - var dom_arch := "forage" - if state.decisions_catalog.has(other.last_dominant_decision_id): - dom_arch = (state.decisions_catalog[other.last_dominant_decision_id] as EvoTypes.DecisionDef).archetype - var posture_tag := "martial" if (other.cultural_trait_ids.has("cul-warrior-expansion") or dom_arch == "dispute") else dom_arch - - var strength_tag := "equal" - if float(other.population) > float(deme.population) * 1.2: - strength_tag = "stronger" - elif float(other.population) < float(deme.population) * 0.8: - strength_tag = "weaker" - - return "%s:%s:%s:def%d" % [rel_tag, posture_tag, strength_tag, other.defense_tier] - - -static func _format_neighbor_sig_tag( - deme: EvoTypes.Deme, - other: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> String: - if other == null: - return "empty" - if other.lineage_id == deme.lineage_id: - return "kin" - if EvoTypes.is_pact_active(state, deme.id, other.id): - return "ally" - if EvoTypes.is_vendetta_active(state, deme.id, other.id): - return "vendetta" - var dom_arch := "forage" - if state.decisions_catalog.has(other.last_dominant_decision_id): - dom_arch = (state.decisions_catalog[other.last_dominant_decision_id] as EvoTypes.DecisionDef).archetype - if other.cultural_trait_ids.has("cul-warrior-expansion") or dom_arch == "dispute": - return "rival_martial" - return "rival" - - -static func build_top_strategy_prior( - deme: EvoTypes.Deme, - _cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Dictionary: - var traits_cat := state.traits_catalog if state != null else {} - var base_probs: Dictionary = EvoCatalog.compute_baseline_decision_weights(deme, traits_cat)["probabilities"] - var pairs: Array[Dictionary] = [] - for k in base_probs.keys(): - pairs.append({"id": str(k), "w": float(base_probs[k])}) - pairs.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return float(a["w"]) > float(b["w"]) - ) - var top_prior: Dictionary = {} - var limit := mini(3, pairs.size()) - for i in range(limit): - top_prior[str(pairs[i]["id"])] = snappedf(float(pairs[i]["w"]), 0.001) - return top_prior - - -static func build_geopolitics_summary( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Dictionary: - var geo := inspect_geopolitical_context(deme, cell, state) - return { - "active_pacts": int(geo["active_pacts"]), - "active_vendettas": int(geo.get("active_vendettas", 0)), - "adjacent_rivals": int(geo["adjacent_rivals"]), - "defense_tier": deme.defense_tier, - "merged_count": deme.merged_count, - "horde_threat": bool(geo.get("nearby_horde_threat", false)), - "memory": str(geo.get("memory_digest", "quiet")), - "neighbor_max_reserves": snappedf(float(geo["neighbor_max_reserves"]), 0.1), - "env_adequacy": snappedf(float(geo["env_adequacy"]), 0.01) - } - - -static func build_compact_system_one_state( - deme: EvoTypes.Deme, - state: EvoTypes.WorldState, - precomputed_top_prior: Dictionary = {}, - precomputed_geo_summary: Dictionary = {} -) -> String: - var current_cell: EvoTypes.HexCell = state.cells[deme.hex_id] - var total_cell_pop: int = 0 - var rival_count: int = 0 - var kin_count: int = 0 - for oid in current_cell.deme_ids: - if state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - total_cell_pop += other.population - if other.id != deme.id: - if other.lineage_id == deme.lineage_id: - kin_count += 1 - else: - rival_count += 1 - if total_cell_pop <= 0: - total_cell_pop = deme.population - var cohabiting_count: int = maxi(0, current_cell.deme_ids.size() - 1) - - var resource_adequacy_ratio := snappedf( - current_cell.current_resources / maxf(10.0, float(total_cell_pop) * 0.48), - 0.01 - ) - var health_pct := int(roundf( - (current_cell.carrying_capacity / maxf(1.0, current_cell.carrying_capacity_max)) * 100.0 - )) - var vitality := _compute_vitality_status(deme.energy_reserves, deme.last_energy_delta, resource_adequacy_ratio) - - var line1 := "Gen %d (%s) | Hex %s (%s): resources=%d/%d (health=%d%% of Kmax=%d), caloricAdequacy=%sx, temp=%s, hum=%s, alt=%s" % [ - state.generation, - state.season, - current_cell.id, - current_cell.biome, - int(roundf(current_cell.current_resources)), - int(roundf(current_cell.carrying_capacity)), - health_pct, - int(roundf(current_cell.carrying_capacity_max)), - str(resource_adequacy_ratio), - str(current_cell.temperature), - str(current_cell.humidity), - str(current_cell.altitude) - ] - - var delta_int := int(roundf(deme.last_energy_delta)) - var delta_str := ("+%d" % delta_int) if deme.last_energy_delta >= 0.0 else ("%d" % delta_int) - var line2 := "Deme \"%s\": pop=%d (%s), reserves=%d, delta=%s, def=Lv%d, merged=%d | CellCompetition: totalPop=%d, cohabiting=%d (rivals=%d, kin=%d)" % [ - deme.name, - deme.population, - vitality, - int(roundf(deme.energy_reserves)), - delta_str, - deme.defense_tier, - deme.merged_count, - total_cell_pop, - cohabiting_count, - rival_count, - kin_count - ] - - var all_traits: Array[String] = [] - for tid in deme.biological_trait_ids: - if state.traits_catalog.has(tid): - all_traits.append(_format_compact_trait(state.traits_catalog[tid])) - for tid in deme.cultural_trait_ids: - if state.traits_catalog.has(tid): - all_traits.append(_format_compact_trait(state.traits_catalog[tid])) - var line3 := "Traits: %s" % ("; ".join(all_traits) if not all_traits.is_empty() else "none") - - var passable_neighbors := _get_sorted_passable_neighbors(deme, current_cell, state) - var top_neighbors := passable_neighbors.slice(0, 3) - var neighbor_parts: Array[String] = [] - for n in top_neighbors: - var nid := str(n["id"]) - var n_biome := str(n["biome"]) - var n_count := int(n["demes_count"]) - var occ_label := "empty" - if n_count > 0 and state.cells.has(nid): - var primary_occ := _get_primary_cell_occupant(state.cells[nid], state) - occ_label = _format_neighbor_occupant_label(deme, primary_occ, state) - neighbor_parts.append("%s(%s, res=%d, pop=%d, %s)" % [ - nid, - n_biome, - int(roundf(float(n["current_resources"]))), - int(n["total_population"]), - occ_label - ]) - var neighbors_text := ", ".join(neighbor_parts) if not neighbor_parts.is_empty() else "none" - - var baldwin_progress: int = 0 - if deme.last_dominant_decision_id != "" and deme.baldwin_counters.has(deme.last_dominant_decision_id): - baldwin_progress = int(deme.baldwin_counters[deme.last_dominant_decision_id]) - else: - for k in deme.baldwin_counters.keys(): - baldwin_progress = maxi(baldwin_progress, int(deme.baldwin_counters[k])) - var dom_str := deme.last_dominant_decision_id if deme.last_dominant_decision_id != "" else "none" - var top_prior := precomputed_top_prior if not precomputed_top_prior.is_empty() else build_top_strategy_prior(deme, current_cell, state) - var geo_summary := precomputed_geo_summary if not precomputed_geo_summary.is_empty() else build_geopolitics_summary(deme, current_cell, state) - var line4 := "lastDominant=%s, baldwin=%d/8, innovation=%.2f/1.0 | memory=%s | Neighbors: %s | strategy_traits_prior=%s | geopolitics=%s" % [ - dom_str, - baldwin_progress, - deme.innovation_counter, - str(geo_summary.get("memory", "quiet")), - neighbors_text, - JSON.stringify(top_prior), - JSON.stringify(geo_summary) - ] - - return "\n".join([line1, line2, line3, line4]) - - -static func build_compact_decision_criteria(eligible_decisions: Array[EvoTypes.DecisionDef]) -> Dictionary: - var criteria: Dictionary = {} - for dec in eligible_decisions: - if dec.has_meta("compact_criterion"): - criteria[dec.id] = str(dec.get_meta("compact_criterion")) - continue - var v := dec.vector - var vec_text := "[cost:%s,extract:%s,eco:%s,mobility:%s,cohesion:%s]" % [ - _format_signed_metric(v.metabolic_cost), - _format_signed_metric(v.extraction_efficiency), - _format_signed_metric(v.ecological_impact), - _format_signed_metric(v.spatial_mobility), - _format_signed_metric(v.social_cohesion) - ] - var full_text := ("%s %s: %s" % [dec.name, vec_text, dec.description]).substr(0, 240) - dec.set_meta("compact_criterion", full_text) - criteria[dec.id] = full_text - return criteria - - -static func compute_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: - var model_key := deme.decision_model_tag if deme.decision_model_tag != "" else resolve_deme_decision_model_tag(deme.id) - if not state.cells.has(deme.hex_id): - return "%s|%s|missing" % [model_key, state.season] - - var current_cell: EvoTypes.HexCell = state.cells[deme.hex_id] - var pacts_hash: int = state.active_pacts.hash() if not state.active_pacts.is_empty() else 0 - var sig_cache_key := "%d|%s|%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d" % [ - state.generation, - model_key, - deme.hex_id, - deme.population, - int(deme.energy_reserves), - deme.defense_tier, - deme.merged_count, - state.demes.size(), - state.chronicle.size(), - current_cell.deme_ids.size(), - int(current_cell.current_resources), - pacts_hash, - deme.biological_trait_ids.size() + deme.cultural_trait_ids.size() - ] - if deme.has_meta("sig_cache_key") and str(deme.get_meta("sig_cache_key")) == sig_cache_key: - return str(deme.get_meta("sig_cache_val")) - var total_cell_pop: int = 0 - var rival_count: int = 0 - var kin_count: int = 0 - for oid in current_cell.deme_ids: - if state.demes.has(oid): - var other: EvoTypes.Deme = state.demes[oid] - total_cell_pop += other.population - if other.id != deme.id: - if other.lineage_id == deme.lineage_id: - kin_count += 1 - else: - rival_count += 1 - if total_cell_pop <= 0: - total_cell_pop = deme.population - - var resource_adequacy_ratio := snappedf( - current_cell.current_resources / maxf(10.0, float(total_cell_pop) * 0.48), - 0.01 - ) - var adequacy_bucket: int = 0 if resource_adequacy_ratio < 0.85 else (1 if resource_adequacy_ratio <= 1.35 else 2) - var health_ratio := current_cell.carrying_capacity / maxf(1.0, current_cell.carrying_capacity_max) - var health_bucket: int = 0 if health_ratio < 0.35 else (1 if health_ratio <= 0.80 else 2) - var vitality := _compute_vitality_status(deme.energy_reserves, deme.last_energy_delta, resource_adequacy_ratio) - var geo := inspect_geopolitical_context(deme, current_cell, state) - var pact_count := int(geo["active_pacts"]) - var vendetta_flag := 1 if int(geo.get("active_vendettas", 0)) > 0 else 0 - var horde_flag := 1 if bool(geo.get("nearby_horde_threat", false)) else 0 - var surplus_flag := 1 if (bool(geo["neighbor_has_surplus"]) or bool(geo["neighbor_is_weaker"])) else 0 - var competition_key := "%d:%d:%s:%d:p%d:v%d:h%d:d%d:s%d" % [ - rival_count, - kin_count, - "viable" if deme.population >= 40 else "small", - 1 if (bool(geo["has_intra_rival"]) or bool(geo["has_adjacent_rival"])) else 0, - pact_count, - vendetta_flag, - horde_flag, - deme.defense_tier, - surplus_flag - ] - - var traits_cache_key := "%d|%d" % [deme.biological_trait_ids.size(), deme.cultural_trait_ids.size()] - var traits_key: String - if deme.has_meta("traits_sig_key") and str(deme.get_meta("traits_sig_key")) == traits_cache_key: - traits_key = str(deme.get_meta("traits_sig_val")) - else: - var all_tids: Array[String] = [] - for tid in deme.biological_trait_ids: - all_tids.append(tid) - for tid in deme.cultural_trait_ids: - all_tids.append(tid) - all_tids.sort() - var trait_parts: Array[String] = [] - for tid in all_tids: - if state.traits_catalog.has(tid): - var tv: EvoTypes.BiophysicalVector = (state.traits_catalog[tid] as EvoTypes.TraitDef).vector - trait_parts.append("%s(%.2f,%.2f,%.2f,%.2f,%.2f)" % [ - tid, - tv.metabolic_cost, - tv.extraction_efficiency, - tv.ecological_impact, - tv.spatial_mobility, - tv.social_cohesion - ]) - else: - trait_parts.append(tid) - traits_key = ",".join(trait_parts) - deme.set_meta("traits_sig_key", traits_cache_key) - deme.set_meta("traits_sig_val", traits_key) - - var dec_hash: int = 17 - if state.has_meta("dec_cat_hash") and int(state.get_meta("dec_cat_size", -1)) == state.decisions_catalog.size(): - dec_hash = int(state.get_meta("dec_cat_hash")) - else: - var dec_ids: Array[String] = [] - for did in state.decisions_catalog.keys(): - dec_ids.append(str(did)) - dec_ids.sort() - for did in dec_ids: - var d_def: EvoTypes.DecisionDef = state.decisions_catalog[did] - var dv := d_def.vector - var d_str := "%s:%.2f:%.2f:%.2f:%.2f:%.2f" % [ - did, - dv.metabolic_cost, - dv.extraction_efficiency, - dv.ecological_impact, - dv.spatial_mobility, - dv.social_cohesion - ] - dec_hash = ((dec_hash * 31) + d_str.hash()) & 0x7FFFFFFF - state.set_meta("dec_cat_hash", dec_hash) - state.set_meta("dec_cat_size", state.decisions_catalog.size()) - - var dominant_key := "%x" % dec_hash - - var passable_neighbors := _get_sorted_passable_neighbors(deme, current_cell, state).slice(0, 3) - var n_keys: Array[String] = [] - for n in passable_neighbors: - var nid := str(n["id"]) - var n_count := int(n["demes_count"]) - var n_rel_tag := "empty" - if n_count > 0 and state.cells.has(nid): - var primary_occ := _get_primary_cell_occupant(state.cells[nid], state) - n_rel_tag = _format_neighbor_sig_tag(deme, primary_occ, state) - var n_res_bucket := int(roundf(float(n["current_resources"]) / 150.0)) - n_keys.append("%s:%s:%d" % [str(n["biome"]), n_rel_tag, n_res_bucket]) - var neighbor_key := ",".join(n_keys) - - var final_sig := "%s|%s|%s|%d|%d|%s|%s|%s|%s|%s" % [ - model_key, - state.season, - current_cell.biome, - adequacy_bucket, - health_bucket, - vitality, - competition_key, - traits_key, - dominant_key, - neighbor_key - ] - deme.set_meta("sig_cache_key", sig_cache_key) - deme.set_meta("sig_cache_val", final_sig) - return final_sig - - -static func invalidate_state_caches(state: EvoTypes.WorldState) -> void: - if state == null: - return - if state.has_meta("dec_cat_hash"): - state.remove_meta("dec_cat_hash") - if state.has_meta("dec_cat_size"): - state.remove_meta("dec_cat_size") - if state.has_meta("pact_counts_key"): - state.remove_meta("pact_counts_key") - if state.has_meta("pact_counts_val"): - state.remove_meta("pact_counts_val") - if state.has_meta("vendetta_counts_val"): - state.remove_meta("vendetta_counts_val") - if state.has_meta("world_horde_key"): - state.remove_meta("world_horde_key") - if state.has_meta("world_horde_val"): - state.remove_meta("world_horde_val") - for tid in state.traits_catalog.keys(): - var t: EvoTypes.TraitDef = state.traits_catalog[tid] - if t.has_meta("compact_str"): - t.remove_meta("compact_str") - for did in state.decisions_catalog.keys(): - var d: EvoTypes.DecisionDef = state.decisions_catalog[did] - if d.has_meta("compact_criterion"): - d.remove_meta("compact_criterion") - for deme_id in state.demes.keys(): - var deme: EvoTypes.Deme = state.demes[deme_id] - for k in ["geo_ctx_key", "pass_n_key", "env_w_key", "traits_sig_key", "base_w_key", "eff_mig_mob_key", "sig_cache_key"]: - if deme.has_meta(k): - deme.remove_meta(k) - for cid in state.cells.keys(): - var c: EvoTypes.HexCell = state.cells[cid] - if c.has_meta("cell_pass_n_key"): - c.remove_meta("cell_pass_n_key") - - -static func build_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: - return compute_deme_evaluation_signature(deme, state) - - -static func build_contextual_justification( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState, - dominant_id: String, - dominant_prob: float, - lang: String, - _confidence: float = 0.85 -) -> String: - var is_en := lang == "en" - var model_tag := deme.decision_model_tag if deme.decision_model_tag != "" else resolve_deme_decision_model_tag(deme.id) - var dec_name := dominant_id - if state != null and state.decisions_catalog.has(dominant_id): - dec_name = EvoI18n.decision_name(lang, state.decisions_catalog[dominant_id]) - var pct_str := "%.1f%%" % (dominant_prob * 100.0) - var ctx_parts: Array[String] = [] - if deme != null and cell != null and state != null: - var geo := inspect_geopolitical_context(deme, cell, state) - if bool(geo.get("nearby_horde_threat", false)): - ctx_parts.append("nearby horde threat" if is_en else "amenaza de horda cercana") - if int(geo.get("active_vendettas", 0)) > 0: - ctx_parts.append("active vendetta (%d)" % int(geo["active_vendettas"]) if is_en else "vendetta activa (%d)" % int(geo["active_vendettas"])) - if int(geo.get("active_pacts", 0)) > 0: - ctx_parts.append("allied pacts=%d" % int(geo["active_pacts"]) if is_en else "pactos aliados=%d" % int(geo["active_pacts"])) - if deme.defense_tier > 0: - ctx_parts.append("defense Lv.%d" % deme.defense_tier if is_en else "defensa Nv.%d" % deme.defense_tier) - var adeq := float(geo.get("env_adequacy", 1.0)) - if adeq < 0.85: - ctx_parts.append("caloric deficit (%.2fx)" % adeq if is_en else "déficit calórico (%.2fx)" % adeq) - elif adeq >= 1.35: - ctx_parts.append("ecological surplus (%.2fx)" % adeq if is_en else "superávit ecológico (%.2fx)" % adeq) - else: - ctx_parts.append("adequacy %.2fx" % adeq if is_en else "adecuación %.2fx" % adeq) - var ctx_suffix := (" [%s]" % ", ".join(ctx_parts)) if not ctx_parts.is_empty() else "" - var model_reason := ( - "Live neural decision evaluation via Ollama System 1 (%s)%s." % [model_tag, ctx_suffix] - if is_en - else "Evaluación neuronal de decisión en vivo mediante Ollama System 1 (%s)%s." % [model_tag, ctx_suffix] - ) - return "%s (%s): %s" % [dec_name, pct_str, model_reason] - - -static func _slugify(text: String) -> String: - var lower := text.to_lower() - var out := "" - var prev_dash := false - for i in range(lower.length()): - var ch := lower[i] - var is_alnum := (ch >= "a" and ch <= "z") or (ch >= "0" and ch <= "9") - if is_alnum: - out += ch - prev_dash = false - elif not prev_dash and out.length() > 0: - out += "-" - prev_dash = true - out = out.trim_suffix("-") - if out.length() > 28: - out = out.substr(0, 28).trim_suffix("-") - return out if out.length() > 0 else "adaptation" - - -static func synthesize_milestone_trait( - state: EvoTypes.WorldState, - deme: EvoTypes.Deme, - trigger_type: String, - lang: String = "es" -) -> EvoTypes.TraitDef: - var is_en := lang == "en" - var category := "cultural" if trigger_type == "cultural_innovation" else "biological" - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] if state.cells.has(deme.hex_id) else EvoTypes.HexCell.new() - var biome_label := EvoI18n.biome_name(lang, cell.biome) - - var base_name := "" - if category == "cultural": - base_name = "Cooperative Ecological Tradition" if is_en else "Tradición Ecológica Cooperativa" - elif trigger_type == "allopatric_speciation": - base_name = "Allopatric Divergence" if is_en else "Divergencia Alopátrica" - else: - base_name = "Adaptive Homeostasis" if is_en else "Homeostasis Adaptativa" - - var candidate_name := "%s (%s)" % [base_name, biome_label] - if candidate_name.length() > 60: - candidate_name = candidate_name.substr(0, 60) - - var existing_names: Dictionary = {} - for tid in state.traits_catalog.keys(): - var t: EvoTypes.TraitDef = state.traits_catalog[tid] - existing_names[t.name.to_lower()] = true - - var final_name := candidate_name - var suffix := 2 - while existing_names.has(final_name.to_lower()): - var suffix_str := " #%d" % suffix - final_name = candidate_name.substr(0, max(1, 60 - suffix_str.length())) + suffix_str - suffix += 1 - - var baldwin_dec_id := str(deme.get_meta("baldwin_decision_id", "")) if deme.has_meta("baldwin_decision_id") else "" - if baldwin_dec_id == "": - baldwin_dec_id = deme.last_dominant_decision_id - - var raw_vec: EvoTypes.BiophysicalVector - if category == "cultural": - raw_vec = EvoTypes.BiophysicalVector.new(0.20, 0.35, 0.20, 0.15, 0.40, 0.45) - elif trigger_type == "baldwin_assimilation" and baldwin_dec_id != "" and state.decisions_catalog.has(baldwin_dec_id): - var dom_dec: EvoTypes.DecisionDef = state.decisions_catalog[baldwin_dec_id] - var dv := dom_dec.vector - raw_vec = EvoTypes.BiophysicalVector.new( - maxf(0.10, dv.metabolic_cost * 0.70), - clampf(maxf(0.15, dv.extraction_efficiency * 0.80), 0.0, 0.85), - clampf(dv.ecological_impact * 0.60, -0.50, 0.50), - clampf(maxf(0.10, dv.spatial_mobility * 0.80), 0.0, 0.85), - clampf(maxf(0.10, dv.social_cohesion * 0.80), 0.0, 0.85), - clampf(maxf(0.20, dv.transmission_threshold * 0.80), 0.10, 0.85) - ) - else: - raw_vec = EvoTypes.BiophysicalVector.new(0.18, 0.35, 0.10, 0.30, 0.20, 0.20) - var balanced_vec := EvoThermodynamics.validate_and_balance_thermodynamics(raw_vec, false) - - var slug := _slugify(final_name) - var sanitized_deme := _slugify(deme.id) - var base_id := "emergent-%d-%s" % [state.generation, slug] - var trait_id := base_id if not state.traits_catalog.has(base_id) else "%s-%s" % [base_id, sanitized_deme] - var id_suffix := 2 - while state.traits_catalog.has(trait_id): - trait_id = "%s-%s-%d" % [base_id, sanitized_deme, id_suffix] - id_suffix += 1 - - var dom_name := baldwin_dec_id if trigger_type == "baldwin_assimilation" else deme.last_dominant_decision_id - if state.decisions_catalog.has(dom_name): - dom_name = EvoI18n.decision_name(lang, state.decisions_catalog[dom_name]) - - var desc := "" - if is_en: - desc = "Emergent adaptation consolidating sustained behavioral selection in %s (%s)." % [biome_label, dom_name] - else: - desc = "Adaptación emergente que consolida la selección conductual sostenida en %s (%s)." % [biome_label, dom_name] - - var trait_def := EvoTypes.TraitDef.new() - trait_def.id = trait_id - trait_def.name = final_name - trait_def.category = category - trait_def.origin = "emergent" - trait_def.description = desc - trait_def.vector = balanced_vec - trait_def.mutually_exclusive_with = [] - trait_def.enables_cultural_transmission = false - return trait_def diff --git a/src/engine/cognitive/heuristic_scorer.gd.uid b/src/engine/cognitive/heuristic_scorer.gd.uid deleted file mode 100644 index c9b9a13..0000000 --- a/src/engine/cognitive/heuristic_scorer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bj2m4d5dxg20p diff --git a/src/engine/cognitive/ollama_client.gd b/src/engine/cognitive/ollama_client.gd index b11563b..b4e9978 100644 --- a/src/engine/cognitive/ollama_client.gd +++ b/src/engine/cognitive/ollama_client.gd @@ -3,17 +3,15 @@ extends RefCounted const DEFAULT_OLLAMA_HOST: String = "127.0.0.1" const DEFAULT_OLLAMA_PORT: int = 11434 -const DEFAULT_SYSTEM_ONE_MODEL: String = EvoUserSettings.DEFAULT_PRIMARY_MODEL -const SECONDARY_SYSTEM_ONE_MODEL: String = EvoUserSettings.DEFAULT_SECONDARY_MODEL const STATUS_TIMEOUT_MS: int = 1500 const EVAL_TIMEOUT_MS: int = 60000 const PULL_STALL_TIMEOUT_MS: int = 60000 -const SYSTEM_ONE_INSTRUCTIONS: String = "Evaluate the ecological, energetic, and social state of the deme and its passable neighbors to score each behavioral decision." +const MAX_CONCURRENT_JOBS: int = 8 static var _mutex := Mutex.new() static var _cached_ollama_status: Dictionary = {} -static var _live_signature_cache: Dictionary = {} -static var _pending_jobs_by_sig: Dictionary = {} +static var _results_by_deme: Dictionary = {} +static var _pending_jobs: Array[Dictionary] = [] static var _cache_epoch: int = 0 static var _active_worker_epoch: int = -1 static var _worker_task_id: int = -1 @@ -24,7 +22,7 @@ static var _status_reprobe_requested: bool = false static var _status_task_id: int = -1 static var _shutdown_requested: bool = false static var _completed_revision: int = 0 -static var _newly_completed_sigs: Dictionary = {} +static var _newly_completed: Dictionary = {} static var _pull_task_id: int = -1 static var _pull_running: bool = false @@ -66,9 +64,9 @@ static func _default_ollama_status() -> Dictionary: static func clear_cache() -> void: _mutex.lock() _cache_epoch += 1 - _live_signature_cache.clear() - _pending_jobs_by_sig.clear() - _newly_completed_sigs.clear() + _results_by_deme.clear() + _pending_jobs.clear() + _newly_completed.clear() _mutex.unlock() @@ -252,10 +250,6 @@ static func start_pull_models(models: Array[String]) -> void: _mutex.unlock() -static func trigger_auto_pull_missing_models(models: Array[String]) -> void: - start_pull_models(models) - - static func cancel_pull() -> void: _mutex.lock() _pull_cancel_requested = true @@ -399,204 +393,54 @@ static func _resolve_ollama_model_for_tag(desired: String, status: Dictionary) - return s_tag -static func build_compact_system_one_state(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> Dictionary: - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] if state.cells.has(deme.hex_id) else null - var top_prior := EvoHeuristicScorer.build_top_strategy_prior(deme, cell, state) - var geo_summary := EvoHeuristicScorer.build_geopolitics_summary(deme, cell, state) - return { - "briefing": EvoHeuristicScorer.build_compact_system_one_state(deme, state, top_prior, geo_summary), - "strategy_traits_prior": top_prior, - "geopolitics": geo_summary, - "memory": str(geo_summary.get("memory", "quiet")) - } - - -static func build_live_job(sig: String, rep: EvoTypes.Deme, state: EvoTypes.WorldState) -> Dictionary: - if not state.cells.has(rep.hex_id): - return {} - var eligible_decisions := EvoHeuristicScorer.get_eligible_decisions(rep, state.decisions_catalog, state.traits_catalog, state) - var eligible_ids: Array[String] = [] - for dec in eligible_decisions: - eligible_ids.append(dec.id) - if eligible_ids.is_empty(): - return {} - var cell: EvoTypes.HexCell = state.cells[rep.hex_id] - var preferred_tag := rep.decision_model_tag if rep.decision_model_tag != "" else EvoHeuristicScorer.resolve_deme_decision_model_tag(rep.id) - var top_prior := EvoHeuristicScorer.build_top_strategy_prior(rep, cell, state) - var geo_summary := EvoHeuristicScorer.build_geopolitics_summary(rep, cell, state) - return { - "sig": sig, - "preferred_tag": preferred_tag, - "briefing": EvoHeuristicScorer.build_compact_system_one_state(rep, state, top_prior, geo_summary), - "strategy_traits_prior": top_prior, - "geopolitics": geo_summary, - "memory": str(geo_summary.get("memory", "quiet")), - "criteria": EvoHeuristicScorer.build_compact_decision_criteria(eligible_decisions), - "eligible_ids": eligible_ids - } - - static func _can_enqueue_locked() -> bool: if _shutdown_requested: return false if not _status_probed: return true - return bool(_cached_ollama_status.get("connected", false)) and ( - bool(_cached_ollama_status.get("primary_ready", false)) - or bool(_cached_ollama_status.get("secondary_ready", false)) + return bool(_cached_ollama_status["connected"]) and ( + bool(_cached_ollama_status["primary_ready"]) + or bool(_cached_ollama_status["secondary_ready"]) ) -static func can_enqueue_jobs() -> bool: - _mutex.lock() - var ok := _can_enqueue_locked() - _mutex.unlock() - return ok - - -static func needs_prefetch() -> bool: - _mutex.lock() - var ok := _can_enqueue_locked() and (not _worker_running or _pending_jobs_by_sig.size() < 2) - _mutex.unlock() - return ok - - -static func has_cached_or_can_enqueue(enqueue_missing: bool) -> bool: - _mutex.lock() - var ok := not _live_signature_cache.is_empty() or (enqueue_missing and _can_enqueue_locked()) - _mutex.unlock() - return ok - - -static func lookup_or_enqueue_single_deme( - sig: String, - deme: EvoTypes.Deme, - state: EvoTypes.WorldState -) -> Dictionary: - var cached_entry: Dictionary = {} - var need_enqueue := false +## Each job: {deme_id, preferred_tag, briefing, instructions, criteria (action key -> forecast)}. +static func enqueue_jobs(jobs: Array[Dictionary]) -> void: _mutex.lock() - if _live_signature_cache.has(sig): - cached_entry = (_live_signature_cache[sig] as Dictionary).duplicate(true) - elif not _pending_jobs_by_sig.has(sig) and _can_enqueue_locked(): - need_enqueue = true + var can_enqueue := _can_enqueue_locked() + if can_enqueue: + _pending_jobs.append_array(jobs) _mutex.unlock() - - if not cached_entry.is_empty(): - return cached_entry - - if need_enqueue: - var job := build_live_job(sig, deme, state) - if not job.is_empty(): - _mutex.lock() - if not _live_signature_cache.has(sig) and not _pending_jobs_by_sig.has(sig): - _pending_jobs_by_sig[sig] = job - _mutex.unlock() - _start_worker_if_needed() - - return {} + if can_enqueue: + _start_worker_if_needed() -static func seed_signature_cache(sig: String, entry: Dictionary) -> void: +static func seed_deme_result(deme_id: String, entry: Dictionary) -> void: _mutex.lock() - _live_signature_cache[sig] = entry.duplicate(true) + _results_by_deme[deme_id] = entry.duplicate(true) _mutex.unlock() -static func sync_and_enqueue_batch( - buckets: Dictionary, - state: EvoTypes.WorldState, - enqueue_missing: bool = true -) -> Dictionary: - var live_results: Dictionary = {} - var missing_sigs: Array[String] = [] - if not buckets.is_empty(): - _mutex.lock() - var can_enq := enqueue_missing and _can_enqueue_locked() - if not _worker_running: - for pending_sig in _pending_jobs_by_sig.keys(): - if not buckets.has(pending_sig): - _pending_jobs_by_sig.erase(pending_sig) - for sig in buckets.keys(): - if _live_signature_cache.has(sig): - live_results[sig] = (_live_signature_cache[sig] as Dictionary).duplicate(true) - _newly_completed_sigs.erase(sig) - elif can_enq and not _pending_jobs_by_sig.has(sig): - missing_sigs.append(str(sig)) - _mutex.unlock() - - var new_jobs: Array[Dictionary] = [] - var max_new := mini(3, maxi(0, 8 - _pending_jobs_by_sig.size())) - for i in range(mini(max_new, missing_sigs.size())): - var sig := missing_sigs[i] - var rep_deme: EvoTypes.Deme = buckets[sig]["representative"] - var job := build_live_job(sig, rep_deme, state) - if not job.is_empty(): - new_jobs.append(job) - - if not new_jobs.is_empty(): - _mutex.lock() - for job in new_jobs: - var s := str(job["sig"]) - if not _live_signature_cache.has(s) and not _pending_jobs_by_sig.has(s): - _pending_jobs_by_sig[s] = job - _mutex.unlock() - _start_worker_if_needed() - - return live_results - - -static func prefetch_for_reps(reps_by_sig: Dictionary, state: EvoTypes.WorldState) -> void: - var missing_sigs: Array[String] = [] +static func take_result(deme_id: String) -> Dictionary: _mutex.lock() - var can_enq := _can_enqueue_locked() - if not _worker_running: - for pending_sig in _pending_jobs_by_sig.keys(): - if not reps_by_sig.has(pending_sig): - _pending_jobs_by_sig.erase(pending_sig) - if can_enq and _pending_jobs_by_sig.size() < 8: - for sig in reps_by_sig.keys(): - if not _live_signature_cache.has(sig) and not _pending_jobs_by_sig.has(sig): - missing_sigs.append(str(sig)) - if _pending_jobs_by_sig.size() + missing_sigs.size() >= 8 or missing_sigs.size() >= 2: - break + var result: Dictionary = (_results_by_deme[deme_id] as Dictionary).duplicate(true) if _results_by_deme.has(deme_id) else {} _mutex.unlock() + return result - if missing_sigs.is_empty(): - return - - var new_jobs: Array[Dictionary] = [] - for sig in missing_sigs: - var job := build_live_job(sig, reps_by_sig[sig], state) - if not job.is_empty(): - new_jobs.append(job) - - if not new_jobs.is_empty(): - _mutex.lock() - for job in new_jobs: - var s := str(job["sig"]) - if not _live_signature_cache.has(s) and not _pending_jobs_by_sig.has(s): - _pending_jobs_by_sig[s] = job - _mutex.unlock() - _start_worker_if_needed() - -static func pop_newly_completed(last_seen_rev: int, enabled: bool) -> Dictionary: +static func pop_newly_completed(last_seen_rev: int) -> Dictionary: _mutex.lock() var current_rev := _completed_revision - if current_rev == last_seen_rev or not enabled or _newly_completed_sigs.is_empty(): - _mutex.unlock() - return {"revision": current_rev, "newly_completed": {}} - var newly_completed := _newly_completed_sigs.duplicate(true) - _newly_completed_sigs.clear() + var newly := {} if current_rev == last_seen_rev else _newly_completed.duplicate(true) + _newly_completed.clear() _mutex.unlock() - return {"revision": current_rev, "newly_completed": newly_completed} + return {"revision": current_rev, "newly_completed": newly} static func _start_worker_if_needed() -> void: var task_to_reap: int = -1 _mutex.lock() - var should_start := not _shutdown_requested and not _worker_running and not _pending_jobs_by_sig.is_empty() + var should_start := not _shutdown_requested and not _worker_running and not _pending_jobs.is_empty() if should_start: _worker_running = true _active_worker_epoch = _cache_epoch @@ -614,62 +458,32 @@ static func _start_worker_if_needed() -> void: static func _run_ollama_worker() -> void: - var need_probe := false - _mutex.lock() - if not _status_probed and not _status_probe_running and not _shutdown_requested: - _status_probe_running = true - need_probe = true - _mutex.unlock() - - if need_probe: + if not _status_probed: _probe_ollama_status_sync() - else: - while not _shutdown_requested: - _mutex.lock() - var done_probing := _status_probed or not _status_probe_running - _mutex.unlock() - if done_probing: - break - OS.delay_msec(2) - while not _shutdown_requested: - var batch: Array[Dictionary] = [] - var status: Dictionary = {} - var batch_epoch: int = 0 - _mutex.lock() - if _pending_jobs_by_sig.is_empty() or _shutdown_requested: + if _pending_jobs.is_empty() or not _can_enqueue_locked(): + _pending_jobs.clear() _worker_running = false _active_worker_epoch = -1 _mutex.unlock() return - status = _cached_ollama_status.duplicate(true) - var can_eval := bool(status["connected"]) and (bool(status["primary_ready"]) or bool(status["secondary_ready"])) - if not can_eval: - _pending_jobs_by_sig.clear() - _worker_running = false - _active_worker_epoch = -1 - _mutex.unlock() - return - batch_epoch = _cache_epoch + var status := _cached_ollama_status.duplicate(true) + var ctx_len := EvoUserSettings.context_length + var batch_epoch := _cache_epoch _active_worker_epoch = batch_epoch - var keys := _pending_jobs_by_sig.keys() - var count := mini(4, keys.size()) - for i in range(count): - var k := str(keys[i]) - batch.append(_pending_jobs_by_sig[k]) - _pending_jobs_by_sig.erase(k) + var batch: Array[Dictionary] = _pending_jobs.slice(0, MAX_CONCURRENT_JOBS) + _pending_jobs = _pending_jobs.slice(MAX_CONCURRENT_JOBS) _mutex.unlock() - var batch_results := _evaluate_job_batch_with_ollama(batch, status) - if not batch_results.is_empty(): - _mutex.lock() - if _cache_epoch == batch_epoch: - for sig_key in batch_results.keys(): - _live_signature_cache[sig_key] = batch_results[sig_key] - _newly_completed_sigs[sig_key] = batch_results[sig_key] - _completed_revision += 1 - _mutex.unlock() + var batch_results := _evaluate_job_batch_with_ollama(batch, status, ctx_len) + _mutex.lock() + if _cache_epoch == batch_epoch: + for deme_id in batch_results.keys(): + _results_by_deme[deme_id] = batch_results[deme_id] + _newly_completed[deme_id] = batch_results[deme_id] + _completed_revision += 1 + _mutex.unlock() _mutex.lock() _worker_running = false @@ -677,53 +491,58 @@ static func _run_ollama_worker() -> void: _mutex.unlock() +static func build_payload(job: Dictionary, model_tag: String, ctx_len: int) -> Dictionary: + return { + "model": model_tag, + "keep_alive": "30m", + "options": {"num_ctx": ctx_len}, + "state": {"briefing": str(job["briefing"])}, + "questions": { + "decision": { + "type": "choice", + "instructions": str(job["instructions"]), + "criteria": job["criteria"] + } + } + } + + +static func normalize_probabilities(raw: Dictionary, keys: Array) -> Dictionary: + var total := 0.0 + var clean: Dictionary = {} + for key in keys: + var val := float(raw.get(key, 0.0)) + clean[key] = val if is_finite(val) and val > 0.0 else 0.0 + total += float(clean[key]) + if total <= 0.0: + return {} + for key in keys: + clean[key] = float(clean[key]) / total + return clean + + static func _evaluate_job_batch_with_ollama( batch: Array[Dictionary], - status: Dictionary + status: Dictionary, + ctx_len: int ) -> Dictionary: - var results_by_sig: Dictionary = {} + var results_by_deme: Dictionary = {} var active_jobs: Array[Dictionary] = [] - var ctx_len: int = EvoUserSettings.context_length - for item in batch: - if _shutdown_requested: - break var model_tag := _resolve_ollama_model_for_tag(str(item["preferred_tag"]), status) - var payload := { - "model": model_tag, - "keep_alive": "30m", - "options": { - "num_ctx": ctx_len - }, - "state": { - "briefing": str(item["briefing"]), - "strategy_traits_prior": item.get("strategy_traits_prior", {}), - "geopolitics": item.get("geopolitics", {}), - "memory": str(item.get("memory", "quiet")) - }, - "questions": { - "decision": { - "type": "choice", - "instructions": SYSTEM_ONE_INSTRUCTIONS, - "criteria": item["criteria"] - } - } - } - var body_str := JSON.stringify(payload) var client := HTTPClient.new() var err := client.connect_to_host(DEFAULT_OLLAMA_HOST, DEFAULT_OLLAMA_PORT) active_jobs.append({ - "sig": str(item["sig"]), + "deme_id": str(item["deme_id"]), + "keys": (item["criteria"] as Dictionary).keys(), "client": client, - "body": body_str, - "eligible_ids": item["eligible_ids"], + "body": JSON.stringify(build_payload(item, model_tag, ctx_len)), "stage": "connecting" if err == OK else "failed", "rb": PackedByteArray() }) var start_ms := Time.get_ticks_msec() var headers := PackedStringArray(["Content-Type: application/json", "Accept: application/json"]) - while not _shutdown_requested and Time.get_ticks_msec() - start_ms <= EVAL_TIMEOUT_MS: var any_active := false for job in active_jobs: @@ -734,80 +553,52 @@ static func _evaluate_job_batch_with_ollama( var client: HTTPClient = job["client"] client.poll() var st := client.get_status() - if stage == "connecting": - if st in [HTTPClient.STATUS_CONNECTING, HTTPClient.STATUS_RESOLVING]: - continue - elif st == HTTPClient.STATUS_CONNECTED: - var req_err := client.request(HTTPClient.METHOD_POST, "/v1/systemone", headers, str(job["body"])) - job["stage"] = "requesting" if req_err == OK else "failed" - else: + if st == HTTPClient.STATUS_CONNECTED: + job["stage"] = "requesting" if client.request(HTTPClient.METHOD_POST, "/v1/systemone", headers, str(job["body"])) == OK else "failed" + elif st not in [HTTPClient.STATUS_CONNECTING, HTTPClient.STATUS_RESOLVING]: job["stage"] = "failed" - elif stage == "requesting": - if st == HTTPClient.STATUS_REQUESTING: - continue - elif client.has_response() and client.get_response_code() == 200: - if st == HTTPClient.STATUS_BODY: - var chunk := client.read_response_body_chunk() - if chunk.size() > 0: - var rb: PackedByteArray = job["rb"] - rb.append_array(chunk) - job["rb"] = rb - if client.get_status() == HTTPClient.STATUS_BODY: - job["stage"] = "reading_body" - else: - job["stage"] = "done" if (job["rb"] as PackedByteArray).size() > 0 else "failed" - else: + elif st == HTTPClient.STATUS_BODY: + if client.get_response_code() != 200: job["stage"] = "failed" - elif stage == "reading_body": - if st == HTTPClient.STATUS_BODY: - var chunk := client.read_response_body_chunk() - if chunk.size() > 0: - var rb: PackedByteArray = job["rb"] - rb.append_array(chunk) - job["rb"] = rb + continue + var chunk := client.read_response_body_chunk() + if chunk.size() > 0: + var rb: PackedByteArray = job["rb"] + rb.append_array(chunk) + job["rb"] = rb if client.get_status() != HTTPClient.STATUS_BODY: job["stage"] = "done" if (job["rb"] as PackedByteArray).size() > 0 else "failed" - + elif st != HTTPClient.STATUS_REQUESTING: + job["stage"] = "done" if (job["rb"] as PackedByteArray).size() > 0 else "failed" if not any_active: break OS.delay_msec(2) for job in active_jobs: - var client: HTTPClient = job["client"] - client.close() - if _shutdown_requested or str(job["stage"]) != "done": - continue - var raw_text := (job["rb"] as PackedByteArray).get_string_from_utf8() - var parsed = JSON.parse_string(raw_text) - if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("answers"): + (job["client"] as HTTPClient).close() + if str(job["stage"]) != "done": continue - var answers = parsed["answers"] - if typeof(answers) != TYPE_DICTIONARY or not answers.has("decision"): + var body_str := (job["rb"] as PackedByteArray).get_string_from_utf8() + if body_str.is_empty(): continue - var dec_ans = answers["decision"] - if typeof(dec_ans) != TYPE_DICTIONARY or not dec_ans.has("probabilities"): + var parsed = JSON.parse_string(body_str) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("answers") or not (parsed["answers"] as Dictionary).has("decision"): continue - var raw_probs = dec_ans["probabilities"] - if typeof(raw_probs) != TYPE_DICTIONARY: + var answer: Dictionary = parsed["answers"]["decision"] + var probabilities := normalize_probabilities(answer["probabilities"], job["keys"]) + if probabilities.is_empty(): continue - - var eligible_ids: Array[String] = job["eligible_ids"] - var normalized := EvoHeuristicScorer.normalize_decision_probabilities(raw_probs, eligible_ids) - var raw_conf: float = float(dec_ans["confidence"]) if dec_ans.has("confidence") else 0.75 - var clean_conf: float = snappedf(clampf(raw_conf, 0.0, 1.0), 0.0001) if (not is_nan(raw_conf) and not is_inf(raw_conf)) else 0.75 - results_by_sig[str(job["sig"])] = { - "probabilities": normalized["probabilities"], - "dominant_decision_id": str(normalized["dominant_decision_id"]), - "confidence": clean_conf + results_by_deme[job["deme_id"]] = { + "probabilities": probabilities, + "confidence": clampf(float(answer["confidence"]), 0.0, 1.0) } - - return results_by_sig + return results_by_deme static func has_pending_jobs() -> bool: _mutex.lock() - var pending := not _pending_jobs_by_sig.is_empty() or (_worker_running and _active_worker_epoch == _cache_epoch) + var pending := not _pending_jobs.is_empty() or (_worker_running and _active_worker_epoch == _cache_epoch) _mutex.unlock() return pending @@ -816,7 +607,7 @@ static func wait_for_workers() -> void: _mutex.lock() _shutdown_requested = true _pull_cancel_requested = true - _pending_jobs_by_sig.clear() + _pending_jobs.clear() var s_task := _status_task_id var w_task := _worker_task_id var p_task := _pull_task_id diff --git a/src/engine/decision_model.gd b/src/engine/decision_model.gd index d00896b..55267bc 100644 --- a/src/engine/decision_model.gd +++ b/src/engine/decision_model.gd @@ -1,23 +1,25 @@ class_name EvoDecisionModel extends RefCounted -const DEFAULT_OLLAMA_HOST: String = EvoOllamaClient.DEFAULT_OLLAMA_HOST -const DEFAULT_OLLAMA_PORT: int = EvoOllamaClient.DEFAULT_OLLAMA_PORT -const DEFAULT_SYSTEM_ONE_MODEL: String = EvoHeuristicScorer.DEFAULT_SYSTEM_ONE_MODEL -const SECONDARY_SYSTEM_ONE_MODEL: String = EvoHeuristicScorer.SECONDARY_SYSTEM_ONE_MODEL - static func resolve_deme_decision_model_tag( deme_id: String, primary_tag: String = "", secondary_tag: String = "" ) -> String: - return EvoHeuristicScorer.resolve_deme_decision_model_tag(deme_id, primary_tag, secondary_tag) + var p_tag := primary_tag if primary_tag != "" else EvoUserSettings.primary_model + var s_tag := secondary_tag if secondary_tag != "" else EvoUserSettings.secondary_model + if deme_id in ["deme-rift-west", "deme-island-1", "deme-darwin-founder"]: + return p_tag + if deme_id in ["deme-rift-east", "deme-island-2", "deme-darwin-rival"]: + return s_tag + var h: int = 0 + for i in range(deme_id.length()): + h = (h * 31 + deme_id.unicode_at(i)) & 0x7FFFFFFF + return p_tag if (h % 2 == 0) else s_tag static func reassign_world_models(state: EvoTypes.WorldState) -> void: - if state == null: - return for deme_id in state.demes.keys(): var deme: EvoTypes.Deme = state.demes[deme_id] deme.decision_model_tag = resolve_deme_decision_model_tag(deme.id) @@ -27,340 +29,79 @@ static func check_ollama_status(force_refresh: bool = false) -> Dictionary: return EvoOllamaClient.check_ollama_status(force_refresh) -static func refresh_ollama_status_async() -> void: - EvoOllamaClient.refresh_ollama_status_async() - - -static func is_decision_eligible( - deme: EvoTypes.Deme, - dec: EvoTypes.DecisionDef, - traits_catalog: Dictionary = {}, - state: EvoTypes.WorldState = null -) -> bool: - return EvoHeuristicScorer.is_decision_eligible(deme, dec, traits_catalog, state) - - -static func get_eligible_decisions( - deme: EvoTypes.Deme, - decisions_catalog: Dictionary, - traits_catalog: Dictionary = {}, - state: EvoTypes.WorldState = null -) -> Array[EvoTypes.DecisionDef]: - return EvoHeuristicScorer.get_eligible_decisions(deme, decisions_catalog, traits_catalog, state) - - -static func get_eligible_decision_ids( - deme: EvoTypes.Deme, - decisions_catalog: Dictionary, - traits_catalog: Dictionary = {}, - state: EvoTypes.WorldState = null -) -> Array[String]: - return EvoHeuristicScorer.get_eligible_decision_ids(deme, decisions_catalog, traits_catalog, state) - - -static func pick_dominant_decision(probs: Dictionary) -> String: - return EvoHeuristicScorer.pick_dominant_decision(probs) - - -static func compute_environmental_decision_weights( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Dictionary: - return EvoHeuristicScorer.compute_environmental_decision_weights(deme, cell, state) - - -static func modulate_deme_probabilities_for_turn( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> void: - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(deme, cell, state) - - -static func blend_with_environmental_weights( - model_probs: Dictionary, - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState -) -> Dictionary: - var env_weights: Dictionary = {} - var eligible_ids: Array[String] = [] - if deme != null and cell != null and state != null: - var w_key := "%d|%s|%d|%.1f" % [state.generation, cell.id, deme.population, deme.energy_reserves] - if deme.has_meta("env_w_key") and str(deme.get_meta("env_w_key")) == w_key: - env_weights = deme.get_meta("env_w_val") as Dictionary - else: - env_weights = EvoHeuristicScorer.compute_environmental_decision_weights(deme, cell, state) - deme.set_meta("env_w_key", w_key) - deme.set_meta("env_w_val", env_weights) - for k in env_weights.keys(): - eligible_ids.append(str(k)) - else: - eligible_ids = EvoHeuristicScorer.get_eligible_decision_ids(deme, state.decisions_catalog, state.traits_catalog, state) - if not eligible_ids.is_empty(): - env_weights = EvoHeuristicScorer.compute_environmental_decision_weights(deme, cell, state) - if eligible_ids.is_empty(): - return {"probabilities": model_probs.duplicate(), "dominant_decision_id": EvoHeuristicScorer.pick_dominant_decision(model_probs)} - var norm_model: Dictionary = EvoHeuristicScorer.normalize_decision_probabilities(model_probs, eligible_ids)["probabilities"] - var blended: Dictionary = {} - for dec_id in eligible_ids: - var m_prob := float(norm_model.get(dec_id, 0.0)) - var e_weight := float(env_weights.get(dec_id, 0.0)) - blended[dec_id] = 0.75 * m_prob + 0.25 * e_weight - return EvoHeuristicScorer.normalize_decision_probabilities(blended, eligible_ids) - - -static func normalize_decision_probabilities( - raw_scores: Dictionary, - eligible_ids: Array[String] -) -> Dictionary: - return EvoHeuristicScorer.normalize_decision_probabilities(raw_scores, eligible_ids) - - -static func compute_baseline_mobility(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> float: - return EvoHeuristicScorer.compute_baseline_mobility(deme, state) - - -static func build_compact_system_one_state(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: - return EvoHeuristicScorer.build_compact_system_one_state(deme, state) - - -static func build_compact_decision_criteria(eligible_decisions: Array[EvoTypes.DecisionDef]) -> Dictionary: - return EvoHeuristicScorer.build_compact_decision_criteria(eligible_decisions) - - -static func compute_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: - return EvoHeuristicScorer.compute_deme_evaluation_signature(deme, state) - - -static func build_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTypes.WorldState) -> String: - return EvoHeuristicScorer.build_deme_evaluation_signature(deme, state) - - -static func build_contextual_justification( - deme: EvoTypes.Deme, - cell: EvoTypes.HexCell, - state: EvoTypes.WorldState, - dominant_id: String, - dominant_prob: float, - lang: String, - confidence: float = 0.85 -) -> String: - return EvoHeuristicScorer.build_contextual_justification( - deme, - cell, - state, - dominant_id, - dominant_prob, - lang, - confidence - ) - - -static func invalidate_state_caches(state: EvoTypes.WorldState) -> void: - EvoHeuristicScorer.invalidate_state_caches(state) - - static func evaluate_deme( deme: EvoTypes.Deme, state: EvoTypes.WorldState, - lang: String = "es" + enqueue_job: bool = false ) -> Dictionary: if deme.decision_model_tag == "" or (deme.id in ["deme-rift-east", "deme-island-2", "deme-darwin-rival"] and deme.decision_model_tag == EvoUserSettings.primary_model): deme.decision_model_tag = resolve_deme_decision_model_tag(deme.id) - if not state.cells.has(deme.hex_id): return { - "probabilities": deme.last_decision_probabilities, - "dominant_decision_id": deme.last_dominant_decision_id, - "confidence": deme.last_confidence, - "justification": deme.last_justification + "chosen_action": deme.chosen_action, + "probabilities": deme.action_probabilities, + "forecasts": deme.action_forecasts, + "confidence": deme.last_confidence } - - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(deme, cell, state) - var fallback_dom_prob := float(deme.last_decision_probabilities.get(deme.last_dominant_decision_id, 0.5)) - if deme.last_justification == "": - deme.last_justification = build_contextual_justification( - deme, - cell, - state, - deme.last_dominant_decision_id, - fallback_dom_prob, - lang, - deme.last_confidence - ) - - var sig := compute_deme_evaluation_signature(deme, state) - deme.set_meta("eval_sig", sig) - var cached_entry := EvoOllamaClient.lookup_or_enqueue_single_deme(sig, deme, state) - if not cached_entry.is_empty(): - var blended := blend_with_environmental_weights(cached_entry["probabilities"], deme, cell, state) - var probs: Dictionary = blended["probabilities"] - var dominant_id: String = str(blended["dominant_decision_id"]) - var confidence: float = float(cached_entry["confidence"]) - var dominant_prob: float = float(probs[dominant_id]) if probs.has(dominant_id) else 0.5 - var justification := build_contextual_justification( - deme, - cell, - state, - dominant_id, - dominant_prob, - lang, - confidence - ) - deme.last_decision_probabilities = probs.duplicate() - deme.last_dominant_decision_id = dominant_id - deme.last_confidence = confidence - deme.last_justification = justification - return { - "probabilities": probs.duplicate(), - "dominant_decision_id": dominant_id, - "confidence": confidence, - "justification": justification - } - + var job := EvoActionPolicy.prepare_deme_menu(deme, state) + var ready := EvoOllamaClient.take_result(deme.id) + if not ready.is_empty(): + EvoActionPolicy.sample_action(deme, ready["probabilities"], float(ready["confidence"]), state.rng) + elif enqueue_job: + EvoOllamaClient.enqueue_jobs([job]) return { - "probabilities": deme.last_decision_probabilities, - "dominant_decision_id": deme.last_dominant_decision_id, - "confidence": deme.last_confidence, - "justification": deme.last_justification + "chosen_action": deme.chosen_action, + "probabilities": deme.action_probabilities, + "forecasts": deme.action_forecasts, + "confidence": deme.last_confidence } static func evaluate_all_demes( state: EvoTypes.WorldState, - lang: String = "es", - enqueue_missing: bool = true + _lang: String = "es", + enqueue_jobs: bool = true ) -> void: - var need_sigs := EvoOllamaClient.has_cached_or_can_enqueue(enqueue_missing) - var buckets: Dictionary = {} + if enqueue_jobs: + EvoOllamaClient.clear_cache() + var jobs: Array[Dictionary] = [] for deme_id in state.demes.keys(): var deme: EvoTypes.Deme = state.demes[deme_id] if deme.decision_model_tag == "" or (deme.id in ["deme-rift-east", "deme-island-2", "deme-darwin-rival"] and deme.decision_model_tag == EvoUserSettings.primary_model): deme.decision_model_tag = resolve_deme_decision_model_tag(deme.id) - if state.cells.has(deme.hex_id): - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(deme, cell, state) - var dom_p := float(deme.last_decision_probabilities.get(deme.last_dominant_decision_id, 0.5)) - var just_key := "%s|%s|%.2f" % [lang, deme.last_dominant_decision_id, dom_p] - if deme.last_justification == "" or not deme.has_meta("just_key") or str(deme.get_meta("just_key")) != just_key: - deme.set_meta("just_key", just_key) - deme.last_justification = build_contextual_justification( - deme, - cell, - state, - deme.last_dominant_decision_id, - dom_p, - lang, - deme.last_confidence - ) - if need_sigs: - var sig := compute_deme_evaluation_signature(deme, state) - deme.set_meta("eval_sig", sig) - if buckets.has(sig): - (buckets[sig]["demes"] as Array).append(deme) - else: - buckets[sig] = { - "representative": deme, - "demes": [deme] - } - - if not need_sigs: - return - var live_results := EvoOllamaClient.sync_and_enqueue_batch(buckets, state, enqueue_missing) - - for sig in buckets.keys(): - if not live_results.has(sig): + if not state.cells.has(deme.hex_id): continue - var bucket: Dictionary = buckets[sig] - var group: Array = bucket["demes"] - var entry: Dictionary = live_results[sig] - var confidence: float = float(entry["confidence"]) - for d in group: - var deme: EvoTypes.Deme = d - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] if state.cells.has(deme.hex_id) else null - var blended := blend_with_environmental_weights(entry["probabilities"], deme, cell, state) - var probs: Dictionary = blended["probabilities"] - var dominant_id: String = str(blended["dominant_decision_id"]) - var dominant_prob: float = float(probs[dominant_id]) if probs.has(dominant_id) else 0.5 - deme.last_decision_probabilities = probs.duplicate() - deme.last_dominant_decision_id = dominant_id - deme.last_confidence = confidence - if cell != null: - deme.last_justification = build_contextual_justification( - deme, - cell, - state, - dominant_id, - dominant_prob, - lang, - confidence - ) - - -static func prefetch_for_state(state: EvoTypes.WorldState) -> void: - if state == null or state.demes.is_empty() or not EvoOllamaClient.needs_prefetch(): - return - var reps_by_sig: Dictionary = {} - for deme_id in state.demes.keys(): - var deme: EvoTypes.Deme = state.demes[deme_id] - if deme.decision_model_tag == "" or (deme.id in ["deme-rift-east", "deme-island-2", "deme-darwin-rival"] and deme.decision_model_tag == EvoUserSettings.primary_model): - deme.decision_model_tag = resolve_deme_decision_model_tag(deme.id) - var sig := compute_deme_evaluation_signature(deme, state) - deme.set_meta("eval_sig", sig) - if not reps_by_sig.has(sig): - reps_by_sig[sig] = deme - if reps_by_sig.size() >= 8: - break - - EvoOllamaClient.prefetch_for_reps(reps_by_sig, state) - - -static func apply_completed_live_results(state: EvoTypes.WorldState, lang: String, last_seen_rev: int) -> Dictionary: - var popped := EvoOllamaClient.pop_newly_completed( - last_seen_rev, - state != null - ) - var current_rev: int = int(popped["revision"]) + var job := EvoActionPolicy.prepare_deme_menu(deme, state) + var ready := EvoOllamaClient.take_result(deme.id) + if not ready.is_empty(): + EvoActionPolicy.sample_action(deme, ready["probabilities"], float(ready["confidence"]), state.rng) + elif enqueue_jobs: + jobs.append(job) + if enqueue_jobs and not jobs.is_empty(): + EvoOllamaClient.enqueue_jobs(jobs) + + +static func apply_completed_live_results(state: EvoTypes.WorldState, _lang: String, last_seen_rev: int) -> Dictionary: + var popped := EvoOllamaClient.pop_newly_completed(last_seen_rev) + var current_rev := int(popped["revision"]) var newly_completed: Dictionary = popped["newly_completed"] - if newly_completed.is_empty() or state == null: + if newly_completed.is_empty(): return {"updated": false, "dom_changed": false, "revision": current_rev} var any_updated := false - var any_dom_changed := false - for deme_id in state.demes.keys(): + var any_changed := false + for deme_id in newly_completed.keys(): + if not state.demes.has(deme_id): + continue var deme: EvoTypes.Deme = state.demes[deme_id] - var sig: String = str(deme.get_meta("eval_sig", "")) if deme.has_meta("eval_sig") else compute_deme_evaluation_signature(deme, state) - if newly_completed.has(sig): - var entry: Dictionary = newly_completed[sig] - var cell: EvoTypes.HexCell = state.cells[deme.hex_id] if state.cells.has(deme.hex_id) else null - var blended := blend_with_environmental_weights(entry["probabilities"], deme, cell, state) - var probs: Dictionary = blended["probabilities"] - var dominant_id: String = str(blended["dominant_decision_id"]) - var confidence: float = float(entry["confidence"]) - var dominant_prob: float = float(probs[dominant_id]) if probs.has(dominant_id) else 0.5 - var dom_changed := deme.last_dominant_decision_id != dominant_id - deme.last_decision_probabilities = probs.duplicate() - deme.last_dominant_decision_id = dominant_id - deme.last_confidence = confidence - if cell != null: - deme.last_justification = build_contextual_justification( - deme, - cell, - state, - dominant_id, - dominant_prob, - lang, - confidence - ) - if dom_changed: - any_dom_changed = true - if dom_changed or deme.id != "": - any_updated = true + var prev_key := str(deme.chosen_action["key"]) + var entry: Dictionary = newly_completed[deme_id] + EvoActionPolicy.sample_action(deme, entry["probabilities"], float(entry["confidence"]), state.rng) + if str(deme.chosen_action["key"]) != prev_key: + any_changed = true + any_updated = true - return {"updated": any_updated, "dom_changed": any_dom_changed, "revision": current_rev} + return {"updated": any_updated, "dom_changed": any_changed, "revision": current_rev} static func has_pending_jobs() -> bool: @@ -372,7 +113,7 @@ static func wait_for_workers() -> void: static func _slugify(text: String) -> String: - return EvoHeuristicScorer._slugify(text) + return EvoMilestonePhase._slugify(text) static func synthesize_milestone_trait( @@ -381,4 +122,5 @@ static func synthesize_milestone_trait( trigger_type: String, lang: String = "es" ) -> EvoTypes.TraitDef: - return EvoHeuristicScorer.synthesize_milestone_trait(state, deme, trigger_type, lang) + return EvoMilestonePhase.synthesize_milestone_trait(state, deme, trigger_type, lang) + diff --git a/src/engine/env_phase.gd b/src/engine/env_phase.gd index f3d1d81..de84848 100644 --- a/src/engine/env_phase.gd +++ b/src/engine/env_phase.gd @@ -2,22 +2,61 @@ class_name EvoEnvPhase extends RefCounted -static func _prune_stale_pacts(state: EvoTypes.WorldState) -> void: - if state.active_pacts.is_empty(): +static func _prune_stale_relations(state: EvoTypes.WorldState) -> void: + if state.relations.is_empty(): return - var sep := EvoTypes.make_pair_key("a", "b").trim_prefix("a").trim_suffix("b") var stale_keys: Array[String] = [] - for pk_var in state.active_pacts.keys(): + for pk_var in state.relations.keys(): var pk := str(pk_var) - var parts := pk.split(sep) + var parts := pk.split("__") if parts.size() != 2 or not state.demes.has(parts[0]) or not state.demes.has(parts[1]): stale_keys.append(pk) for pk in stale_keys: - state.active_pacts.erase(pk) + state.relations.erase(pk) + + +static func apply_cell_sea_level_transition(cell: EvoTypes.HexCell, sea_level: float) -> bool: + if cell.altitude < sea_level and cell.biome != "ocean": + EvoTurnEngine._apply_biome_climate_defaults(cell, "ocean", true) + return true + if cell.altitude >= sea_level and cell.biome == "ocean": + var emerged_biome := "coastal_shallows" + if cell.altitude >= 0.78: + emerged_biome = "high_mountain" + elif cell.altitude >= 0.55: + emerged_biome = "arid_steppe" + elif cell.altitude >= 0.25: + emerged_biome = "savanna" + EvoTurnEngine._apply_biome_climate_defaults(cell, emerged_biome, false) + return true + return false + + +static func step_cell_seasonal_climate( + cell: EvoTypes.HexCell, + delta_temp: float, + delta_hum: float, + gamma_season: float, + suffered_collapse: bool +) -> void: + cell.temperature = clampf(snappedf(cell.temperature + delta_temp, 0.01), -1.0, 1.0) + cell.humidity = clampf(snappedf(cell.humidity + delta_hum, 0.01), 0.0, 1.0) + var recovery_rate: float = 0.005 if suffered_collapse else 0.05 + var next_k := clampf( + snappedf(cell.carrying_capacity + recovery_rate * (cell.carrying_capacity_max - cell.carrying_capacity), 0.01), + 10.0, + cell.carrying_capacity_max + ) + var next_r := minf( + next_k, + snappedf(cell.current_resources + next_k * cell.seasonal_regen_rate * gamma_season, 0.01) + ) + cell.carrying_capacity = next_k + cell.current_resources = next_r static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = "es") -> void: - _prune_stale_pacts(state) + _prune_stale_relations(state) var prev_generation := state.generation var next_generation := state.generation + 1 @@ -28,10 +67,12 @@ static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = " var prev_exchange_conns: Array[Dictionary] = [] var conn_idx_by_pair: Dictionary = {} for existing_conn in state.disused_connections: + var next_age := int(existing_conn["age"]) + 1 + if next_age > 2: + continue var c_copy := existing_conn.duplicate() - c_copy["age"] = int(existing_conn.get("age", 0)) + 1 - c_copy["broken_gen"] = int(existing_conn.get("broken_gen", prev_generation)) - var pk := EvoTypes.make_pair_key(str(c_copy.get("from_deme_id", "")), str(c_copy.get("to_deme_id", ""))) + c_copy["age"] = next_age + var pk := EvoTypes.make_pair_key(str(c_copy["from_deme_id"]), str(c_copy["to_deme_id"])) conn_idx_by_pair[pk] = prev_exchange_conns.size() prev_exchange_conns.append(c_copy) for fl in state.active_flows: @@ -57,6 +98,7 @@ static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = " }) state.disused_connections = prev_exchange_conns state.active_flows.clear() + state.turn_events.clear() state.recent_extinction_hexes.clear() if state.glacial_sea_level_cycle and next_generation % 8 == 0: @@ -65,99 +107,20 @@ static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = " var biome_changed_hexes: Dictionary = {} for cid in state.cells.keys(): var cell: EvoTypes.HexCell = state.cells[cid] - if cell.altitude < state.sea_level and cell.biome != "ocean": - EvoTurnEngine._apply_biome_climate_defaults(cell, "ocean") - biome_changed_hexes[cell.id] = true - elif cell.altitude >= state.sea_level and cell.biome == "ocean": - if cell.altitude < 0.25: - cell.biome = "coastal_shallows" - elif cell.altitude < 0.55: - cell.biome = "savanna" - elif cell.altitude < 0.78: - cell.biome = "arid_steppe" - else: - cell.biome = "high_mountain" - var defaults: Dictionary = EvoTypes.BIOME_CLIMATE_DEFAULTS[cell.biome] - cell.temperature = float(defaults["temperature"]) - cell.humidity = float(defaults["humidity"]) - cell.seasonal_regen_rate = float(defaults["regen"]) - cell.carrying_capacity_max = maxf(float(defaults["k_max"]), cell.carrying_capacity_max) - cell.carrying_capacity = maxf(roundf(cell.carrying_capacity_max * 0.88), cell.carrying_capacity) - cell.current_resources = maxf(cell.current_resources, roundf(cell.carrying_capacity * 0.35)) + if apply_cell_sea_level_transition(cell, state.sea_level): biome_changed_hexes[cell.id] = true - var is_en := lang == "en" - for cid in state.cells.keys(): - var cell: EvoTypes.HexCell = state.cells[cid] - if cell.biome == "ocean" and not cell.deme_ids.is_empty(): - var resident_ids := cell.deme_ids.duplicate() - cell.deme_ids.clear() - - for deme_id in resident_ids: - if not state.demes.has(deme_id): - continue - var deme: EvoTypes.Deme = state.demes[deme_id] - - var candidates: Array[EvoTypes.HexCell] = [] - for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - if ncell.biome != "ocean" and ncell.deme_ids.size() < 3 and ncell.altitude <= 0.85: - candidates.append(ncell) - - candidates.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: - return a.current_resources > b.current_resources - ) - - if not candidates.is_empty(): - var target_cell := candidates[0] - deme.hex_id = target_cell.id - deme.energy_reserves = snappedf(deme.energy_reserves * 0.80, 0.01) - target_cell.deme_ids.append(deme.id) - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = cell.id - flow.to_hex_id = target_cell.id - flow.flow_type = "migration" - flow.intensity = 0.65 - flow.from_deme_id = deme.id - flow.to_deme_id = deme.id - state.active_flows.append(flow) - else: - state.demes.erase(deme_id) - if not state.recent_extinction_hexes.has(cell.id): - state.recent_extinction_hexes.append(cell.id) - - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-submerge-%d-%s" % [next_generation, deme.id] - entry.generation = next_generation - entry.event_type = "extinction" - entry.title = ("%s Submerged" if is_en else "%s Sumergido") % deme.name - entry.causal_explanation = ( - "%s went extinct when cell (%s) submerged into ocean with no accessible terrestrial refuge." % [deme.name, cell.id] - if is_en - else "%s se extinguió cuando la celda (%s) quedó sumergida en el océano sin refugio terrestre accesible." % [deme.name, cell.id] - ) - entry.hex_id = cell.id - entry.deme_id = deme.id - entry.lineage_id = deme.lineage_id - state.chronicle.append(entry) - - var has_relatives := false - for rem_id in state.demes.keys(): - var rem: EvoTypes.Deme = state.demes[rem_id] - if rem.lineage_id == deme.lineage_id: - has_relatives = true - break - if not has_relatives and state.lineage_tree.has(deme.lineage_id): - (state.lineage_tree[deme.lineage_id] as EvoTypes.LineageNode).extinct_generation = next_generation - - _prune_stale_pacts(state) + resolve_submerged_demes(state, lang) + _prune_stale_relations(state) var delta_temp: float = 0.04 if state.season == "wet_warm" else -0.04 var delta_hum: float = 0.05 if state.season == "wet_warm" else -0.05 var collapsed_prev_gen_hexes: Dictionary = {} - for entry in state.chronicle: + for i in range(state.chronicle.size() - 1, -1, -1): + var entry := state.chronicle[i] + if entry.generation < prev_generation: + break if entry.event_type == "tragedy_of_commons" and entry.generation == prev_generation: collapsed_prev_gen_hexes[entry.hex_id] = true @@ -169,21 +132,124 @@ static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = " if biome_changed_hexes.has(cell.id): continue - cell.temperature = clampf(snappedf(cell.temperature + delta_temp, 0.01), -1.0, 1.0) - cell.humidity = clampf(snappedf(cell.humidity + delta_hum, 0.01), 0.0, 1.0) - - var suffered_collapse := collapsed_prev_gen_hexes.has(cell.id) - var recovery_rate: float = 0.005 if suffered_collapse else 0.05 - var next_k := clampf( - snappedf(cell.carrying_capacity + recovery_rate * (cell.carrying_capacity_max - cell.carrying_capacity), 0.01), - 10.0, - cell.carrying_capacity_max - ) - var next_r := minf( - next_k, - snappedf(cell.current_resources + next_k * cell.seasonal_regen_rate * gamma_season, 0.01) - ) - cell.carrying_capacity = next_k - cell.current_resources = next_r + step_cell_seasonal_climate(cell, delta_temp, delta_hum, gamma_season, collapsed_prev_gen_hexes.has(cell.id)) state.selective_pressure_points += state.selective_pressure_regen_per_turn + + +static func resolve_submerged_demes(state: EvoTypes.WorldState, lang: String = "es") -> void: + var is_en := lang == "en" + for cid in state.cells.keys(): + var cell: EvoTypes.HexCell = state.cells[cid] + var is_uninhabitable := cell.biome in ["ocean", "high_mountain"] or cell.altitude > 0.85 + if is_uninhabitable: + cell.land_use = "wild" + cell.owner_deme_id = "" + cell.has_ruins = false + if not is_uninhabitable or cell.deme_ids.is_empty(): + continue + + var resident_ids := cell.deme_ids.duplicate() + cell.deme_ids.clear() + + for deme_id in resident_ids: + if not state.demes.has(deme_id): + continue + var deme: EvoTypes.Deme = state.demes[deme_id] + + var candidates: Array[EvoTypes.HexCell] = [] + for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if ncell.biome not in ["ocean", "high_mountain"] and ncell.altitude <= 0.85 and ncell.deme_ids.is_empty(): + candidates.append(ncell) + + candidates.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: + return a.current_resources > b.current_resources + ) + + if not candidates.is_empty(): + var target_cell := candidates[0] + target_cell.owner_deme_id = "" + target_cell.land_use = "wild" + target_cell.has_ruins = false + deme.hex_id = target_cell.id + deme.energy_reserves = snappedf(deme.energy_reserves * 0.80, 0.01) + target_cell.deme_ids.append(deme.id) + + for rcid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var rcell: EvoTypes.HexCell = state.cells[rcid] + if rcell.owner_deme_id == deme.id and HexMath.hex_distance(rcell.q, rcell.r, target_cell.q, target_cell.r) != 1: + rcell.owner_deme_id = "" + rcell.land_use = "wild" + + var flow := EvoTypes.HexEdgeFlow.new() + flow.from_hex_id = cell.id + flow.to_hex_id = target_cell.id + flow.flow_type = "migration" + flow.intensity = 0.65 + flow.from_deme_id = deme.id + flow.to_deme_id = deme.id + state.active_flows.append(flow) + else: + EvoTypes.push_event(state, "extinction", cell.id, cell.id, deme.id, "", 0.0, deme.population) + state.demes.erase(deme_id) + cell.has_ruins = false + for rcid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): + var rcell: EvoTypes.HexCell = state.cells[rcid] + if rcell.owner_deme_id == deme_id: + rcell.owner_deme_id = "" + rcell.land_use = "wild" + EvoTypes.erase_deme_relations(state, deme_id) + if not state.recent_extinction_hexes.has(cell.id): + state.recent_extinction_hexes.append(cell.id) + + var entry := EvoTypes.ChronicleEntry.new() + entry.id = "chronicle-submerge-%d-%s" % [state.generation, deme.id] + entry.generation = state.generation + entry.event_type = "extinction" + entry.title = ("%s Submerged" if is_en else "%s Sumergido") % deme.name + entry.causal_explanation = ( + "%s went extinct when cell (%s) submerged into ocean with no accessible terrestrial refuge." % [deme.name, cell.id] + if is_en + else "%s se extinguió cuando la celda (%s) quedó sumergida en el océano sin refugio terrestre accesible." % [deme.name, cell.id] + ) + entry.hex_id = cell.id + entry.deme_id = deme.id + entry.lineage_id = deme.lineage_id + state.chronicle.append(entry) + EvoSettlementPhase.mark_lineage_if_extinct(state, deme.lineage_id) + + EvoSettlementPhase.revalidate_walled_unions(state) + EvoRuralHinterland.enforce_rural_hinterland_quotas(state) + + var valid_flows: Array[EvoTypes.HexEdgeFlow] = [] + for fl in state.active_flows: + if fl.from_deme_id != "" and not state.demes.has(fl.from_deme_id): + continue + if fl.to_deme_id != "" and not state.demes.has(fl.to_deme_id): + continue + if fl.flow_type != "migration": + if fl.from_deme_id != "" and state.demes.has(fl.from_deme_id): + fl.from_hex_id = (state.demes[fl.from_deme_id] as EvoTypes.Deme).hex_id + if fl.to_deme_id != "" and state.demes.has(fl.to_deme_id): + fl.to_hex_id = (state.demes[fl.to_deme_id] as EvoTypes.Deme).hex_id + if (state.cells.has(fl.from_hex_id) and (state.cells[fl.from_hex_id] as EvoTypes.HexCell).biome == "ocean") or (state.cells.has(fl.to_hex_id) and (state.cells[fl.to_hex_id] as EvoTypes.HexCell).biome == "ocean"): + continue + else: + if state.cells.has(fl.to_hex_id) and (state.cells[fl.to_hex_id] as EvoTypes.HexCell).biome == "ocean": + continue + valid_flows.append(fl) + state.active_flows = valid_flows + + var valid_conns: Array[Dictionary] = [] + for conn in state.disused_connections: + var fa := str(conn["from_deme_id"]) + var fb := str(conn["to_deme_id"]) + if (fa != "" and not state.demes.has(fa)) or (fb != "" and not state.demes.has(fb)): + continue + var ha := str(conn["from_hex_id"]) + var hb := str(conn["to_hex_id"]) + if (state.cells.has(ha) and (state.cells[ha] as EvoTypes.HexCell).biome == "ocean") or (state.cells.has(hb) and (state.cells[hb] as EvoTypes.HexCell).biome == "ocean"): + continue + valid_conns.append(conn) + state.disused_connections = valid_conns diff --git a/src/engine/milestone_phase.gd b/src/engine/milestone_phase.gd index cf19ed1..8eb5768 100644 --- a/src/engine/milestone_phase.gd +++ b/src/engine/milestone_phase.gd @@ -2,6 +2,113 @@ class_name EvoMilestonePhase extends RefCounted +static func _slugify(text: String) -> String: + var lower := text.to_lower() + var out := "" + var prev_dash := false + for i in range(lower.length()): + var ch := lower[i] + var is_alnum := (ch >= "a" and ch <= "z") or (ch >= "0" and ch <= "9") + if is_alnum: + out += ch + prev_dash = false + elif not prev_dash and out.length() > 0: + out += "-" + prev_dash = true + out = out.trim_suffix("-") + if out.length() > 28: + out = out.substr(0, 28).trim_suffix("-") + return out if out.length() > 0 else "adaptation" + + +static func synthesize_milestone_trait( + state: EvoTypes.WorldState, + deme: EvoTypes.Deme, + trigger_type: String, + lang: String = "es" +) -> EvoTypes.TraitDef: + var is_en := lang == "en" + var category := "cultural" if trigger_type == "cultural_innovation" else "biological" + var cell: EvoTypes.HexCell = state.cells[deme.hex_id] if state.cells.has(deme.hex_id) else EvoTypes.HexCell.new() + var biome_label := EvoI18n.biome_name(lang, cell.biome) + + var base_name := "" + if category == "cultural": + base_name = "Cooperative Ecological Tradition" if is_en else "Tradición Ecológica Cooperativa" + elif trigger_type == "allopatric_speciation": + base_name = "Allopatric Divergence" if is_en else "Divergencia Alopátrica" + else: + base_name = "Adaptive Homeostasis" if is_en else "Homeostasis Adaptativa" + + var candidate_name := "%s (%s)" % [base_name, biome_label] + if candidate_name.length() > 60: + candidate_name = candidate_name.substr(0, 60) + + var existing_names: Dictionary = {} + for tid in state.traits_catalog.keys(): + var t: EvoTypes.TraitDef = state.traits_catalog[tid] + existing_names[t.name.to_lower()] = true + + var final_name := candidate_name + var suffix := 2 + while existing_names.has(final_name.to_lower()): + var suffix_str := " #%d" % suffix + final_name = candidate_name.substr(0, max(1, 60 - suffix_str.length())) + suffix_str + suffix += 1 + + var baldwin_dec_id := str(deme.get_meta("baldwin_decision_id", "")) if deme.has_meta("baldwin_decision_id") else "" + if baldwin_dec_id == "": + baldwin_dec_id = str(deme.chosen_action["type"]) + + var raw_vec: EvoTypes.BiophysicalVector + if category == "cultural": + raw_vec = EvoTypes.BiophysicalVector.new(0.05, 0.28, 0.20, 0.12, 0.18, 0.35) + elif trigger_type == "baldwin_assimilation" and baldwin_dec_id != "" and state.decisions_catalog.has(baldwin_dec_id): + var dom_dec: EvoTypes.DecisionDef = state.decisions_catalog[baldwin_dec_id] + var dv := dom_dec.vector + raw_vec = EvoTypes.BiophysicalVector.new( + maxf(0.10, dv.metabolic_cost * 0.70), + clampf(maxf(0.15, dv.extraction_efficiency * 0.80), 0.0, 0.85), + clampf(dv.ecological_impact * 0.60, -0.50, 0.50), + clampf(maxf(0.10, dv.spatial_mobility * 0.80), 0.0, 0.85), + clampf(maxf(0.10, dv.social_cohesion * 0.80), 0.0, 0.85), + clampf(maxf(0.20, dv.transmission_threshold * 0.80), 0.10, 0.85) + ) + else: + raw_vec = EvoTypes.BiophysicalVector.new(0.18, 0.35, 0.10, 0.30, 0.20, 0.20) + var balanced_vec := EvoThermodynamics.validate_and_balance_thermodynamics(raw_vec, false) + + var slug := _slugify(final_name) + var sanitized_deme := _slugify(deme.id) + var base_id := "emergent-%d-%s" % [state.generation, slug] + var trait_id := base_id if not state.traits_catalog.has(base_id) else "%s-%s" % [base_id, sanitized_deme] + var id_suffix := 2 + while state.traits_catalog.has(trait_id): + trait_id = "%s-%s-%d" % [base_id, sanitized_deme, id_suffix] + id_suffix += 1 + + var dom_name := baldwin_dec_id if trigger_type == "baldwin_assimilation" else str(deme.chosen_action["type"]) + if state.decisions_catalog.has(dom_name): + dom_name = EvoI18n.decision_name(lang, state.decisions_catalog[dom_name]) + + var desc := ( + "Emergent adaptation consolidating sustained behavioral selection in %s (%s)." % [biome_label, dom_name] + if is_en + else "Adaptación emergente que consolida la selección conductual sostenida en %s (%s)." % [biome_label, dom_name] + ) + + var trait_def := EvoTypes.TraitDef.new() + trait_def.id = trait_id + trait_def.name = final_name + trait_def.category = category + trait_def.origin = "emergent" + trait_def.description = desc + trait_def.vector = balanced_vec + trait_def.mutually_exclusive_with = [] + trait_def.enables_cultural_transmission = false + return trait_def + + static func _compute_set_divergence(set_a: Dictionary, set_b: Dictionary) -> float: var union_set: Dictionary = set_a.duplicate() for k in set_b.keys(): @@ -61,6 +168,7 @@ static func run_milestone_phase( if not state.cells.has(deme.hex_id): continue var cell: EvoTypes.HexCell = state.cells[deme.hex_id] + var action_type := str(deme.chosen_action["type"]) var emergent_bio_count: int = 0 for tid in deme.biological_trait_ids: @@ -105,14 +213,13 @@ static func run_milestone_phase( "priority": 0, "trigger_type": "allopatric_speciation", "deme_id": deme.id, - "decision_id": deme.last_dominant_decision_id + "decision_id": action_type }) var triggered_baldwin_dec := "" for dec_id in deme.baldwin_counters.keys(): var cnt: int = int(deme.baldwin_counters[dec_id]) - var prob: float = float(deme.last_decision_probabilities[dec_id]) if deme.last_decision_probabilities.has(dec_id) else 0.0 - if dec_id != "dec-conservative-forage" and cnt >= 8 and (prob >= 0.35 or deme.last_dominant_decision_id == dec_id): + if str(dec_id) != "forage" and cnt >= 8: triggered_baldwin_dec = str(dec_id) break @@ -124,12 +231,12 @@ static func run_milestone_phase( "decision_id": triggered_baldwin_dec }) - if deme.innovation_counter >= 1.0 and EvoCatalog.deme_has_cultural_capacity(deme, state.traits_catalog) and emergent_cul_count < 2 and deme.last_energy_delta >= 0.0: + if deme.innovation_counter >= 1.0 and EvoCatalog.deme_has_cultural_capacity(deme, state.traits_catalog) and emergent_cul_count < 2 and (deme.last_energy_delta >= 0.0 or deme.energy_reserves >= 35.0): candidates.append({ "priority": 2, "trigger_type": "cultural_innovation", "deme_id": deme.id, - "decision_id": deme.last_dominant_decision_id + "decision_id": action_type }) candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: @@ -145,7 +252,7 @@ static func run_milestone_phase( if state.demes.has(target_id): var deme: EvoTypes.Deme = state.demes[target_id] deme.set_meta("baldwin_decision_id", dec_id) - var new_trait := EvoDecisionModel.synthesize_milestone_trait(state, deme, trigger_type, lang) + var new_trait := synthesize_milestone_trait(state, deme, trigger_type, lang) state.traits_catalog[new_trait.id] = new_trait if new_trait.category == "biological": @@ -155,6 +262,7 @@ static func run_milestone_phase( if not deme.cultural_trait_ids.has(new_trait.id): deme.cultural_trait_ids.append(new_trait.id) + var ancestor_hex_id := deme.hex_id if trigger_type == "baldwin_assimilation": deme.baldwin_counters[dec_id] = 0 if state.lineage_tree.has(deme.lineage_id): @@ -173,9 +281,10 @@ static func run_milestone_phase( lin_c.acquired_trait_ids.append(new_trait.id) elif trigger_type == "allopatric_speciation": var old_lineage_id := deme.lineage_id + ancestor_hex_id = (state.lineage_tree[old_lineage_id] as EvoTypes.LineageNode).founder_hex_id if state.lineage_tree.has(old_lineage_id) else deme.hex_id deme.isolation_turns = 0 - var slug := EvoDecisionModel._slugify(new_trait.name) - var san_deme := EvoDecisionModel._slugify(deme.id) + var slug := _slugify(new_trait.name) + var san_deme := _slugify(deme.id) var base_lin_id := "lineage-%d-%s" % [state.generation, slug] var new_lin_id := base_lin_id if not state.lineage_tree.has(base_lin_id) else "%s-%s" % [base_lin_id, san_deme] var l_suf := 2 @@ -219,6 +328,7 @@ static func run_milestone_phase( (state.lineage_tree[old_lineage_id] as EvoTypes.LineageNode).extinct_generation = state.generation var chronicle_type := "baldwin_trait" if trigger_type == "baldwin_assimilation" else trigger_type + EvoTypes.push_event(state, chronicle_type, ancestor_hex_id if chronicle_type == "allopatric_speciation" else deme.hex_id, deme.hex_id, deme.id) var entry := EvoTypes.ChronicleEntry.new() entry.id = "chronicle-%s-%d-%s" % [chronicle_type, state.generation, deme.id] entry.generation = state.generation @@ -262,7 +372,7 @@ static func run_milestone_phase( for did in state.demes.keys(): living_demes.append(state.demes[did]) var survivor := living_demes[0] - var survivor_root_id := EvoDemographyMigration._strip_migration_suffix(survivor.id) + var survivor_root_id := EvoSettlementPhase.strip_migration_suffix(survivor.id) var ancestor_lineages: Dictionary = {survivor.lineage_id: true} var cur_lid := survivor.lineage_id @@ -286,7 +396,7 @@ static func run_milestone_phase( had_chronicle_this_turn = true if e.event_type == "extinction": has_prior_extinction = true - var e_root_id := EvoDemographyMigration._strip_migration_suffix(e.deme_id) + var e_root_id := EvoSettlementPhase.strip_migration_suffix(e.deme_id) if not ancestor_lineages.has(e.lineage_id) or (e_root_id != survivor_root_id and e_root_id == e.deme_id): has_non_migrant_extinction = true elif e.event_type == "single_survivor": diff --git a/src/engine/turn_engine.gd b/src/engine/turn_engine.gd index be0a2c2..513953c 100644 --- a/src/engine/turn_engine.gd +++ b/src/engine/turn_engine.gd @@ -10,10 +10,12 @@ const COST_ADJUST_SEA_LEVEL: int = 25 static func advance_generation(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: var prev_chronicle_count := state.chronicle.size() + EvoDecisionModel.apply_completed_live_results(state, lang, -1) EvoEnvPhase.run_environmental_phase(state, lang) - EvoDecisionModel.evaluate_all_demes(state, lang, false) EvoBiophysPhase.run_biophysical_phase(state, lang) EvoMilestonePhase.run_milestone_phase(state, lang) + EvoTypes.record_turn_stats(state) + EvoDecisionModel.evaluate_all_demes(state, lang, true) var new_entries: Array[EvoTypes.ChronicleEntry] = [] var has_milestone := false @@ -31,8 +33,6 @@ static func advance_generation(state: EvoTypes.WorldState, lang: String = "es") has_macro_outcome = true outcome_entry = entry - EvoDecisionModel.prefetch_for_state(state) - return { "generation": state.generation, "new_entries": new_entries, @@ -52,114 +52,8 @@ static func _spend_points(state: EvoTypes.WorldState, cost: int) -> void: state.selective_pressure_points -= cost -static func _displace_submerged_demes(state: EvoTypes.WorldState, lang: String = "es") -> void: - var is_en := lang == "en" - for cid in state.cells.keys(): - var cell: EvoTypes.HexCell = state.cells[cid] - if cell.biome != "ocean" or cell.deme_ids.is_empty(): - continue - - var resident_ids := cell.deme_ids.duplicate() - cell.deme_ids.clear() - - for deme_id in resident_ids: - if not state.demes.has(deme_id): - continue - var deme: EvoTypes.Deme = state.demes[deme_id] - - var candidates: Array[EvoTypes.HexCell] = [] - for nid in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[nid] - if ncell.biome != "ocean" and ncell.altitude <= 0.85 and ncell.deme_ids.size() < 3: - candidates.append(ncell) - - candidates.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: - return a.current_resources > b.current_resources - ) - - if not candidates.is_empty(): - var target_cell := candidates[0] - deme.hex_id = target_cell.id - deme.energy_reserves = snappedf(deme.energy_reserves * 0.80, 0.01) - target_cell.deme_ids.append(deme.id) - - var flow := EvoTypes.HexEdgeFlow.new() - flow.from_hex_id = cell.id - flow.to_hex_id = target_cell.id - flow.flow_type = "migration" - flow.intensity = 0.65 - flow.from_deme_id = deme.id - flow.to_deme_id = deme.id - state.active_flows.append(flow) - else: - state.demes.erase(deme_id) - var drowned_pacts: Array[String] = [] - for pk_var in state.active_pacts.keys(): - var pk := str(pk_var) - var sep := "__" if pk.contains("__") else "|" - var parts := pk.split(sep) - if parts.size() == 2 and (parts[0] == deme_id or parts[1] == deme_id): - drowned_pacts.append(pk) - for pk in drowned_pacts: - state.active_pacts.erase(pk) - if not state.recent_extinction_hexes.has(cell.id): - state.recent_extinction_hexes.append(cell.id) - - var entry := EvoTypes.ChronicleEntry.new() - entry.id = "chronicle-submerge-brush-%d-%s" % [state.generation, deme.id] - entry.generation = state.generation - entry.event_type = "extinction" - entry.title = ("%s Submerged" if is_en else "%s Sumergido") % deme.name - entry.causal_explanation = ( - "%s went extinct when cell (%s) submerged into ocean with no accessible terrestrial refuge." % [deme.name, cell.id] - if is_en - else "%s se extinguió cuando la celda (%s) quedó sumergida en el océano sin refugio terrestre accesible." % [deme.name, cell.id] - ) - entry.hex_id = cell.id - entry.deme_id = deme.id - entry.lineage_id = deme.lineage_id - state.chronicle.append(entry) - - var has_relatives := false - for rem_id in state.demes.keys(): - var rem: EvoTypes.Deme = state.demes[rem_id] - if rem.lineage_id == deme.lineage_id: - has_relatives = true - break - if not has_relatives and state.lineage_tree.has(deme.lineage_id): - (state.lineage_tree[deme.lineage_id] as EvoTypes.LineageNode).extinct_generation = state.generation - - var valid_flows: Array[EvoTypes.HexEdgeFlow] = [] - for fl in state.active_flows: - if fl.from_deme_id != "" and not state.demes.has(fl.from_deme_id): - continue - if fl.to_deme_id != "" and not state.demes.has(fl.to_deme_id): - continue - if fl.flow_type != "migration": - if fl.from_deme_id != "" and state.demes.has(fl.from_deme_id): - fl.from_hex_id = (state.demes[fl.from_deme_id] as EvoTypes.Deme).hex_id - if fl.to_deme_id != "" and state.demes.has(fl.to_deme_id): - fl.to_hex_id = (state.demes[fl.to_deme_id] as EvoTypes.Deme).hex_id - if (state.cells.has(fl.from_hex_id) and (state.cells[fl.from_hex_id] as EvoTypes.HexCell).biome == "ocean") or (state.cells.has(fl.to_hex_id) and (state.cells[fl.to_hex_id] as EvoTypes.HexCell).biome == "ocean"): - continue - else: - if state.cells.has(fl.to_hex_id) and (state.cells[fl.to_hex_id] as EvoTypes.HexCell).biome == "ocean": - continue - valid_flows.append(fl) - state.active_flows = valid_flows - - var valid_conns: Array[Dictionary] = [] - for conn in state.disused_connections: - var fa := str(conn.get("from_deme_id", "")) - var fb := str(conn.get("to_deme_id", "")) - if (fa != "" and not state.demes.has(fa)) or (fb != "" and not state.demes.has(fb)): - continue - var ha := str(conn.get("from_hex_id", "")) - var hb := str(conn.get("to_hex_id", "")) - if (state.cells.has(ha) and (state.cells[ha] as EvoTypes.HexCell).biome == "ocean") or (state.cells.has(hb) and (state.cells[hb] as EvoTypes.HexCell).biome == "ocean"): - continue - valid_conns.append(conn) - state.disused_connections = valid_conns +static func _revalidate_walled_unions(state: EvoTypes.WorldState) -> void: + EvoSettlementPhase.revalidate_walled_unions(state) static func _apply_biome_climate_defaults(cell: EvoTypes.HexCell, biome_id: String, reset_to_healthy: bool = false) -> void: @@ -176,6 +70,9 @@ static func _apply_biome_climate_defaults(cell: EvoTypes.HexCell, biome_id: Stri cell.carrying_capacity = 50.0 cell.current_resources = 0.0 cell.seasonal_regen_rate = 0.05 + cell.land_use = "wild" + cell.owner_deme_id = "" + cell.has_ruins = false elif reset_to_healthy: cell.carrying_capacity = snappedf(clampf(cell.carrying_capacity_max * 0.90, 20.0, cell.carrying_capacity_max), 0.01) cell.current_resources = snappedf(clampf(cell.carrying_capacity * 0.85, 10.0, cell.carrying_capacity), 0.01) @@ -212,11 +109,18 @@ static func apply_set_biome( var min_land_alt := maxf(state.sea_level + 0.02, float(defaults["min_alt"])) var max_land_alt := maxf(min_land_alt, float(defaults["max_alt"])) cell.altitude = snappedf(clampf(cell.altitude, min_land_alt, max_land_alt), 0.01) + if cell.biome in ["ocean", "high_mountain"] or cell.altitude > 0.85: + cell.land_use = "wild" + cell.owner_deme_id = "" + cell.has_ruins = false + elif cell.owner_deme_id != "" and cell.land_use in ["crops", "pasture"]: + cell.land_use = "pasture" if cell.biome in ["arid_steppe", "tundra"] else "crops" if not changed: return false _spend_points(state, COST_SET_BIOME) - _displace_submerged_demes(state, lang) + EvoEnvPhase.resolve_submerged_demes(state, lang) + EvoDecisionModel.evaluate_all_demes(state, lang, true) return true @@ -266,18 +170,26 @@ static func apply_adjust_altitude( if next_biome != prev_biome or next_biome == "ocean": _apply_biome_climate_defaults(cell, next_biome, prev_biome == "ocean") + if cell.biome in ["ocean", "high_mountain"] or cell.altitude > 0.85: + cell.land_use = "wild" + cell.owner_deme_id = "" + cell.has_ruins = false + elif cell.owner_deme_id != "" and cell.land_use in ["crops", "pasture"]: + cell.land_use = "pasture" if cell.biome in ["arid_steppe", "tundra"] else "crops" if not changed: return false _spend_points(state, COST_ADJUST_ALTITUDE) - _displace_submerged_demes(state, lang) + EvoEnvPhase.resolve_submerged_demes(state, lang) + EvoDecisionModel.evaluate_all_demes(state, lang, true) return true static func apply_adjust_carrying_capacity( state: EvoTypes.WorldState, hex_ids: Array[String], - factor_delta: float + factor_delta: float, + lang: String = "es" ) -> bool: if hex_ids.is_empty() or not _can_afford(state, COST_ADJUST_CAPACITY): return false @@ -302,8 +214,7 @@ static func apply_adjust_carrying_capacity( ) next_r = clampf(roundf(added_res), 0.0, next_k) else: - var degrade_factor := 0.78 if factor_delta < 0.0 else factor - next_r = snappedf(clampf(cell.current_resources * degrade_factor, 0.0, next_k), 0.01) + next_r = snappedf(clampf(cell.current_resources * 0.78, 0.0, next_k), 0.01) if not is_equal_approx(next_k, prev_k) or not is_equal_approx(next_r, prev_r): cell.carrying_capacity = next_k cell.current_resources = next_r @@ -312,6 +223,7 @@ static func apply_adjust_carrying_capacity( if not changed: return false _spend_points(state, COST_ADJUST_CAPACITY) + EvoDecisionModel.evaluate_all_demes(state, lang, true) return true @@ -331,17 +243,8 @@ static func apply_adjust_sea_level( for cid in state.cells.keys(): var cell: EvoTypes.HexCell = state.cells[cid] - if cell.altitude < next_sea_level and cell.biome != "ocean": - _apply_biome_climate_defaults(cell, "ocean", true) - elif cell.altitude >= next_sea_level and cell.biome == "ocean": - var emerged_biome := "coastal_shallows" - if cell.altitude >= 0.78: - emerged_biome = "high_mountain" - elif cell.altitude >= 0.55: - emerged_biome = "arid_steppe" - elif cell.altitude >= 0.25: - emerged_biome = "savanna" - _apply_biome_climate_defaults(cell, emerged_biome, false) - - _displace_submerged_demes(state, lang) + EvoEnvPhase.apply_cell_sea_level_transition(cell, next_sea_level) + + EvoEnvPhase.resolve_submerged_demes(state, lang) + EvoDecisionModel.evaluate_all_demes(state, lang, true) return true diff --git a/src/main.gd b/src/main.gd index 0906364..b627237 100644 --- a/src/main.gd +++ b/src/main.gd @@ -1,8 +1,7 @@ class_name EvoMainController extends Node2D -const NORMAL_STEP_INTERVAL: float = 1.50 -const FAST_STEP_INTERVAL: float = 0.30 +const STEP_INTERVALS: Array[float] = [4.0, 2.0, 0.6] const SLOT_A_DEME_IDS: Array[String] = ["deme-rift-west", "deme-darwin-founder", "deme-island-1"] const SLOT_B_DEME_IDS: Array[String] = ["deme-rift-east", "deme-darwin-rival", "deme-island-2"] @@ -13,20 +12,9 @@ var ui_layer: CanvasLayer var hud: EvoMainHud var lang: String = "es" -var current_lang: String: - get: - return lang - set(value): - lang = value - if world_renderer != null: - world_renderer.lang = value - -var lab_modal: EvoLabModal: - get: - return hud.lab_modal if hud != null else null var is_playing: bool = false -var is_fast: bool = false +var speed_index: int = 1 var quit_on_close_enabled: bool = true var _play_timer: float = 0.0 var _last_model_revision: int = 0 @@ -74,14 +62,14 @@ func _process(delta: float) -> void: var async_res := EvoDecisionModel.apply_completed_live_results(world_state, lang, _last_model_revision) _last_model_revision = int(async_res["revision"]) if bool(async_res["updated"]): - hud.refresh(world_state) + hud.refresh_inspector_only(world_state) world_renderer.refresh_village_decisions() - if bool(async_res.get("dom_changed", true)): - world_renderer.refresh() + if bool(async_res["dom_changed"]): + world_renderer.refresh_overlay(world_state) if not is_playing: return - var interval: float = FAST_STEP_INTERVAL if is_fast else NORMAL_STEP_INTERVAL + var interval: float = STEP_INTERVALS[clampi(speed_index, 0, STEP_INTERVALS.size() - 1)] _play_timer += delta if _play_timer >= interval: if EvoDecisionModel.has_pending_jobs(): @@ -168,17 +156,12 @@ func load_preset(preset_id: String, strategy_a_id: String = "", strategy_b_id: S EvoOllamaClient.clear_cache() var preserved_traits: Dictionary = {} - var preserved_decisions: Dictionary = {} var preserved_strategies: Dictionary = {} if world_state != null: for tid in world_state.traits_catalog.keys(): var tr: EvoTypes.TraitDef = world_state.traits_catalog[tid] if tr.origin in ["user", "custom"]: preserved_traits[tid] = tr.duplicate_trait() - for did in world_state.decisions_catalog.keys(): - var dec: EvoTypes.DecisionDef = world_state.decisions_catalog[did] - if dec.origin in ["user", "custom"]: - preserved_decisions[did] = dec.duplicate_decision() for sid in world_state.strategies_catalog.keys(): var strat: EvoTypes.StrategyDef = world_state.strategies_catalog[sid] if strat.origin in ["user", "custom"]: @@ -197,9 +180,6 @@ func load_preset(preset_id: String, strategy_a_id: String = "", strategy_b_id: S for tid in preserved_traits.keys(): if not world_state.traits_catalog.has(tid): world_state.traits_catalog[tid] = preserved_traits[tid] - for did in preserved_decisions.keys(): - if not world_state.decisions_catalog.has(did): - world_state.decisions_catalog[did] = preserved_decisions[did] for sid in preserved_strategies.keys(): if not world_state.strategies_catalog.has(sid): world_state.strategies_catalog[sid] = preserved_strategies[sid] @@ -253,10 +233,10 @@ func _reapply_starting_strategies() -> void: return for did in SLOT_A_DEME_IDS: if world_state.demes.has(did): - EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], world_state.strategy_a_id, lang) + EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], world_state.strategy_a_id) for did in SLOT_B_DEME_IDS: if world_state.demes.has(did): - EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], world_state.strategy_b_id, lang) + EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], world_state.strategy_b_id) EvoDecisionModel.evaluate_all_demes(world_state, lang) @@ -265,20 +245,19 @@ func _on_strategy_saved(strategy_id: String) -> void: return EvoUserSettings.save_user_catalog(world_state) EvoOllamaClient.clear_cache() - EvoDecisionModel.invalidate_state_caches(world_state) if world_state.generation == 0 and (strategy_id == world_state.strategy_a_id or strategy_id == world_state.strategy_b_id): if strategy_id == world_state.strategy_a_id: for did in SLOT_A_DEME_IDS: if world_state.demes.has(did): - EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], strategy_id, lang) + EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], strategy_id) if strategy_id == world_state.strategy_b_id: for did in SLOT_B_DEME_IDS: if world_state.demes.has(did): - EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], strategy_id, lang) + EvoCatalog.apply_strategy_to_deme(world_state, world_state.demes[did], strategy_id) for did in world_state.demes.keys(): var d: EvoTypes.Deme = world_state.demes[did] if d.strategy_id == strategy_id: - EvoCatalog.apply_strategy_to_deme(world_state, d, strategy_id, lang) + EvoCatalog.apply_strategy_to_deme(world_state, d, strategy_id) EvoDecisionModel.evaluate_all_demes(world_state, lang) elif world_state.strategies_catalog.has(strategy_id): var strat: EvoTypes.StrategyDef = world_state.strategies_catalog[strategy_id] @@ -290,7 +269,7 @@ func _on_strategy_saved(strategy_id: String) -> void: var lin: EvoTypes.LineageNode = world_state.lineage_tree[d.lineage_id] if lin.divergence_reason != "allopatric_speciation": lin.color = strat.color - EvoDecisionModel.prefetch_for_state(world_state) + EvoDecisionModel.evaluate_all_demes(world_state, lang) hud.refresh(world_state) world_renderer.refresh() @@ -334,7 +313,6 @@ func step_generation() -> void: if hud != null and (hud._ollama_startup_blocked or (hud._missing_models_modal != null and hud._missing_models_modal.visible)): return var res := EvoTurnEngine.advance_generation(world_state, lang) - hud.mark_generation_advanced() _sync_selection_after_world_mutation() if bool(res["has_macro_outcome"]) or (world_state.auto_pause_on_milestone and bool(res["has_milestone"])): @@ -342,6 +320,7 @@ func step_generation() -> void: hud.set_playing_state(false) hud.refresh(world_state) + world_renderer.set("replay_seconds", STEP_INTERVALS[clampi(speed_index, 0, STEP_INTERVALS.size() - 1)]) world_renderer.refresh() @@ -364,9 +343,6 @@ func _apply_brush_at_hex(hex_id: String) -> void: applied = EvoTurnEngine.apply_adjust_carrying_capacity(world_state, target_hexes, delta_k) if applied: - EvoDecisionModel.invalidate_state_caches(world_state) - EvoDecisionModel.evaluate_all_demes(world_state, lang) - hud.mark_tool_used() if world_renderer.selected_deme_id != "" and world_state.demes.has(world_renderer.selected_deme_id) and (world_state.demes[world_renderer.selected_deme_id] as EvoTypes.Deme).hex_id != hex_id: _sync_selection_after_world_mutation() else: @@ -391,7 +367,7 @@ func _on_world_hex_clicked(hex_id: String, button_index: int) -> void: else: world_renderer.selected_deme_id = "" hud.select_hex(hex_id) - world_renderer.refresh() + world_renderer.refresh_overlay(world_state) elif tool_id in ["set_biome", "adjust_altitude", "adjust_carrying_capacity"]: _apply_brush_at_hex(hex_id) @@ -406,7 +382,7 @@ func _on_world_deme_clicked(deme_id: String, hex_id: String) -> void: world_renderer.selected_deme_id = deme_id world_renderer.selected_hex_id = hex_id hud.select_deme(deme_id) - world_renderer.refresh() + world_renderer.refresh_overlay(world_state) func _on_state_imported(new_state: EvoTypes.WorldState) -> void: @@ -434,7 +410,6 @@ func _on_state_imported(new_state: EvoTypes.WorldState) -> void: world_renderer.highlighted_lineage_id = "" hud._selected_hex_id = world_renderer.selected_hex_id EvoDecisionModel.evaluate_all_demes(world_state, hud._lang if hud != null else lang) - EvoDecisionModel.prefetch_for_state(world_state) world_renderer.set_world_state(world_state) hud.refresh(world_state) @@ -455,8 +430,8 @@ func _on_quit_requested() -> void: get_tree().quit() -func _on_speed_changed(p_fast: bool) -> void: - is_fast = p_fast +func _on_speed_changed(p_speed_index: int) -> void: + speed_index = p_speed_index _play_timer = 0.0 @@ -491,26 +466,25 @@ func _on_decision_models_changed() -> void: func _on_camera_preset_requested(action: String, value: float) -> void: if action == "pitch": camera.set_pitch_preset(value) - elif action == "yaw": - camera.rotate_by(value) + elif action == "fit": + camera.apply_preset("fit") elif action == "reset": camera.reset_camera() + else: + camera.apply_preset(action) func _on_tool_changed(tool_id: String, _sub_mode: String, brush_radius: int) -> void: camera.active_tool = tool_id world_renderer.active_tool = tool_id world_renderer.brush_radius = brush_radius - world_renderer.refresh() + world_renderer.refresh_overlay(world_state) func _on_sea_level_change_requested(delta: float) -> void: if world_state == null: return if EvoTurnEngine.apply_adjust_sea_level(world_state, delta, lang): - EvoDecisionModel.invalidate_state_caches(world_state) - EvoDecisionModel.evaluate_all_demes(world_state, lang) - hud.mark_tool_used() _sync_selection_after_world_mutation() hud.refresh(world_state) world_renderer.refresh() @@ -528,7 +502,7 @@ func _on_hud_deme_selected(deme_id: String, hex_id: String) -> void: var c: EvoTypes.HexCell = world_state.cells[hex_id] var focus_alt := maxf(c.altitude, world_state.sea_level) if c.biome == "ocean" else c.altitude camera.focus_on_hex(c.q, c.r, focus_alt) - world_renderer.refresh() + world_renderer.refresh_overlay(world_state) func _on_hud_hex_jump_requested(hex_id: String, deme_id: String) -> void: @@ -539,19 +513,17 @@ func _on_hud_hex_jump_requested(hex_id: String, deme_id: String) -> void: var c: EvoTypes.HexCell = world_state.cells[hex_id] var focus_alt := maxf(c.altitude, world_state.sea_level) if c.biome == "ocean" else c.altitude camera.focus_on_hex(c.q, c.r, focus_alt) - world_renderer.refresh() + world_renderer.refresh_overlay(world_state) func _on_lineage_highlight_requested(lineage_id: String) -> void: world_renderer.highlighted_lineage_id = lineage_id - world_renderer.refresh() + world_renderer.refresh_overlay(world_state) func _on_inject_trait_requested(_deme_id: String, _trait_id: String) -> void: if world_state != null: - EvoDecisionModel.invalidate_state_caches(world_state) EvoDecisionModel.evaluate_all_demes(world_state, lang) - EvoDecisionModel.prefetch_for_state(world_state) hud.refresh(world_state) world_renderer.refresh() @@ -563,8 +535,6 @@ func _on_catalog_item_saved(item_type: String, item_data: Variant = null) -> voi _on_strategy_saved(str((item_data as Dictionary)["id"])) return EvoOllamaClient.clear_cache() - EvoDecisionModel.invalidate_state_caches(world_state) EvoDecisionModel.evaluate_all_demes(world_state, hud._lang if hud != null else lang) - EvoDecisionModel.prefetch_for_state(world_state) hud.refresh(world_state) world_renderer.refresh() diff --git a/src/ui/hud/hud_banners_toasts.gd b/src/ui/hud/hud_banners_toasts.gd index dc49ef5..179847e 100644 --- a/src/ui/hud/hud_banners_toasts.gd +++ b/src/ui/hud/hud_banners_toasts.gd @@ -1,6 +1,25 @@ class_name EvoHudBannersToasts extends RefCounted +const EVENT_FEED_FILTER_IDS: Array[String] = [ + "all", + "war", + "diplomacy", + "health", + "evolution" +] + +const EVENT_FEED_FILTER_MAP: Dictionary = { + "war": ["war", "war_repelled", "siege", "raid", "betrayal", "pillage"], + "diplomacy": ["pact", "tribute", "merge", "walled_union", "absorption", "colonize"], + "health": ["epidemic", "epidemic_spread", "epidemic_recovered", "famine", "tragedy_of_commons"], + "evolution": [ + "baldwin_trait", "cultural_innovation", "allopatric_speciation", "symbiosis", + "cultural_loss", "extinction", "total_extinction", "single_survivor", + "ecological_equilibrium", "fortify" + ] +} + static func build_banners_and_toasts(hud: Control) -> void: # Top-Center Golden Parchment Milestone / Outcome Banner @@ -12,8 +31,8 @@ static func build_banners_and_toasts(hud: Control) -> void: hud._milestone_banner.anchor_bottom = 0.0 hud._milestone_banner.offset_left = -220 hud._milestone_banner.offset_right = 220 - hud._milestone_banner.offset_top = 12 - hud._milestone_banner.offset_bottom = 92 + hud._milestone_banner.offset_top = 76 + hud._milestone_banner.offset_bottom = 156 hud._milestone_banner.z_index = 25 var banner_style := EvoUiStyle.make_parchment_card(Color("#fef3c7"), EvoUiStyle.COLOR_GOLD_BRIGHT, 8) banner_style.border_width_left = 4 @@ -49,228 +68,201 @@ static func build_banners_and_toasts(hud: Control) -> void: close_banner_btn.pressed.connect(func() -> void: hud._milestone_banner.visible = false) b_hbox.add_child(close_banner_btn) - # Bottom-Left Chronicle Toast Stack - hud._toast_vbox = VBoxContainer.new() - hud._toast_vbox.anchor_left = 0.0 - hud._toast_vbox.anchor_right = 0.0 - hud._toast_vbox.anchor_top = 1.0 - hud._toast_vbox.anchor_bottom = 1.0 - hud._toast_vbox.offset_left = 10 - hud._toast_vbox.offset_right = 310 - hud._toast_vbox.offset_top = -240 - hud._toast_vbox.offset_bottom = -16 - hud._toast_vbox.alignment = BoxContainer.ALIGNMENT_END - hud._toast_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE - hud._toast_vbox.add_theme_constant_override("separation", 5) - hud.add_child(hud._toast_vbox) - - # Compact Collapsible First-Steps Quest Note (Hidden by default to keep bottom-left clear) - hud._first_steps_panel = PanelContainer.new() - hud._first_steps_panel.visible = false - hud._first_steps_panel.anchor_left = 0.0 - hud._first_steps_panel.anchor_right = 0.0 - hud._first_steps_panel.anchor_top = 1.0 - hud._first_steps_panel.anchor_bottom = 1.0 - hud._first_steps_panel.offset_left = 10 - hud._first_steps_panel.offset_right = 278 - hud._first_steps_panel.offset_top = -88 - hud._first_steps_panel.offset_bottom = -8 - hud._first_steps_panel.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(5)) - hud.add_child(hud._first_steps_panel) - - var fs_hbox := HBoxContainer.new() - fs_hbox.add_theme_constant_override("separation", 6) - hud._first_steps_panel.add_child(fs_hbox) - - var fs_vbox := VBoxContainer.new() - fs_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - fs_vbox.add_theme_constant_override("separation", 1) - fs_hbox.add_child(fs_vbox) - - hud._step1_lbl = Label.new() - hud._step1_lbl.add_theme_font_size_override("font_size", 10) - fs_vbox.add_child(hud._step1_lbl) - - hud._step2_lbl = Label.new() - hud._step2_lbl.add_theme_font_size_override("font_size", 10) - fs_vbox.add_child(hud._step2_lbl) - - hud._step3_lbl = Label.new() - hud._step3_lbl.add_theme_font_size_override("font_size", 10) - fs_vbox.add_child(hud._step3_lbl) - - var hide_fs_btn := Button.new() - hide_fs_btn.text = "✕" - hide_fs_btn.custom_minimum_size = Vector2(22, 22) - hide_fs_btn.pressed.connect(func() -> void: hud._first_steps_panel.visible = false) - fs_hbox.add_child(hide_fs_btn) - - # Startup Ollama Offline / Missing Decision Models Modal (Full-Screen Blocking Backdrop) - hud._missing_models_modal = ColorRect.new() - hud._missing_models_modal.visible = false - hud._missing_models_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - hud._missing_models_modal.color = Color(0.06, 0.04, 0.03, 0.72) - hud._missing_models_modal.mouse_filter = Control.MOUSE_FILTER_STOP - hud._missing_models_modal.z_index = 60 - hud.add_child(hud._missing_models_modal) - - var mm_card := PanelContainer.new() - mm_card.anchor_left = 0.5 - mm_card.anchor_right = 0.5 - mm_card.anchor_top = 0.5 - mm_card.anchor_bottom = 0.5 - mm_card.offset_left = -230 - mm_card.offset_right = 230 - mm_card.offset_top = -120 - mm_card.offset_bottom = 120 - mm_card.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(8)) - hud._missing_models_modal.add_child(mm_card) - - var mm_inner := PanelContainer.new() - mm_inner.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(10, false)) - mm_card.add_child(mm_inner) - - var mm_vbox := VBoxContainer.new() - mm_vbox.add_theme_constant_override("separation", 8) - mm_inner.add_child(mm_vbox) - - hud._missing_models_title_lbl = Label.new() - hud._missing_models_title_lbl.add_theme_font_size_override("font_size", 14) - hud._missing_models_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - hud._missing_models_title_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - mm_vbox.add_child(hud._missing_models_title_lbl) - - hud._missing_models_desc_lbl = Label.new() - hud._missing_models_desc_lbl.add_theme_font_size_override("font_size", 11) - hud._missing_models_desc_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - hud._missing_models_desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - mm_vbox.add_child(hud._missing_models_desc_lbl) - - hud._missing_models_progress_bar = ProgressBar.new() - hud._missing_models_progress_bar.visible = false - hud._missing_models_progress_bar.min_value = 0.0 - hud._missing_models_progress_bar.max_value = 100.0 - hud._missing_models_progress_bar.value = 0.0 - hud._missing_models_progress_bar.show_percentage = true - hud._missing_models_progress_bar.custom_minimum_size = Vector2(0, 16) - EvoUiStyle.style_progress_bar(hud._missing_models_progress_bar, EvoUiStyle.COLOR_GREEN_OK) - mm_vbox.add_child(hud._missing_models_progress_bar) + EvoHudSettingsModals.build_missing_models_modal(hud) - hud._missing_models_status_lbl = Label.new() - hud._missing_models_status_lbl.visible = false - hud._missing_models_status_lbl.add_theme_font_size_override("font_size", 11) - hud._missing_models_status_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) - hud._missing_models_status_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - mm_vbox.add_child(hud._missing_models_status_lbl) - - var mm_btns := HBoxContainer.new() - mm_btns.alignment = BoxContainer.ALIGNMENT_END - mm_btns.add_theme_constant_override("separation", 8) - mm_vbox.add_child(mm_btns) - - hud._missing_models_skip_btn = Button.new() - hud._missing_models_skip_btn.pressed.connect(hud._on_missing_models_skip_pressed) - mm_btns.add_child(hud._missing_models_skip_btn) - - hud._missing_models_download_btn = Button.new() - hud._missing_models_download_btn.pressed.connect(hud._on_missing_models_download_pressed) - mm_btns.add_child(hud._missing_models_download_btn) - - -static func build_legend_panel(hud: Control) -> void: - var legend_panel := PanelContainer.new() - legend_panel.anchor_left = 0.0 - legend_panel.anchor_right = 0.0 - legend_panel.anchor_top = 0.0 - legend_panel.anchor_bottom = 0.0 - legend_panel.offset_left = 10 - legend_panel.offset_right = 236 - legend_panel.offset_top = 132 - legend_panel.offset_bottom = 262 - legend_panel.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(5)) - hud.add_child(legend_panel) + +static func build_event_feed_panel(hud: Control) -> void: + hud._event_feed_panel = PanelContainer.new() + hud._event_feed_panel.anchor_left = 0.0 + hud._event_feed_panel.anchor_right = 0.0 + hud._event_feed_panel.anchor_top = 1.0 + hud._event_feed_panel.anchor_bottom = 1.0 + hud._event_feed_panel.offset_left = 10 + hud._event_feed_panel.offset_right = 380 + hud._event_feed_panel.offset_top = -270 + hud._event_feed_panel.offset_bottom = -10 + hud._event_feed_panel.z_index = 18 + hud._event_feed_panel.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(5)) + hud.add_child(hud._event_feed_panel) var outer_vbox := VBoxContainer.new() - outer_vbox.add_theme_constant_override("separation", 3) - legend_panel.add_child(outer_vbox) + outer_vbox.add_theme_constant_override("separation", 4) + hud._event_feed_panel.add_child(outer_vbox) + # Header Row: Title + Filter Pills + Collapse Button var header_row := HBoxContainer.new() - header_row.add_theme_constant_override("separation", 4) + header_row.add_theme_constant_override("separation", 3) outer_vbox.add_child(header_row) var title_lbl := Label.new() title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title_lbl.clip_text = true title_lbl.add_theme_font_size_override("font_size", 11) title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GOLD_TEXT) header_row.add_child(title_lbl) + var filter_btns: Dictionary = {} + for f_id in EVENT_FEED_FILTER_IDS: + var f_btn := Button.new() + f_btn.custom_minimum_size = Vector2(26, 22) + f_btn.add_theme_font_size_override("font_size", 10) + f_btn.pressed.connect(func() -> void: + hud._event_feed_filter = f_id + refresh_event_feed(hud) + ) + header_row.add_child(f_btn) + filter_btns[f_id] = f_btn + var body_card := PanelContainer.new() - body_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(6, false)) + body_card.size_flags_vertical = Control.SIZE_EXPAND_FILL + body_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(5, false)) outer_vbox.add_child(body_card) - var toggle_btn := Button.new() - toggle_btn.text = "▾" - toggle_btn.custom_minimum_size = Vector2(20, 20) - toggle_btn.add_theme_font_size_override("font_size", 10) - toggle_btn.pressed.connect(func() -> void: - body_card.visible = not body_card.visible - toggle_btn.text = "▾" if body_card.visible else "▸" + var collapse_btn := Button.new() + collapse_btn.text = "▾" + collapse_btn.custom_minimum_size = Vector2(22, 22) + collapse_btn.add_theme_font_size_override("font_size", 10) + collapse_btn.pressed.connect(func() -> void: + hud._event_feed_collapsed = not hud._event_feed_collapsed + body_card.visible = not hud._event_feed_collapsed + collapse_btn.text = "▸" if hud._event_feed_collapsed else "▾" + hud._event_feed_panel.offset_top = -46 if hud._event_feed_collapsed else -270 ) - header_row.add_child(toggle_btn) + header_row.add_child(collapse_btn) + + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + body_card.add_child(scroll) - var items_vbox := VBoxContainer.new() - items_vbox.add_theme_constant_override("separation", 3) - body_card.add_child(items_vbox) + hud._event_feed_vbox = VBoxContainer.new() + hud._event_feed_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hud._event_feed_vbox.add_theme_constant_override("separation", 3) + scroll.add_child(hud._event_feed_vbox) - var legend_defs := [ - {"key": "legend_flow_pact", "icon": "icon_pact", "color": Color("#10b981")}, - {"key": "legend_flow_war", "icon": "icon_war", "color": Color("#ef4444")}, - {"key": "legend_flow_raid", "icon": "icon_raid", "color": Color("#f97316")}, - {"key": "legend_flow_betrayal", "icon": "icon_betrayal", "color": Color("#be123c")} + hud.set_meta("_event_feed_title_lbl", title_lbl) + hud.set_meta("_event_feed_filter_btns", filter_btns) + + +static func refresh_event_feed(hud: Control) -> void: + if hud._event_feed_panel == null or hud._event_feed_vbox == null: + return + + var lang: String = hud._lang + var title_lbl := hud.get_meta("_event_feed_title_lbl") as Label + title_lbl.text = EvoUiStyle.tr_text(lang, "📜 Crónica en Vivo", "📜 Live Chronicle") + + var filter_btns: Dictionary = hud.get_meta("_event_feed_filter_btns") + for f_id in EVENT_FEED_FILTER_IDS: + var btn := filter_btns[f_id] as Button + btn.text = _filter_short_label(lang, f_id) + btn.tooltip_text = _filter_tooltip_label(lang, f_id) + EvoUiStyle.apply_button_active(btn, hud._event_feed_filter == f_id) + + if hud._world_state == null: + for child in hud._event_feed_vbox.get_children(): + hud._event_feed_vbox.remove_child(child) + child.queue_free() + hud.set_meta("_last_event_feed_key", "") + return + + var state: EvoTypes.WorldState = hud._world_state + var active_filter: String = hud._event_feed_filter + var last_entry_id: String = (state.chronicle[-1] as EvoTypes.ChronicleEntry).id if not state.chronicle.is_empty() else "" + var feed_key := "%d:%d:%s:%s:%s:%d" % [ + state.get_instance_id(), + state.generation, + last_entry_id, + active_filter, + lang, + state.chronicle.size() ] - var legend_labels: Dictionary = {} - for def in legend_defs: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - items_vbox.add_child(row) - - var swatch := ColorRect.new() - swatch.custom_minimum_size = Vector2(4, 14) - swatch.size_flags_vertical = Control.SIZE_SHRINK_CENTER - swatch.color = def["color"] - row.add_child(swatch) - - var icon_rect := TextureRect.new() - icon_rect.custom_minimum_size = Vector2(14, 14) - icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - icon_rect.texture = EvoUiStyle.get_pixel_icon(str(def["icon"])) - row.add_child(icon_rect) - - var lbl := Label.new() - lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL - lbl.clip_text = true - lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - lbl.add_theme_font_size_override("font_size", 10) - lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - row.add_child(lbl) - legend_labels[str(def["key"])] = lbl - - hud.set_meta("_legend_title_lbl", title_lbl) - hud.set_meta("_legend_labels", legend_labels) - refresh_legend_texts(hud) - - -static func refresh_legend_texts(hud: Control) -> void: - if not hud.has_meta("_legend_title_lbl") or not hud.has_meta("_legend_labels"): + if str(hud.get_meta("_last_event_feed_key", "")) == feed_key and hud._event_feed_vbox.get_child_count() > 0: return - var title_lbl := hud.get_meta("_legend_title_lbl") as Label - title_lbl.text = EvoUiStyle.tr_text(hud._lang, "🗺️ Leyenda Geopolítica", "🗺️ Geopolitical Legend") - var legend_labels: Dictionary = hud.get_meta("_legend_labels") - for k in legend_labels.keys(): - var lbl := legend_labels[k] as Label - lbl.text = EvoI18n.t(hud._lang, str(k)) + hud.set_meta("_last_event_feed_key", feed_key) + + for child in hud._event_feed_vbox.get_children(): + hud._event_feed_vbox.remove_child(child) + child.queue_free() + + var allowed_types: Array = EVENT_FEED_FILTER_MAP[active_filter] if EVENT_FEED_FILTER_MAP.has(active_filter) else [] + + var shown := 0 + for i in range(state.chronicle.size() - 1, -1, -1): + var entry: EvoTypes.ChronicleEntry = state.chronicle[i] + if active_filter != "all" and not (entry.event_type in allowed_types): + continue + var ev_color := EvoUiStyle.get_event_type_color(entry.event_type) + var is_current := (entry.generation == state.generation) + + var row_btn := Button.new() + row_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + row_btn.clip_text = true + row_btn.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + row_btn.custom_minimum_size = Vector2(0, 24) + row_btn.add_theme_font_size_override("font_size", 10) + row_btn.text = "G%d · (%s) · %s" % [ + entry.generation, + entry.hex_id, + _clean_toast_title(entry.title) + ] + row_btn.tooltip_text = "%s\n%s" % [entry.title, entry.causal_explanation] + + var bg_col := Color("#fff8db") if is_current else Color("#fbf2de") + var border_col := ev_color.lightened(0.15) if is_current else ev_color + var normal_style := EvoUiStyle.make_parchment_card(bg_col, border_col, 4) + normal_style.border_width_left = 4 + var hover_style := EvoUiStyle.make_parchment_card(Color("#fef08a"), border_col, 4) + hover_style.border_width_left = 4 + row_btn.add_theme_stylebox_override("normal", normal_style) + row_btn.add_theme_stylebox_override("hover", hover_style) + row_btn.add_theme_stylebox_override("pressed", hover_style) + row_btn.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK if is_current else EvoUiStyle.COLOR_INK) + row_btn.pressed.connect(hud._on_modal_jump_to_hex.bind(entry.hex_id, entry.deme_id)) + hud._event_feed_vbox.add_child(row_btn) + + shown += 1 + if shown >= 30: + break + + if shown == 0: + var empty_lbl := Label.new() + empty_lbl.text = EvoUiStyle.tr_text(lang, "Sin eventos registrados en este filtro.", "No recorded events in this filter.") + empty_lbl.add_theme_font_size_override("font_size", 10) + empty_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) + hud._event_feed_vbox.add_child(empty_lbl) + + +static func _filter_short_label(lang: String, filter_id: String) -> String: + match filter_id: + "all": + return EvoUiStyle.tr_text(lang, "Todo", "All") + "war": + return "⚔" + "diplomacy": + return "🤝" + "health": + return "☣" + "evolution": + return "🧬" + return filter_id + + +static func _filter_tooltip_label(lang: String, filter_id: String) -> String: + match filter_id: + "all": + return EvoUiStyle.tr_text(lang, "Todos los eventos", "All events") + "war": + return EvoUiStyle.tr_text(lang, "⚔ Guerra, asedio, saqueo y traición", "⚔ War, siege, raid & betrayal") + "diplomacy": + return EvoUiStyle.tr_text(lang, "🤝 Pactos, tributos, uniones y colonización", "🤝 Pacts, tribute, unions & colonization") + "health": + return EvoUiStyle.tr_text(lang, "☣ Epidemias, contagios y hambrunas", "☣ Epidemics, contagion & famine") + "evolution": + return EvoUiStyle.tr_text(lang, "🧬 Evolución, rasgos, fortificación y extinción", "🧬 Evolution, traits, fortification & extinction") + return filter_id static func show_ollama_offline_popup(hud: Control) -> void: @@ -332,20 +324,6 @@ static func show_missing_models_popup(hud: Control, missing_models: Array) -> vo hud._missing_models_modal.visible = true -static func process_toasts(toast_vbox: VBoxContainer, delta: float) -> void: - if toast_vbox == null: - return - for child in toast_vbox.get_children(): - if child.has_meta("ttl"): - var ttl: float = float(child.get_meta("ttl")) - delta - if ttl <= 0.0: - child.queue_free() - else: - child.set_meta("ttl", ttl) - if ttl < 1.0: - (child as CanvasItem).modulate.a = clampf(ttl, 0.0, 1.0) - - static func show_milestone_banner(hud: Control, title: String, summary: String, is_macro: bool = false) -> void: if hud._milestone_banner == null: return @@ -361,81 +339,3 @@ static func _clean_toast_title(raw_title: String) -> String: cleaned = cleaned.substr(prefix.length()).strip_edges() break return cleaned - - -static func push_chronicle_toast(hud: Control, entry: EvoTypes.ChronicleEntry) -> void: - if hud._toast_vbox == null: - return - - var header_text := "G%d · %s · 📍(%s)" % [ - entry.generation, - EvoUiStyle.get_event_type_label(entry.event_type, hud._lang), - entry.hex_id - ] - var body_text := _clean_toast_title(entry.title) - - if hud._toast_vbox.get_child_count() > 0: - var newest: Node = hud._toast_vbox.get_child(hud._toast_vbox.get_child_count() - 1) - if int(newest.get_meta("generation", -1)) == entry.generation and str(newest.get_meta("event_type", "")) == entry.event_type: - var top_existing := newest.get_meta("top_lbl") as Label - var title_existing := newest.get_meta("title_lbl") as Label - top_existing.text = header_text - title_existing.text = body_text - newest.set_meta("ttl", 3.8) - (newest as CanvasItem).modulate.a = 1.0 - return - - while hud._toast_vbox.get_child_count() >= 2: - var oldest: Node = hud._toast_vbox.get_child(0) - hud._toast_vbox.remove_child(oldest) - oldest.queue_free() - - var ev_color := EvoUiStyle.get_event_type_color(entry.event_type) - var toast := PanelContainer.new() - toast.mouse_filter = Control.MOUSE_FILTER_IGNORE - toast.set_meta("ttl", 3.8) - toast.set_meta("generation", entry.generation) - toast.set_meta("event_type", entry.event_type) - var style := EvoUiStyle.make_parchment_card(Color("#fbf2de"), ev_color, 5) - style.border_width_left = 4 - toast.add_theme_stylebox_override("panel", style) - - var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 2) - toast.add_child(vbox) - - var top_lbl := Label.new() - top_lbl.text = header_text - top_lbl.add_theme_font_size_override("font_size", 10) - top_lbl.add_theme_color_override("font_color", ev_color) - vbox.add_child(top_lbl) - - var title_lbl := Label.new() - title_lbl.text = body_text - title_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - title_lbl.max_lines_visible = 2 - title_lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - title_lbl.add_theme_font_size_override("font_size", 11) - title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - vbox.add_child(title_lbl) - - toast.set_meta("top_lbl", top_lbl) - toast.set_meta("title_lbl", title_lbl) - hud._toast_vbox.add_child(toast) - - -static func update_first_steps_labels(hud: Control) -> void: - if hud._step1_lbl == null: - return - var c1 := "☑" if hud._step_1_done else "☐" - var c2 := "☑" if hud._step_2_done else "☐" - var c3 := "☑" if hud._step_3_done else "☐" - - hud._step1_lbl.text = EvoUiStyle.tr_text(hud._lang, "%s 1. Seleccionar aldea" % c1, "%s 1. Select village" % c1) - hud._step2_lbl.text = EvoUiStyle.tr_text(hud._lang, "%s 2. Usar herramienta (1-0)" % c2, "%s 2. Use tool (1-0)" % c2) - hud._step3_lbl.text = EvoUiStyle.tr_text(hud._lang, "%s 3. Avanzar generación" % c3, "%s 3. Advance generation" % c3) - - hud._step1_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GOLD_TEXT if hud._step_1_done else EvoUiStyle.COLOR_CREAM_TEXT) - hud._step2_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GOLD_TEXT if hud._step_2_done else EvoUiStyle.COLOR_CREAM_TEXT) - hud._step3_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GOLD_TEXT if hud._step_3_done else EvoUiStyle.COLOR_CREAM_TEXT) - refresh_legend_texts(hud) diff --git a/src/ui/hud/hud_hotbar_dock.gd b/src/ui/hud/hud_hotbar_dock.gd index c36797a..2468a2c 100644 --- a/src/ui/hud/hud_hotbar_dock.gd +++ b/src/ui/hud/hud_hotbar_dock.gd @@ -14,17 +14,6 @@ const HOTBAR_SLOTS_DEF: Array[Dictionary] = [ {"slot": 9, "key": "0", "tool": "sea_level", "sub": "sea_level", "icon": "slot_sealevel", "cost": 25} ] -var _hud: Control = null - - -func _init(p_hud: Control = null) -> void: - _hud = p_hud - - -func refresh_legend(state: EvoTypes.WorldState, lang: String = "es") -> void: - if _hud != null: - EvoHudHotbarDock.refresh_legend_for_hud(_hud, state, lang) - static func build_bottom_hotbar(hud: Control) -> void: # 1. Floating Sub-Options Bar (appears right above the hotbar when Sea Level / extra biomes active) @@ -48,30 +37,7 @@ static func build_bottom_hotbar(hud: Control) -> void: hud._sub_options_flow.add_theme_constant_override("v_separation", 4) hud._sub_options_panel.add_child(hud._sub_options_flow) - # 2. Hidden Equipped Tool Plaque (tooltips + hotbar slot badges handle display) - hud._equipped_tool_banner = PanelContainer.new() - hud._equipped_tool_banner.visible = false - hud._equipped_tool_banner.anchor_left = 0.5 - hud._equipped_tool_banner.anchor_right = 0.5 - hud._equipped_tool_banner.anchor_top = 1.0 - hud._equipped_tool_banner.anchor_bottom = 1.0 - hud._equipped_tool_banner.offset_left = -240 - hud._equipped_tool_banner.offset_right = 240 - hud._equipped_tool_banner.offset_top = -100 - hud._equipped_tool_banner.offset_bottom = -74 - hud._equipped_tool_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE - var eq_style := EvoUiStyle.make_parchment_card(Color("#fef3c7"), EvoUiStyle.COLOR_GOLD_BRIGHT, 4) - hud._equipped_tool_banner.add_theme_stylebox_override("panel", eq_style) - hud.add_child(hud._equipped_tool_banner) - - hud._equipped_tool_lbl = Label.new() - hud._equipped_tool_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - hud._equipped_tool_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - hud._equipped_tool_lbl.add_theme_font_size_override("font_size", 12) - hud._equipped_tool_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - hud._equipped_tool_banner.add_child(hud._equipped_tool_lbl) - - # 3. 10-Slot Inventory Hotbar + Brush Radius Switcher + # 2. 10-Slot Inventory Hotbar + Brush Radius Switcher var hotbar_frame := PanelContainer.new() hotbar_frame.anchor_left = 0.5 hotbar_frame.anchor_right = 0.5 @@ -171,10 +137,10 @@ static func build_legend_panel(hud: Control) -> void: hud._legend_panel.anchor_right = 0.0 hud._legend_panel.anchor_top = 0.0 hud._legend_panel.anchor_bottom = 0.0 - hud._legend_panel.offset_left = 428 - hud._legend_panel.offset_right = 736 - hud._legend_panel.offset_top = 10 - hud._legend_panel.offset_bottom = 434 + hud._legend_panel.offset_left = 454 + hud._legend_panel.offset_right = 762 + hud._legend_panel.offset_top = 76 + hud._legend_panel.offset_bottom = 490 hud._legend_panel.z_index = 22 hud._legend_panel.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(7)) hud.add_child(hud._legend_panel) @@ -262,13 +228,6 @@ static func update_hotbar_visuals(hud: Control) -> void: btn.tooltip_text = "[%s] %s\n%s" % [str(HOTBAR_SLOTS_DEF[i]["key"]), info["title"], info["desc"]] EvoUiStyle.apply_hotbar_slot_style(btn, i == active_idx) - var cur_info := get_slot_title_and_desc(hud._lang, active_idx) - if hud._active_tool == "set_biome" and hud._active_sub_mode in ["coastal_shallows", "ocean", "high_mountain"]: - var b_lbl := EvoUiStyle.get_biome_label(hud._active_sub_mode, hud._lang) - hud._equipped_tool_lbl.text = "🐚 %s — %s (⚡10)" % [b_lbl, EvoUiStyle.tr_text(hud._lang, "Moldear bioma costero/cumbre", "Shape coastal/peak biome")] - else: - hud._equipped_tool_lbl.text = "%s — %s" % [cur_info["title"], cur_info["desc"]] - static func rebuild_sub_tool_options(hud: Control) -> void: if hud._sub_options_flow == null: @@ -379,76 +338,88 @@ static func refresh_legend_for_hud(hud: Control, state: EvoTypes.WorldState, lan hud._legend_vbox.add_child(sep) var title := Label.new() - title.text = EvoUiStyle.tr_text(lang, "🗺️ Leyenda del Valle", "🗺️ Valley Legend") title.add_theme_font_size_override("font_size", 13) title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) hud._legend_vbox.add_child(title) - for b_id in EvoTypes.BIOMES: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - hud._legend_vbox.add_child(row) - - var sw := ColorRect.new() - sw.custom_minimum_size = Vector2(12, 12) - sw.color = EvoUiStyle.get_biome_color(b_id) - row.add_child(sw) - - var lbl := Label.new() - lbl.text = EvoUiStyle.get_biome_label(b_id, lang) - lbl.add_theme_font_size_override("font_size", 11) - row.add_child(lbl) - - var tier_lbl := Label.new() - tier_lbl.text = EvoUiStyle.tr_text( - lang, - "Aldeas: I Campamento · II Caserío · III Aldea · IV Poblado · V Metrópolis\nCaminos: 🛣️ Cooperación · ⚔️ Disputa · 🧭 Migración", - "Villages: I Camp · II Hamlet · III Village · IV Settlement · V Metropolis\nPaths: 🛣️ Cooperation · ⚔️ Dispute · 🧭 Migration" - ) - tier_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - tier_lbl.add_theme_font_size_override("font_size", 10) - tier_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) - hud._legend_vbox.add_child(tier_lbl) - - var flow_sep := HSeparator.new() - hud._legend_vbox.add_child(flow_sep) - - var geo_title := Label.new() - geo_title.text = EvoUiStyle.tr_text(lang, "🗺️ Leyenda Geopolítica", "🗺️ Geopolitical Legend") - geo_title.add_theme_font_size_override("font_size", 12) - geo_title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - hud._legend_vbox.add_child(geo_title) - - var legend_defs := [ - {"key": "legend_flow_pact", "icon": "icon_pact", "color": Color("#10b981")}, - {"key": "legend_flow_merge", "icon": "icon_merge", "color": Color("#eab308")}, - {"key": "legend_flow_tribute", "icon": "icon_tribute", "color": Color("#f59e0b")}, - {"key": "legend_flow_war", "icon": "icon_war", "color": Color("#ef4444")}, - {"key": "legend_flow_siege", "icon": "icon_siege", "color": Color("#dc2626")}, - {"key": "legend_flow_raid", "icon": "icon_raid", "color": Color("#f97316")}, - {"key": "legend_flow_betrayal", "icon": "icon_betrayal", "color": Color("#be123c")} - ] - for def in legend_defs: - var f_row := HBoxContainer.new() - f_row.add_theme_constant_override("separation", 6) - hud._legend_vbox.add_child(f_row) - - var f_sw := ColorRect.new() - f_sw.custom_minimum_size = Vector2(12, 12) - f_sw.size_flags_vertical = Control.SIZE_SHRINK_CENTER - f_sw.color = def["color"] - f_row.add_child(f_sw) - - var f_icon := TextureRect.new() - f_icon.custom_minimum_size = Vector2(14, 14) - f_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - f_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - f_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - f_icon.texture = EvoUiStyle.get_pixel_icon(str(def["icon"])) - f_row.add_child(f_icon) - - var f_lbl := Label.new() - f_lbl.text = EvoI18n.t(lang, str(def["key"])) - f_lbl.add_theme_font_size_override("font_size", 11) - f_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - f_row.add_child(f_lbl) + match hud._active_lens: + "food": + title.text = EvoUiStyle.tr_text(lang, "🌾 Lente: Alimento y Capacidad K", "🌾 Lens: Food & Capacity K") + _add_legend_row(hud._legend_vbox, Color("#16a34a"), EvoUiStyle.tr_text(lang, "R/Kmax ≥ 65% · Biomasa abundante", "R/Kmax ≥ 65% · Abundant biomass"), "icon_forage") + _add_legend_row(hud._legend_vbox, Color("#eab308"), EvoUiStyle.tr_text(lang, "R/Kmax 30–64% · Alimento moderado", "R/Kmax 30–64% · Moderate food")) + _add_legend_row(hud._legend_vbox, Color("#92400e"), EvoUiStyle.tr_text(lang, "R/Kmax < 30% · Suelo agotado", "R/Kmax < 30% · Depleted resources")) + _add_legend_row(hud._legend_vbox, Color("#f59e0b"), EvoUiStyle.tr_text(lang, "Borde discontinuo · K/Kmax degradado (< 85%)", "Dashed border · Degraded K/Kmax (< 85%)")) + _add_legend_row(hud._legend_vbox, Color("#22c55e"), EvoUiStyle.tr_text(lang, "🍖 Barra Ingesta / Necesidad calórica", "🍖 Caloric Intake / Need bar"), "icon_forage") + "health": + title.text = EvoUiStyle.tr_text(lang, "✚ Lente: Salubridad y Epidemias", "✚ Lens: Salubrity & Epidemics") + _add_legend_row(hud._legend_vbox, Color("#10b981"), EvoUiStyle.tr_text(lang, "Salubridad ≥ 75% · Sana (resiste contagio)", "Salubrity ≥ 75% · Healthy (resists contagion)")) + _add_legend_row(hud._legend_vbox, Color("#f59e0b"), EvoUiStyle.tr_text(lang, "Salubridad 40–74% · Susceptible (< 70%)", "Salubrity 40–74% · Susceptible (< 70%)")) + _add_legend_row(hud._legend_vbox, Color("#ef4444"), EvoUiStyle.tr_text(lang, "Salubridad < 35% · Crítica (riesgo de brote)", "Salubrity < 35% · Critical (outbreak risk)")) + _add_legend_row(hud._legend_vbox, Color("#84cc16"), EvoUiStyle.tr_text(lang, "☣ Miasma epidémico activo + turnos", "☣ Active epidemic miasma + turns"), "icon_epidemic") + _add_legend_row(hud._legend_vbox, Color("#06b6d4"), EvoUiStyle.tr_text(lang, "🛡️ Anillo cian de inmunidad temporal", "🛡️ Cyan ring of temporary immunity"), "icon_immune") + _add_legend_row(hud._legend_vbox, Color("#a3e635"), EvoUiStyle.tr_text(lang, "➔ Flecha de contagio epidémico", "➔ Epidemic contagion arrow"), "icon_epidemic") + "diplomacy": + title.text = EvoUiStyle.tr_text(lang, "🤝 Lente: Diplomacia y Conflictos", "🤝 Lens: Diplomacy & Conflicts") + _add_legend_row(hud._legend_vbox, Color("#10b981"), EvoUiStyle.tr_text(lang, "🤝 Pacto activo (grosor = duración)", "🤝 Active pact (thickness = duration)"), "icon_pact") + _add_legend_row(hud._legend_vbox, Color("#34d399"), EvoUiStyle.tr_text(lang, "Cooperación incipiente (1 turno)", "Incipient cooperation (1 turn)"), "icon_cooperate") + _add_legend_row(hud._legend_vbox, Color("#be123c"), EvoUiStyle.tr_text(lang, "🩸 Vendetta activa (línea discontinua)", "🩸 Active vendetta (dashed line)"), "icon_vendetta") + _add_legend_row(hud._legend_vbox, Color("#ef4444"), EvoUiStyle.tr_text(lang, "⚔️ Guerra / Asedio (flecha direccional)", "⚔️ War / Siege (directional arrow)"), "icon_war") + _add_legend_row(hud._legend_vbox, Color("#f97316"), EvoUiStyle.tr_text(lang, "🔥 Incursión / Saqueo (flecha direccional)", "🔥 Raid / Pillage (directional arrow)"), "icon_raid") + _add_legend_row(hud._legend_vbox, Color("#f59e0b"), EvoUiStyle.tr_text(lang, "🪙 Tributo (quien paga ➔ quien cobra)", "🪙 Tribute (payer ➔ receiver)"), "icon_tribute") + _add_legend_row(hud._legend_vbox, Color("#eab308"), EvoUiStyle.tr_text(lang, "🧱 Unión Amurallada (enlace dorado)", "🧱 Walled Union (golden link)"), "icon_merge") + "territory": + title.text = EvoUiStyle.tr_text(lang, "🚩 Lente: Dominio Territorial A vs. B", "🚩 Lens: Territorial Control A vs. B") + var col_a := Color("#10b981") + var col_b := Color("#ef4444") + if state != null: + if state.strategies_catalog.has(state.strategy_a_id): + col_a = Color.from_string((state.strategies_catalog[state.strategy_a_id] as EvoTypes.StrategyDef).color, col_a) + if state.strategies_catalog.has(state.strategy_b_id): + col_b = Color.from_string((state.strategies_catalog[state.strategy_b_id] as EvoTypes.StrategyDef).color, col_b) + _add_legend_row(hud._legend_vbox, col_a, EvoUiStyle.tr_text(lang, "Dominio Estrategia A (urbano y rural)", "Strategy A Control (urban & rural)")) + _add_legend_row(hud._legend_vbox, col_b, EvoUiStyle.tr_text(lang, "Dominio Estrategia B (urbano y rural)", "Strategy B Control (urban & rural)")) + _add_legend_row(hud._legend_vbox, Color("#fbbf24"), EvoUiStyle.tr_text(lang, "Frontera exterior continua de cada bando", "Continuous outer border per side")) + _add_legend_row(hud._legend_vbox, Color("#94a3b8"), EvoUiStyle.tr_text(lang, "Tierra salvaje sin reclamar", "Unclaimed wild territory")) + _: + title.text = EvoUiStyle.tr_text(lang, "🌿 Lente: Bioma y Asentamientos", "🌿 Lens: Biome & Settlements") + for b_id in EvoTypes.BIOMES: + _add_legend_row(hud._legend_vbox, EvoUiStyle.get_biome_color(b_id), EvoUiStyle.get_biome_label(b_id, lang), b_id) + var tier_lbl := Label.new() + tier_lbl.text = EvoUiStyle.tr_text( + lang, + "Aldeas: I Campamento · II Aldea · III Villa · IV Metrópolis\nRural: 🌾 Cultivos · 🐑 Pastos · 🏚️ Ruinas · 🏰 Murallas (Nv.1–3)", + "Villages: I Camp · II Village · III Town · IV Metropolis\nRural: 🌾 Crops · 🐑 Pasture · 🏚️ Ruins · 🏰 Perimeter Walls (Lv.1–3)" + ) + tier_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + tier_lbl.add_theme_font_size_override("font_size", 10) + tier_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) + hud._legend_vbox.add_child(tier_lbl) + + +static func _add_legend_row(vbox: VBoxContainer, color: Color, text: String, icon_key: String = "") -> void: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + vbox.add_child(row) + + var sw := ColorRect.new() + sw.custom_minimum_size = Vector2(12, 12) + sw.size_flags_vertical = Control.SIZE_SHRINK_CENTER + sw.color = color + row.add_child(sw) + + if icon_key != "": + var icon_rect := TextureRect.new() + icon_rect.custom_minimum_size = Vector2(14, 14) + icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + icon_rect.texture = EvoUiStyle.get_pixel_icon(icon_key) + row.add_child(icon_rect) + + var lbl := Label.new() + lbl.text = text + lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + lbl.add_theme_font_size_override("font_size", 11) + lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) + row.add_child(lbl) diff --git a/src/ui/hud/hud_inspector_formatters.gd b/src/ui/hud/hud_inspector_formatters.gd new file mode 100644 index 0000000..5651da5 --- /dev/null +++ b/src/ui/hud/hud_inspector_formatters.gd @@ -0,0 +1,562 @@ +class_name EvoHudInspectorFormatters +extends RefCounted + + +static func resolve_deme_strategy_icon(state: EvoTypes.WorldState, d: EvoTypes.Deme) -> String: + var strat_id := d.strategy_id + if strat_id == "" and state.lineage_tree.has(d.lineage_id): + strat_id = (state.lineage_tree[d.lineage_id] as EvoTypes.LineageNode).strategy_id + if state.strategies_catalog.has(strat_id): + return (state.strategies_catalog[strat_id] as EvoTypes.StrategyDef).icon + return "🛡️" + + +static func format_salubrity_status(d: EvoTypes.Deme, lang: String) -> Dictionary: + var sal_pct := int(round(d.salubrity * 100.0)) + var sal_badge := EvoUiStyle.tr_text(lang, "✓ Sano", "✓ Healthy") + var sal_col := EvoUiStyle.COLOR_GREEN_OK + if d.infection_turns > 0: + sal_badge = EvoUiStyle.tr_text(lang, "☣ Infectado (%dt)" % d.infection_turns, "☣ Infected (%dt)" % d.infection_turns) + sal_col = EvoUiStyle.COLOR_RED_DANGER + elif d.immunity_turns > 0: + sal_badge = EvoUiStyle.tr_text(lang, "🛡️ Inmune (%dt)" % d.immunity_turns, "🛡️ Immune (%dt)" % d.immunity_turns) + sal_col = Color("#06b6d4") + elif d.salubrity < 0.40: + sal_col = EvoUiStyle.COLOR_RED_DANGER + elif d.salubrity < 0.75: + sal_col = EvoUiStyle.COLOR_AMBER_WARN + return { + "right_text": "%d%% · %s" % [sal_pct, sal_badge], + "pct": clampf(d.salubrity * 100.0, 0.0, 100.0), + "color": sal_col + } + + +static func format_food_intake_status(d: EvoTypes.Deme) -> Dictionary: + var food_ratio := d.last_food_intake / maxf(1.0, d.last_food_need) if d.last_food_need > 0.0 else 1.0 + var food_pct := int(round(food_ratio * 100.0)) + var food_col := EvoUiStyle.COLOR_GREEN_OK if food_ratio >= 0.95 else (EvoUiStyle.COLOR_AMBER_WARN if food_ratio >= 0.70 else EvoUiStyle.COLOR_RED_DANGER) + return { + "right_text": "%.1f / %.1f (%d%%)" % [d.last_food_intake, d.last_food_need, food_pct], + "pct": clampf(food_ratio * 100.0, 0.0, 1.0 * 100.0), + "color": food_col + } + + +static func populate_traits_sheet(hud: Control, d: EvoTypes.Deme) -> void: + var mile_title := Label.new() + mile_title.text = EvoUiStyle.tr_text(hud._lang, "⏳ Medidores de Hitos Evolutivos", "⏳ Evolutionary Milestone Meters") + mile_title.add_theme_font_size_override("font_size", 12) + mile_title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + hud._traits_sheet_vbox.add_child(mile_title) + + var max_baldwin := 0 + for b_k in d.baldwin_counters.keys(): + var cnt := int(d.baldwin_counters[b_k]) + if cnt > max_baldwin: + max_baldwin = cnt + add_milestone_bar( + hud._traits_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "Asimilación Genética (Baldwin)", "Genetic Assimilation (Baldwin)"), + "%d / 8 gen" % max_baldwin, + clampf(float(max_baldwin) / 8.0 * 100.0, 0.0, 100.0), + Color("#0d9488") + ) + + var innov_pct := clampf(d.innovation_counter * 100.0, 0.0, 100.0) + add_milestone_bar( + hud._traits_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "Innovación Cultural", "Cultural Innovation"), + "%d%%" % int(round(innov_pct)), + innov_pct, + EvoUiStyle.COLOR_PURPLE_CULTURE + ) + + add_milestone_bar( + hud._traits_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "Aislamiento Alopátrico", "Allopatric Isolation"), + "%d / 10 gen" % d.isolation_turns, + clampf(float(d.isolation_turns) / 10.0 * 100.0, 0.0, 100.0), + EvoUiStyle.COLOR_BLUE_INFO + ) + + var traits_title := Label.new() + traits_title.text = EvoUiStyle.tr_text(hud._lang, "🧬 Medallones de Rasgos y Tradiciones", "🧬 Trait & Tradition Medallions") + traits_title.add_theme_font_size_override("font_size", 12) + traits_title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + hud._traits_sheet_vbox.add_child(traits_title) + + var traits_flow := HFlowContainer.new() + traits_flow.add_theme_constant_override("h_separation", 4) + traits_flow.add_theme_constant_override("v_separation", 4) + hud._traits_sheet_vbox.add_child(traits_flow) + + for t_id in d.biological_trait_ids: + traits_flow.add_child(make_trait_tag(hud._world_state, t_id, false, hud._lang)) + for t_id in d.cultural_trait_ids: + traits_flow.add_child(make_trait_tag(hud._world_state, t_id, true, hud._lang)) + + var open_forge_btn := Button.new() + open_forge_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + open_forge_btn.text = EvoUiStyle.tr_text(hud._lang, "⚗️ Abrir Forja / Inyectar Rasgo", "⚗️ Open Forge / Inject Trait") + open_forge_btn.pressed.connect(hud.open_lab_modal) + hud._traits_sheet_vbox.add_child(open_forge_btn) + + +static func populate_hex_metrics(hud: Control, cell: EvoTypes.HexCell) -> void: + add_sheet_kv(hud._hex_sheet_vbox, EvoUiStyle.tr_text(hud._lang, "🌡️ Temperatura:", "🌡️ Temperature:"), "%+.2f" % cell.temperature) + add_sheet_kv(hud._hex_sheet_vbox, EvoUiStyle.tr_text(hud._lang, "💧 Humedad:", "💧 Humidity:"), "%d%%" % int(round(cell.humidity * 100.0))) + add_sheet_kv(hud._hex_sheet_vbox, EvoUiStyle.tr_text(hud._lang, "⛰️ Altitud:", "⛰️ Altitude:"), "%.2f" % cell.altitude) + + if cell.land_use in ["crops", "pasture"] and cell.owner_deme_id != "": + var lu_label := EvoUiStyle.tr_text(hud._lang, "Tierras de cultivo", "Crop fields") if cell.land_use == "crops" else EvoUiStyle.tr_text(hud._lang, "Pastizales", "Pasturelands") + var owner_label := cell.owner_deme_id + if hud._world_state.demes.has(cell.owner_deme_id): + var owner_deme: EvoTypes.Deme = hud._world_state.demes[cell.owner_deme_id] + owner_label = format_short_deme_label(owner_deme) + add_sheet_kv( + hud._hex_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "🚜 Uso del suelo:", "🚜 Land use:"), + "%s · %s" % [lu_label, owner_label] + ) + + if cell.has_ruins: + add_sheet_kv( + hud._hex_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "🏚️ Estado:", "🏚️ Status:"), + EvoUiStyle.tr_text(hud._lang, "Ruinas", "Ruins") + ) + + var r_ratio := clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity), 0.0, 1.0) + add_milestone_bar( + hud._hex_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "🌾 Biomasa Disponible (R)", "🌾 Available Biomass (R)"), + "%.0f / %.0f" % [cell.current_resources, cell.carrying_capacity], + r_ratio * 100.0, + EvoUiStyle.COLOR_GOLD_BEVEL + ) + + var k_ratio := clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + var h_col := EvoUiStyle.COLOR_GREEN_OK + if k_ratio < 0.60: + h_col = EvoUiStyle.COLOR_RED_DANGER + elif k_ratio < 0.85: + h_col = EvoUiStyle.COLOR_AMBER_WARN + + add_milestone_bar( + hud._hex_sheet_vbox, + EvoUiStyle.tr_text(hud._lang, "💚 Salud Ecológica (K / Kmax)", "💚 Ecological Health (K / Kmax)"), + "%.0f / %.0f (%d%%)" % [cell.carrying_capacity, cell.carrying_capacity_max, int(round(k_ratio * 100.0))], + k_ratio * 100.0, + h_col + ) + + +static func format_active_pacts_summary(world_state: EvoTypes.WorldState, lang: String, d: EvoTypes.Deme) -> String: + var allies: Array[String] = [] + for other_id in world_state.demes.keys(): + if other_id == d.id: + continue + var od: EvoTypes.Deme = world_state.demes[other_id] + if od.population > 0 and EvoTypes.is_pact_active(world_state, d.id, other_id): + allies.append("🤝 " + format_short_deme_label(od)) + if allies.is_empty(): + return EvoI18n.t(lang, "inspector_no_pacts") + return ", ".join(allies) + + +static func build_deme_relations_lines(world_state: EvoTypes.WorldState, lang: String, d: EvoTypes.Deme) -> Array[Dictionary]: + var lines: Array[Dictionary] = [] + if world_state == null: + return lines + + var mentioned_deme_ids: Dictionary = {} + var pact_list: Array[String] = [] + var pact_hex := "" + var pact_deme := "" + var vendetta_list: Array[String] = [] + var vendetta_hex := "" + var vendetta_deme := "" + for other_id in world_state.demes.keys(): + if other_id == d.id: + continue + var od_pact: EvoTypes.Deme = world_state.demes[other_id] + if od_pact.population <= 0: + continue + if EvoTypes.is_pact_active(world_state, d.id, other_id): + pact_list.append(format_short_deme_label(od_pact)) + if pact_hex == "": + pact_hex = od_pact.hex_id + pact_deme = od_pact.id + mentioned_deme_ids[other_id] = true + elif EvoTypes.is_vendetta_active(world_state, d.id, other_id): + vendetta_list.append(format_short_deme_label(od_pact)) + if vendetta_hex == "": + vendetta_hex = od_pact.hex_id + vendetta_deme = od_pact.id + mentioned_deme_ids[other_id] = true + + var war_list: Array[String] = [] + var war_hex := "" + var war_deme := "" + var siege_list: Array[String] = [] + var siege_hex := "" + var siege_deme := "" + var raid_list: Array[String] = [] + var raid_hex := "" + var raid_deme := "" + var tribute_list: Array[String] = [] + var tribute_hex := "" + var tribute_deme := "" + var betrayal_list: Array[String] = [] + var betrayal_hex := "" + var betrayal_deme := "" + var coop_full: Array[String] = [] + var coop_short: Array[String] = [] + var coop_coord: Array[String] = [] + var coop_hex := "" + var coop_deme := "" + var dispute_full: Array[String] = [] + var dispute_short: Array[String] = [] + var dispute_coord: Array[String] = [] + var dispute_hex := "" + var dispute_deme := "" + var mig_full: Array[String] = [] + var mig_short: Array[String] = [] + var mig_coord: Array[String] = [] + var mig_hex := "" + var mig_deme := "" + + for fl in world_state.active_flows: + var has_deme_endpoints := (fl.from_deme_id != "" or fl.to_deme_id != "") + var touches := (fl.from_deme_id == d.id or fl.to_deme_id == d.id) if has_deme_endpoints else (fl.from_hex_id == d.hex_id or fl.to_hex_id == d.hex_id) + if not touches: + continue + var other_hex := fl.to_hex_id if fl.from_hex_id == d.hex_id else fl.from_hex_id + var coord_lbl := "(%s)" % other_hex + var full_lbl := "Hex (%s)" % other_hex + var short_lbl := coord_lbl + var other_deme_id := fl.to_deme_id if fl.from_deme_id == d.id else fl.from_deme_id + var resolved_hex := other_hex + var resolved_deme := "" + if other_deme_id != "" and world_state.demes.has(other_deme_id): + var od_flow: EvoTypes.Deme = world_state.demes[other_deme_id] + full_lbl = format_deme_with_hex(od_flow) + short_lbl = format_short_deme_label(od_flow) + coord_lbl = "(%s)" % od_flow.hex_id + resolved_hex = od_flow.hex_id + resolved_deme = od_flow.id + mentioned_deme_ids[other_deme_id] = true + match fl.flow_type: + "war": + if not war_list.has(short_lbl): + war_list.append(short_lbl) + if war_hex == "": + war_hex = resolved_hex + war_deme = resolved_deme + "siege": + if not siege_list.has(short_lbl): + siege_list.append(short_lbl) + if siege_hex == "": + siege_hex = resolved_hex + siege_deme = resolved_deme + "raid": + if not raid_list.has(short_lbl): + raid_list.append(short_lbl) + if raid_hex == "": + raid_hex = resolved_hex + raid_deme = resolved_deme + "tribute": + if not tribute_list.has(short_lbl): + tribute_list.append(short_lbl) + if tribute_hex == "": + tribute_hex = resolved_hex + tribute_deme = resolved_deme + "betrayal": + if not betrayal_list.has(short_lbl): + betrayal_list.append(short_lbl) + if betrayal_hex == "": + betrayal_hex = resolved_hex + betrayal_deme = resolved_deme + "pact": + if not pact_list.has(short_lbl): + pact_list.append(short_lbl) + if pact_hex == "": + pact_hex = resolved_hex + pact_deme = resolved_deme + "exchange", "cooperate", "merge": + if not coop_full.has(full_lbl): + coop_full.append(full_lbl) + coop_short.append(short_lbl) + coop_coord.append(coord_lbl) + if coop_hex == "": + coop_hex = resolved_hex + coop_deme = resolved_deme + "dispute": + if not dispute_full.has(full_lbl): + dispute_full.append(full_lbl) + dispute_short.append(short_lbl) + dispute_coord.append(coord_lbl) + if dispute_hex == "": + dispute_hex = resolved_hex + dispute_deme = resolved_deme + "migration": + if not mig_full.has(coord_lbl): + mig_full.append(coord_lbl) + mig_short.append(coord_lbl) + mig_coord.append(coord_lbl) + if mig_hex == "": + mig_hex = resolved_hex + mig_deme = resolved_deme + + if not pact_list.is_empty(): + lines.append({"text": "🤝 " + EvoI18n.t(lang, "inspector_pact_with", {"partner": ", ".join(pact_list)}), "hex_id": pact_hex, "deme_id": pact_deme, "danger": false}) + if not vendetta_list.is_empty(): + lines.append({"text": "🩸 " + EvoI18n.t(lang, "inspector_vendetta_with", {"partner": ", ".join(vendetta_list)}), "hex_id": vendetta_hex, "deme_id": vendetta_deme, "danger": true}) + if not siege_list.is_empty(): + lines.append({"text": "🏰 %s: %s" % [EvoI18n.t(lang, "event_siege"), ", ".join(siege_list)], "hex_id": siege_hex, "deme_id": siege_deme, "danger": true}) + if not war_list.is_empty(): + lines.append({"text": "⚔️ %s: %s" % [EvoI18n.t(lang, "event_war"), ", ".join(war_list)], "hex_id": war_hex, "deme_id": war_deme, "danger": true}) + if not raid_list.is_empty(): + lines.append({"text": "🔥 %s: %s" % [EvoI18n.t(lang, "event_raid"), ", ".join(raid_list)], "hex_id": raid_hex, "deme_id": raid_deme, "danger": true}) + if not tribute_list.is_empty(): + lines.append({"text": "🪙 %s: %s" % [EvoI18n.t(lang, "event_tribute"), ", ".join(tribute_list)], "hex_id": tribute_hex, "deme_id": tribute_deme, "danger": false}) + if not betrayal_list.is_empty(): + lines.append({"text": "🗡️ %s: %s" % [EvoI18n.t(lang, "event_betrayal"), ", ".join(betrayal_list)], "hex_id": betrayal_hex, "deme_id": betrayal_deme, "danger": true}) + + var coop_line := format_relation_category( + lang, + "🛣️ Cooperación activa: %s", "🛣️ Active cooperation: %s", + "🛣️ Cooperación activa (%d): %s", "🛣️ Active cooperation (%d): %s", + coop_full, coop_short, coop_coord + ) + if coop_line != "": + lines.append({"text": coop_line, "hex_id": coop_hex, "deme_id": coop_deme, "danger": false}) + + var dispute_line := format_relation_category( + lang, + "⚔️ En conflicto: %s", "⚔️ In conflict: %s", + "⚔️ En conflicto (%d): %s", "⚔️ In conflict (%d): %s", + dispute_full, dispute_short, dispute_coord + ) + if dispute_line != "": + lines.append({"text": dispute_line, "hex_id": dispute_hex, "deme_id": dispute_deme, "danger": true}) + + var mig_line := format_relation_category( + lang, + "🧭 Expedición migratoria hacia %s", "🧭 Migratory expedition toward %s", + "🧭 Migración (%d): %s", "🧭 Migration (%d): %s", + mig_full, mig_short, mig_coord + ) + if mig_line != "": + lines.append({"text": mig_line, "hex_id": mig_hex, "deme_id": mig_deme, "danger": false}) + + var sev_full: Array[String] = [] + var sev_short: Array[String] = [] + var sev_coord: Array[String] = [] + var sev_hex := "" + var sev_deme := "" + for dis in world_state.disused_connections: + var f_deme := str(dis.get("from_deme_id", "")) + var t_deme := str(dis.get("to_deme_id", "")) + var f_hex := str(dis.get("from_hex_id", "")) + var t_hex := str(dis.get("to_hex_id", "")) + var has_d := (f_deme != "" or t_deme != "") + var touches_dis := (f_deme == d.id or t_deme == d.id) if has_d else (f_hex == d.hex_id or t_hex == d.hex_id) + if touches_dis: + var other_d_id := t_deme if f_deme == d.id else f_deme + var target_h := t_hex if f_hex == d.hex_id else f_hex + var coord_s := "(%s)" % target_h + var full_s := coord_s + var short_s := coord_s + var res_sev_hex := target_h + var res_sev_deme := "" + if other_d_id != "" and other_d_id != d.id and world_state.demes.has(other_d_id): + var od_dis: EvoTypes.Deme = world_state.demes[other_d_id] + full_s = format_deme_with_hex(od_dis) + short_s = format_short_deme_label(od_dis) + coord_s = "(%s)" % od_dis.hex_id + res_sev_hex = od_dis.hex_id + res_sev_deme = od_dis.id + var age_val := int(dis.get("age", 0)) + if age_val > 0: + full_s = EvoUiStyle.tr_text(lang, "%s (hace %d gen.)" % [full_s, age_val], "%s (%d gen. ago)" % [full_s, age_val]) + if not sev_full.has(full_s): + sev_full.append(full_s) + sev_short.append(short_s) + sev_coord.append(coord_s) + if sev_hex == "": + sev_hex = res_sev_hex + sev_deme = res_sev_deme + + var sev_line := format_relation_category( + lang, + "🚧 Camino cortado con %s", "🚧 Severed path with %s", + "🚧 Caminos cortados (%d): %s", "🚧 Severed paths (%d): %s", + sev_full, sev_short, sev_coord + ) + if sev_line != "": + lines.append({"text": sev_line, "hex_id": sev_hex, "deme_id": sev_deme, "danger": false}) + + if world_state.cells.has(d.hex_id): + var c: EvoTypes.HexCell = world_state.cells[d.hex_id] + var neigh_full: Array[String] = [] + var neigh_short: Array[String] = [] + var neigh_coord: Array[String] = [] + var neigh_hex := "" + var neigh_deme := "" + var n_ids := HexMath.get_neighbor_ids(c.q, c.r, world_state.cells) + for nid in n_ids: + var nc: EvoTypes.HexCell = world_state.cells[nid] + for nd_id in nc.deme_ids: + if not mentioned_deme_ids.has(nd_id) and world_state.demes.has(nd_id): + var nd: EvoTypes.Deme = world_state.demes[nd_id] + var nlbl := format_deme_with_hex(nd) + if not neigh_full.has(nlbl): + neigh_full.append(nlbl) + neigh_short.append(format_short_deme_label(nd)) + neigh_coord.append("(%s)" % nd.hex_id) + if neigh_hex == "": + neigh_hex = nd.hex_id + neigh_deme = nd.id + var neigh_line := format_relation_category( + lang, + "🏘️ Aldea vecina: %s", "🏘️ Neighbor village: %s", + "🏘️ Aldeas vecinas (%d): %s", "🏘️ Neighbor villages (%d): %s", + neigh_full, neigh_short, neigh_coord + ) + if neigh_line != "": + lines.append({"text": neigh_line, "hex_id": neigh_hex, "deme_id": neigh_deme, "danger": false}) + + if lines.is_empty(): + lines.append({ + "text": EvoUiStyle.tr_text(lang, "🏞️ Sin caminos exteriores activos (aldea aislada)", "🏞️ No active outer roads (isolated village)"), + "hex_id": "", + "deme_id": "", + "danger": false + }) + return lines + + +static func format_deme_with_hex(od: EvoTypes.Deme) -> String: + var suffix := "(%s)" % od.hex_id + if od.name.ends_with(suffix): + return od.name + return "%s %s" % [od.name, suffix] + + +static func format_short_deme_label(od: EvoTypes.Deme) -> String: + var suffix := "(%s)" % od.hex_id + var base := od.name + if base.ends_with(suffix): + base = base.substr(0, base.length() - suffix.length()).strip_edges() + if base.length() > 16: + base = base.substr(0, 15) + "…" + return "%s (%s)" % [base, od.hex_id] + + +static func format_relation_category( + lang: String, + single_es: String, + single_en: String, + multi_es: String, + multi_en: String, + full_list: Array[String], + short_list: Array[String], + coord_list: Array[String] +) -> String: + var n := full_list.size() + if n == 0: + return "" + if n == 1: + return EvoUiStyle.tr_text(lang, single_es % full_list[0], single_en % full_list[0]) + if n == 2: + var joined_two := ", ".join(short_list) + return EvoUiStyle.tr_text(lang, multi_es % [n, joined_two], multi_en % [n, joined_two]) + var shown: Array[String] = [] + for i in range(mini(4, n)): + shown.append(coord_list[i]) + if n > 4: + shown.append(EvoUiStyle.tr_text(lang, "+%d más" % (n - 4), "+%d more" % (n - 4))) + var joined_many := ", ".join(shown) + return EvoUiStyle.tr_text(lang, multi_es % [n, joined_many], multi_en % [n, joined_many]) + + +static func make_trait_tag(world_state: EvoTypes.WorldState, trait_id: String, is_cultural: bool, lang: String = "es") -> Control: + var t_name := trait_id + var tip := trait_id + if world_state != null and world_state.traits_catalog.has(trait_id): + var tr: EvoTypes.TraitDef = world_state.traits_catalog[trait_id] + t_name = EvoI18n.trait_name(lang, tr) + var t_desc := EvoI18n.trait_description(lang, tr) + var v := tr.vector + tip = "%s\n%s\nCost: %+.2f | Extr: %+.2f | Eco: %+.2f | Mob: %+.2f | Coh: %+.2f | Thr: %.2f" % [ + t_name, t_desc, + v.metabolic_cost, v.extraction_efficiency, v.ecological_impact, + v.spatial_mobility, v.social_cohesion, v.transmission_threshold + ] + var badge := PanelContainer.new() + var col := EvoUiStyle.COLOR_PURPLE_CULTURE if is_cultural else Color("#0d9488") + badge.add_theme_stylebox_override("panel", EvoUiStyle.make_badge_style(col)) + badge.tooltip_text = tip + + var lbl := Label.new() + var short_name := t_name + if short_name.length() > 30: + short_name = short_name.substr(0, 28) + "…" + lbl.text = ("🔥 " if is_cultural else "🧬 ") + short_name + lbl.add_theme_font_size_override("font_size", 10) + lbl.add_theme_color_override("font_color", Color.WHITE) + badge.add_child(lbl) + return badge + + +static func add_milestone_bar(parent: VBoxContainer, label_str: String, right_str: String, pct: float, fill_col: Color) -> void: + var vb := VBoxContainer.new() + vb.add_theme_constant_override("separation", 2) + parent.add_child(vb) + + var hb := HBoxContainer.new() + vb.add_child(hb) + + var l1 := Label.new() + l1.text = label_str + l1.size_flags_horizontal = Control.SIZE_EXPAND_FILL + l1.clip_text = true + l1.add_theme_font_size_override("font_size", 11) + hb.add_child(l1) + + var l2 := Label.new() + l2.text = right_str + l2.add_theme_font_size_override("font_size", 11) + hb.add_child(l2) + + var bar := ProgressBar.new() + bar.min_value = 0.0 + bar.max_value = 100.0 + bar.value = pct + bar.show_percentage = false + bar.custom_minimum_size = Vector2(0, 9) + EvoUiStyle.style_progress_bar(bar, fill_col) + vb.add_child(bar) + + +static func add_sheet_kv(parent: VBoxContainer, k_str: String, v_str: String) -> void: + var hb := HBoxContainer.new() + hb.add_theme_constant_override("separation", 6) + parent.add_child(hb) + + var kl := Label.new() + kl.text = k_str + kl.add_theme_font_size_override("font_size", 11) + kl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) + hb.add_child(kl) + + var vl := Label.new() + vl.text = v_str + vl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + vl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + vl.add_theme_font_size_override("font_size", 11) + vl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) + hb.add_child(vl) diff --git a/src/ui/hud/hud_inspector_formatters.gd.uid b/src/ui/hud/hud_inspector_formatters.gd.uid new file mode 100644 index 0000000..8c23f01 --- /dev/null +++ b/src/ui/hud/hud_inspector_formatters.gd.uid @@ -0,0 +1 @@ +uid://cevugqu2v7358 diff --git a/src/ui/hud/hud_inspector_panel.gd b/src/ui/hud/hud_inspector_panel.gd index 6a4be6a..c1792e9 100644 --- a/src/ui/hud/hud_inspector_panel.gd +++ b/src/ui/hud/hud_inspector_panel.gd @@ -1,19 +1,6 @@ class_name EvoHudInspectorPanel extends RefCounted -var _hud: Control = null -var deme_strategy_label: Label -var deme_selector_option: OptionButton - - -func _init(p_hud: Control = null) -> void: - _hud = p_hud - - -func _refresh_deme_selector() -> void: - if _hud != null: - EvoHudInspectorPanel.rebuild_villages_list(_hud) - static func build_right_scroll_inspector(hud: Control) -> void: hud._inspector_frame = PanelContainer.new() @@ -69,8 +56,6 @@ static func build_right_scroll_inspector(hud: Control) -> void: village_sel_row.add_child(hud._village_selector_opt) hud.deme_selector_option = hud._village_selector_opt - if hud.inspector_panel != null: - hud.inspector_panel.deme_selector_option = hud._village_selector_opt hud._village_next_btn = Button.new() hud._village_next_btn.text = "▶" @@ -103,10 +88,6 @@ static func build_right_scroll_inspector(hud: Control) -> void: hud._inspector_model_badge_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_CREAM_TEXT) model_badge_panel.add_child(hud._inspector_model_badge_lbl) - hud._inspector_header_title = Label.new() - hud._inspector_header_title.visible = false - h_top.add_child(hud._inspector_header_title) - hud._inspector_header_hl_btn = Button.new() hud._inspector_header_hl_btn.add_theme_font_size_override("font_size", 10) hud._inspector_header_hl_btn.pressed.connect(hud._on_header_highlight_pressed) @@ -125,8 +106,6 @@ static func build_right_scroll_inspector(hud: Control) -> void: hud.deme_strategy_label.add_theme_font_size_override("font_size", 11) hud.deme_strategy_label.add_theme_color_override("font_color", Color("#10B981")) hud._strategy_badge_panel.add_child(hud.deme_strategy_label) - if hud.inspector_panel != null: - hud.inspector_panel.deme_strategy_label = hud.deme_strategy_label # Collapsible Scroll Body (3 Tabs + Cream Parchment Content) hud._inspector_body_vbox = VBoxContainer.new() @@ -225,8 +204,8 @@ static func rebuild_villages_list(hud: Control) -> void: var pop_unit := "pop." if hud._lang == "en" else "hab." for i in range(total_count): var d := deme_list[i] - var strat_icon := _resolve_deme_strategy_icon(hud._world_state, d) - var item_lbl := "%s %s — %d %s" % [strat_icon, _format_deme_with_hex(d), d.population, pop_unit] + var strat_icon := EvoHudInspectorFormatters.resolve_deme_strategy_icon(hud._world_state, d) + var item_lbl := "%s %s — %d %s" % [strat_icon, EvoHudInspectorFormatters.format_deme_with_hex(d), d.population, pop_unit] hud._village_selector_opt.add_item(item_lbl, i) hud._village_selector_opt.set_item_metadata(i, d.id) if d.id == hud._selected_deme_id: @@ -235,15 +214,6 @@ static func rebuild_villages_list(hud: Control) -> void: hud._village_selector_opt.select(selected_idx) -static func _resolve_deme_strategy_icon(state: EvoTypes.WorldState, d: EvoTypes.Deme) -> String: - var strat_id := d.strategy_id - if strat_id == "" and state.lineage_tree.has(d.lineage_id): - strat_id = (state.lineage_tree[d.lineage_id] as EvoTypes.LineageNode).strategy_id - if state.strategies_catalog.has(strat_id): - return (state.strategies_catalog[strat_id] as EvoTypes.StrategyDef).icon - return "🛡️" - - static func rebuild_deme_sheet(hud: Control) -> void: if hud._deme_sheet_vbox == null or hud._traits_sheet_vbox == null: return @@ -255,7 +225,6 @@ static func rebuild_deme_sheet(hud: Control) -> void: child.queue_free() if hud._world_state == null or hud._selected_deme_id == "" or not hud._world_state.demes.has(hud._selected_deme_id): - hud._inspector_header_title.text = EvoUiStyle.tr_text(hud._lang, "Hex (%s)" % hud._selected_hex_id, "Hex (%s)" % hud._selected_hex_id) hud._inspector_model_badge_lbl.text = EvoUiStyle.tr_text(hud._lang, "🌿 Sin aldea seleccionada", "🌿 No village selected") hud._inspector_header_hl_btn.visible = false if hud._strategy_badge_panel != null: @@ -302,7 +271,6 @@ static func rebuild_deme_sheet(hud: Control) -> void: # Update Scroll Header hud._inspector_header_swatch.color = Color.from_string(d.color, Color("#E07A5F")) - hud._inspector_header_title.text = "%s (%s)" % [d.name, d.hex_id] hud._inspector_header_hl_btn.visible = true var is_hl: bool = (hud._highlighted_lineage_id == d.lineage_id and hud._highlighted_lineage_id != "") hud._inspector_header_hl_btn.text = EvoUiStyle.tr_text(hud._lang, "✨ Linaje", "✨ Lineage") @@ -316,63 +284,7 @@ static func rebuild_deme_sheet(hud: Control) -> void: ) if hud._inspector_tab == "traits": - var mile_title := Label.new() - mile_title.text = EvoUiStyle.tr_text(hud._lang, "⏳ Medidores de Hitos Evolutivos", "⏳ Evolutionary Milestone Meters") - mile_title.add_theme_font_size_override("font_size", 12) - mile_title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - hud._traits_sheet_vbox.add_child(mile_title) - - var max_baldwin := 0 - for b_k in d.baldwin_counters.keys(): - var cnt := int(d.baldwin_counters[b_k]) - if cnt > max_baldwin: - max_baldwin = cnt - _add_milestone_bar( - hud._traits_sheet_vbox, - EvoUiStyle.tr_text(hud._lang, "Asimilación Genética (Baldwin)", "Genetic Assimilation (Baldwin)"), - "%d / 8 gen" % max_baldwin, - clampf(float(max_baldwin) / 8.0 * 100.0, 0.0, 100.0), - Color("#0d9488") - ) - - var innov_pct := clampf(d.innovation_counter * 100.0, 0.0, 100.0) - _add_milestone_bar( - hud._traits_sheet_vbox, - EvoUiStyle.tr_text(hud._lang, "Innovación Cultural", "Cultural Innovation"), - "%d%%" % int(round(innov_pct)), - innov_pct, - EvoUiStyle.COLOR_PURPLE_CULTURE - ) - - _add_milestone_bar( - hud._traits_sheet_vbox, - EvoUiStyle.tr_text(hud._lang, "Aislamiento Alopátrico", "Allopatric Isolation"), - "%d / 10 gen" % d.isolation_turns, - clampf(float(d.isolation_turns) / 10.0 * 100.0, 0.0, 100.0), - EvoUiStyle.COLOR_BLUE_INFO - ) - - var traits_title := Label.new() - traits_title.text = EvoUiStyle.tr_text(hud._lang, "🧬 Medallones de Rasgos y Tradiciones", "🧬 Trait & Tradition Medallions") - traits_title.add_theme_font_size_override("font_size", 12) - traits_title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - hud._traits_sheet_vbox.add_child(traits_title) - - var traits_flow := HFlowContainer.new() - traits_flow.add_theme_constant_override("h_separation", 4) - traits_flow.add_theme_constant_override("v_separation", 4) - hud._traits_sheet_vbox.add_child(traits_flow) - - for t_id in d.biological_trait_ids: - traits_flow.add_child(_make_trait_tag(hud._world_state, t_id, false, hud._lang)) - for t_id in d.cultural_trait_ids: - traits_flow.add_child(_make_trait_tag(hud._world_state, t_id, true, hud._lang)) - - var open_forge_btn := Button.new() - open_forge_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - open_forge_btn.text = EvoUiStyle.tr_text(hud._lang, "⚗️ Abrir Forja / Inyectar Rasgo", "⚗️ Open Forge / Inject Trait") - open_forge_btn.pressed.connect(hud.open_lab_modal) - hud._traits_sheet_vbox.add_child(open_forge_btn) + EvoHudInspectorFormatters.populate_traits_sheet(hud, d) return # TAB 1: 🧠 ALDEA Y DECISIONES @@ -384,15 +296,17 @@ static func rebuild_deme_sheet(hud: Control) -> void: m_vbox.add_theme_constant_override("separation", 4) meters_card.add_child(m_vbox) - _add_sheet_kv(m_vbox, EvoUiStyle.tr_text(hud._lang, "🛡️ Linaje:", "🛡️ Lineage:"), lin_name) - var pop_fmt := "%d pop. (%s)" if hud._lang == "en" else "%d hab. (%s)" - _add_sheet_kv( + EvoHudInspectorFormatters.add_sheet_kv(m_vbox, EvoUiStyle.tr_text(hud._lang, "🛡️ Linaje:", "🛡️ Lineage:"), lin_name) + var pop_unit := "pop." if hud._lang == "en" else "hab." + var pop_delta_str := " (%+d)" % d.last_population_delta if d.last_population_delta != 0 else "" + var tier_lbl := EvoUiStyle.get_village_tier_label(d.population, hud._lang, d.merged_count, d.defense_tier, d.walled_union_id) + EvoHudInspectorFormatters.add_sheet_kv( m_vbox, EvoUiStyle.tr_text(hud._lang, "👥 Población:", "👥 Population:"), - pop_fmt % [d.population, EvoUiStyle.get_village_tier_label(d.population, hud._lang, d.merged_count)] + "%d %s%s · %s" % [d.population, pop_unit, pop_delta_str, tier_lbl] ) - _add_milestone_bar( + EvoHudInspectorFormatters.add_milestone_bar( m_vbox, EvoUiStyle.tr_text(hud._lang, "🍯 Reserva Calórica", "🍯 Caloric Reserve"), "%.1f" % d.energy_reserves, @@ -400,62 +314,102 @@ static func rebuild_deme_sheet(hud: Control) -> void: EvoUiStyle.COLOR_GOLD_BRIGHT ) + var food_info := EvoHudInspectorFormatters.format_food_intake_status(d) + EvoHudInspectorFormatters.add_milestone_bar( + m_vbox, + EvoUiStyle.tr_text(hud._lang, "🍖 Ingesta / Necesidad", "🍖 Intake / Need"), + str(food_info["right_text"]), + float(food_info["pct"]), + food_info["color"] as Color + ) + var v_status := d.get_vital_status() var heart_icon := EvoUiStyle.get_vital_status_heart(v_status) var v_label := EvoUiStyle.get_vital_status_label(v_status, hud._lang) - _add_sheet_kv( + EvoHudInspectorFormatters.add_sheet_kv( m_vbox, EvoUiStyle.tr_text(hud._lang, "⚡ Balance Turno:", "⚡ Turn Balance:"), "%+.1f · %s %s" % [d.last_energy_delta, heart_icon, v_label] ) - _add_sheet_kv( + + var sal_info := EvoHudInspectorFormatters.format_salubrity_status(d, hud._lang) + EvoHudInspectorFormatters.add_milestone_bar( + m_vbox, + EvoUiStyle.tr_text(hud._lang, "🏥 Salubridad", "🏥 Salubrity"), + str(sal_info["right_text"]), + float(sal_info["pct"]), + sal_info["color"] as Color + ) + EvoHudInspectorFormatters.add_sheet_kv( m_vbox, "🏰 " + EvoI18n.t(hud._lang, "inspector_defense_label") + ":", EvoI18n.t(hud._lang, "inspector_defense_%d" % clampi(d.defense_tier, 0, 3)) ) if d.merged_count > 0: - _add_sheet_kv( + EvoHudInspectorFormatters.add_sheet_kv( m_vbox, "🏛️ " + EvoUiStyle.tr_text(hud._lang, "Unificación:", "Unification:"), EvoI18n.t(hud._lang, "inspector_merged_city", {"count": str(d.merged_count + 1)}) ) - _add_sheet_kv( + if d.walled_union_id != "": + EvoHudInspectorFormatters.add_sheet_kv( + m_vbox, + "🧱 " + EvoUiStyle.tr_text(hud._lang, "Unión Amurallada:", "Walled Union:"), + EvoUiStyle.tr_text(hud._lang, "Activa (%s)" % d.walled_union_id, "Active (%s)" % d.walled_union_id) + ) + EvoHudInspectorFormatters.add_sheet_kv( m_vbox, "🤝 " + EvoI18n.t(hud._lang, "inspector_pacts") + ":", format_active_pacts_summary(hud._world_state, hud._lang, d) ) + EvoHudInspectorFormatters.add_sheet_kv( + m_vbox, + EvoUiStyle.tr_text(hud._lang, "⚖️ Reputación:", "⚖️ Reputation:"), + "%+.2f (%s)" % [d.reputation, EvoRelations.reputation_word(d.reputation)] + ) - # Decision Model Intention Section (Top 3 probabilities, dominant marked with ★) + # Action Policy Section (Top 3 action options, chosen action marked with ★) var dec_title := Label.new() - dec_title.text = EvoUiStyle.tr_text( - hud._lang, - "🧠 Intención Dominante:", - "🧠 Dominant Intention:" - ) + dec_title.text = EvoUiStyle.tr_text(hud._lang, "🧠 Acción Elegida:", "🧠 Chosen Action:") dec_title.add_theme_font_size_override("font_size", 12) dec_title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) hud._deme_sheet_vbox.add_child(dec_title) - var prob_keys := d.last_decision_probabilities.keys() + var chosen_key := str(d.chosen_action["key"]) + var prob_keys := d.action_probabilities.keys() prob_keys.sort_custom(func(a: Variant, b: Variant) -> bool: - return float(d.last_decision_probabilities[a]) > float(d.last_decision_probabilities[b]) + var ka := str(a) + var kb := str(b) + if ka == chosen_key and kb != chosen_key: + return true + if kb == chosen_key and ka != chosen_key: + return false + return float(d.action_probabilities[a]) > float(d.action_probabilities[b]) ) for i in range(mini(3, prob_keys.size())): - var dec_id := str(prob_keys[i]) - var prob: float = float(d.last_decision_probabilities[dec_id]) - var dec_lbl_str := dec_id - if hud._world_state.decisions_catalog.has(dec_id): - dec_lbl_str = EvoI18n.decision_name(hud._lang, hud._world_state.decisions_catalog[dec_id]) - var is_dom := (dec_id == d.last_dominant_decision_id or (d.last_dominant_decision_id == "" and i == 0)) - if is_dom: - dec_lbl_str = "★ " + dec_lbl_str - - _add_milestone_bar( + var act_key := str(prob_keys[i]) + var prob := float(d.action_probabilities[act_key]) + var parsed := EvoActionMenu.parse_key(act_key) + var act_type := str(parsed["type"]) + var act_target := str(parsed["target"]) + var act_lbl_str := act_type + if hud._world_state.decisions_catalog.has(act_type): + act_lbl_str = EvoI18n.decision_name(hud._lang, hud._world_state.decisions_catalog[act_type]) + if act_target != "": + if hud._world_state.demes.has(act_target): + act_lbl_str += " → " + EvoHudInspectorFormatters.format_short_deme_label(hud._world_state.demes[act_target]) + else: + act_lbl_str += " → (%s)" % act_target + var is_chosen := (act_key == chosen_key) + if is_chosen: + act_lbl_str = "★ " + act_lbl_str + + EvoHudInspectorFormatters.add_milestone_bar( hud._deme_sheet_vbox, - dec_lbl_str, + act_lbl_str, "%d%%" % int(round(prob * 100.0)), prob * 100.0, - EvoUiStyle.COLOR_GOLD_BEVEL if is_dom else EvoUiStyle.COLOR_SEPIA + EvoUiStyle.COLOR_GOLD_BEVEL if is_chosen else EvoUiStyle.COLOR_SEPIA ) # Village Relations & Roads Summary @@ -474,22 +428,39 @@ static func rebuild_deme_sheet(hud: Control) -> void: rel_vbox.add_child(rel_title) var rel_lines := build_deme_relations_lines(hud._world_state, hud._lang, d) - for r_line in rel_lines: - var rl := Label.new() - rl.text = r_line - rl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - rl.add_theme_font_size_override("font_size", 11) - rl.add_theme_color_override("font_color", EvoUiStyle.COLOR_RED_DANGER if r_line.begins_with("🩸") else EvoUiStyle.COLOR_INK) - rel_vbox.add_child(rl) - - # Naturalist's Note (`last_justification`) + for item in rel_lines: + var r_btn := Button.new() + r_btn.flat = true + r_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + r_btn.text = str(item["text"]) + r_btn.clip_text = true + r_btn.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + r_btn.add_theme_font_size_override("font_size", 11) + var txt_col := EvoUiStyle.COLOR_RED_DANGER if bool(item["danger"]) else EvoUiStyle.COLOR_INK + r_btn.add_theme_color_override("font_color", txt_col) + r_btn.add_theme_color_override("font_hover_color", EvoUiStyle.COLOR_WOOD_DARK) + var target_hex := str(item["hex_id"]) + var target_deme := str(item["deme_id"]) + if target_hex != "": + r_btn.pressed.connect(hud._on_modal_jump_to_hex.bind(target_hex, target_deme)) + rel_vbox.add_child(r_btn) + + # Outcome, Forecast & Memory Card var just_card := PanelContainer.new() just_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_PARCHMENT_BORDER, 6)) hud._deme_sheet_vbox.add_child(just_card) var just_lbl := Label.new() - var just_txt := d.last_justification if d.last_justification != "" else EvoUiStyle.tr_text(hud._lang, "Evaluación inicial de hábitat.", "Initial habitat evaluation.") - just_lbl.text = "💬 " + just_txt + var note_parts: Array[String] = [] + if d.action_forecasts.has(chosen_key): + note_parts.append("🎯 " + str(d.action_forecasts[chosen_key])) + if d.last_outcome != "": + note_parts.append("📜 " + d.last_outcome) + if not d.memory.is_empty(): + var recent_mem := d.memory.slice(maxi(0, d.memory.size() - 3), d.memory.size()) + note_parts.append("🧠 " + " | ".join(recent_mem)) + var just_txt := "\n".join(note_parts) if not note_parts.is_empty() else EvoUiStyle.tr_text(hud._lang, "Evaluación inicial de hábitat.", "Initial habitat evaluation.") + just_lbl.text = just_txt just_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART just_lbl.add_theme_font_size_override("font_size", 10) just_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) @@ -508,7 +479,6 @@ static func rebuild_hex_sheet(hud: Control) -> void: var cell: EvoTypes.HexCell = hud._world_state.cells[hud._selected_hex_id] if hud._selected_deme_id == "" or not hud._world_state.demes.has(hud._selected_deme_id): hud._inspector_header_swatch.color = EvoUiStyle.get_biome_color(cell.biome) - hud._inspector_header_title.text = "%s (%d, %d)" % [EvoUiStyle.get_biome_label(cell.biome, hud._lang), cell.q, cell.r] hud._inspector_model_badge_lbl.text = EvoUiStyle.tr_text(hud._lang, "🌿 Parcela sin aldeas · 📍 (%s)" % hud._selected_hex_id, "🌿 Uninhabited parcel · 📍 (%s)" % hud._selected_hex_id) hud._inspector_header_hl_btn.visible = false if hud._strategy_badge_panel != null: @@ -533,384 +503,12 @@ static func rebuild_hex_sheet(hud: Control) -> void: title.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) bio_row.add_child(title) - _add_sheet_kv(hud._hex_sheet_vbox, EvoUiStyle.tr_text(hud._lang, "🌡️ Temperatura:", "🌡️ Temperature:"), "%+.2f" % cell.temperature) - _add_sheet_kv(hud._hex_sheet_vbox, EvoUiStyle.tr_text(hud._lang, "💧 Humedad:", "💧 Humidity:"), "%d%%" % int(round(cell.humidity * 100.0))) - _add_sheet_kv(hud._hex_sheet_vbox, EvoUiStyle.tr_text(hud._lang, "⛰️ Altitud:", "⛰️ Altitude:"), "%.2f" % cell.altitude) - - var r_ratio := clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity), 0.0, 1.0) - _add_milestone_bar( - hud._hex_sheet_vbox, - EvoUiStyle.tr_text(hud._lang, "🌾 Biomasa Disponible (R)", "🌾 Available Biomass (R)"), - "%.0f / %.0f" % [cell.current_resources, cell.carrying_capacity], - r_ratio * 100.0, - EvoUiStyle.COLOR_GOLD_BEVEL - ) - - var k_ratio := clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) - var h_col := EvoUiStyle.COLOR_GREEN_OK - if k_ratio < 0.60: - h_col = EvoUiStyle.COLOR_RED_DANGER - elif k_ratio < 0.85: - h_col = EvoUiStyle.COLOR_AMBER_WARN - - _add_milestone_bar( - hud._hex_sheet_vbox, - EvoUiStyle.tr_text(hud._lang, "💚 Salud Ecológica (K / Kmax)", "💚 Ecological Health (K / Kmax)"), - "%.0f / %.0f (%d%%)" % [cell.carrying_capacity, cell.carrying_capacity_max, int(round(k_ratio * 100.0))], - k_ratio * 100.0, - h_col - ) + EvoHudInspectorFormatters.populate_hex_metrics(hud, cell) static func format_active_pacts_summary(world_state: EvoTypes.WorldState, lang: String, d: EvoTypes.Deme) -> String: - var allies: Array[String] = [] - for other_id in world_state.demes.keys(): - if other_id == d.id: - continue - var od: EvoTypes.Deme = world_state.demes[other_id] - if od.population > 0 and EvoTypes.is_pact_active(world_state, d.id, other_id): - allies.append("🤝 " + _format_short_deme_label(od)) - if allies.is_empty(): - return EvoI18n.t(lang, "inspector_no_pacts") - return ", ".join(allies) - - -static func build_deme_relations_lines(world_state: EvoTypes.WorldState, lang: String, d: EvoTypes.Deme) -> Array[String]: - var lines: Array[String] = [] - if world_state == null: - return lines - - var mentioned_deme_ids: Dictionary = {} - var pact_list: Array[String] = [] - var vendetta_list: Array[String] = [] - for other_id in world_state.demes.keys(): - if other_id == d.id: - continue - var od_pact: EvoTypes.Deme = world_state.demes[other_id] - if od_pact.population <= 0: - continue - if EvoTypes.is_pact_active(world_state, d.id, other_id): - pact_list.append(_format_short_deme_label(od_pact)) - mentioned_deme_ids[other_id] = true - elif EvoTypes.is_vendetta_active(world_state, d.id, other_id): - vendetta_list.append(_format_short_deme_label(od_pact)) - mentioned_deme_ids[other_id] = true - - var war_list: Array[String] = [] - var siege_list: Array[String] = [] - var raid_list: Array[String] = [] - var tribute_list: Array[String] = [] - var betrayal_list: Array[String] = [] - var coop_full: Array[String] = [] - var coop_short: Array[String] = [] - var coop_coord: Array[String] = [] - var dispute_full: Array[String] = [] - var dispute_short: Array[String] = [] - var dispute_coord: Array[String] = [] - var mig_full: Array[String] = [] - var mig_short: Array[String] = [] - var mig_coord: Array[String] = [] - - for fl in world_state.active_flows: - var has_deme_endpoints := (fl.from_deme_id != "" or fl.to_deme_id != "") - var touches := (fl.from_deme_id == d.id or fl.to_deme_id == d.id) if has_deme_endpoints else (fl.from_hex_id == d.hex_id or fl.to_hex_id == d.hex_id) - if not touches: - continue - var other_hex := fl.to_hex_id if fl.from_hex_id == d.hex_id else fl.from_hex_id - var coord_lbl := "(%s)" % other_hex - var full_lbl := "Hex (%s)" % other_hex - var short_lbl := coord_lbl - var other_deme_id := fl.to_deme_id if fl.from_deme_id == d.id else fl.from_deme_id - if other_deme_id != "" and world_state.demes.has(other_deme_id): - var od_flow: EvoTypes.Deme = world_state.demes[other_deme_id] - full_lbl = _format_deme_with_hex(od_flow) - short_lbl = _format_short_deme_label(od_flow) - coord_lbl = "(%s)" % od_flow.hex_id - mentioned_deme_ids[other_deme_id] = true - match fl.flow_type: - "war": - if not war_list.has(short_lbl): - war_list.append(short_lbl) - "siege": - if not siege_list.has(short_lbl): - siege_list.append(short_lbl) - "raid": - if not raid_list.has(short_lbl): - raid_list.append(short_lbl) - "tribute": - if not tribute_list.has(short_lbl): - tribute_list.append(short_lbl) - "betrayal": - if not betrayal_list.has(short_lbl): - betrayal_list.append(short_lbl) - "pact": - if not pact_list.has(short_lbl): - pact_list.append(short_lbl) - "exchange", "cooperate", "merge": - if not coop_full.has(full_lbl): - coop_full.append(full_lbl) - coop_short.append(short_lbl) - coop_coord.append(coord_lbl) - "dispute": - if not dispute_full.has(full_lbl): - dispute_full.append(full_lbl) - dispute_short.append(short_lbl) - dispute_coord.append(coord_lbl) - "migration": - if not mig_full.has(coord_lbl): - mig_full.append(coord_lbl) - mig_short.append(coord_lbl) - mig_coord.append(coord_lbl) - - if not pact_list.is_empty(): - lines.append("🤝 " + EvoI18n.t(lang, "inspector_pact_with", {"partner": ", ".join(pact_list)})) - if not vendetta_list.is_empty(): - lines.append("🩸 " + EvoI18n.t(lang, "inspector_vendetta_with", {"partner": ", ".join(vendetta_list)})) - if not siege_list.is_empty(): - lines.append("🏰 %s: %s" % [EvoI18n.t(lang, "event_siege"), ", ".join(siege_list)]) - if not war_list.is_empty(): - lines.append("⚔️ %s: %s" % [EvoI18n.t(lang, "event_war"), ", ".join(war_list)]) - if not raid_list.is_empty(): - lines.append("🔥 %s: %s" % [EvoI18n.t(lang, "event_raid"), ", ".join(raid_list)]) - if not tribute_list.is_empty(): - lines.append("🪙 %s: %s" % [EvoI18n.t(lang, "event_tribute"), ", ".join(tribute_list)]) - if not betrayal_list.is_empty(): - lines.append("🗡️ %s: %s" % [EvoI18n.t(lang, "event_betrayal"), ", ".join(betrayal_list)]) - - var coop_line := _format_relation_category( - lang, - "🛣️ Cooperación activa: %s", "🛣️ Active cooperation: %s", - "🛣️ Cooperación activa (%d): %s", "🛣️ Active cooperation (%d): %s", - coop_full, coop_short, coop_coord - ) - if coop_line != "": - lines.append(coop_line) - - var dispute_line := _format_relation_category( - lang, - "⚔️ En conflicto: %s", "⚔️ In conflict: %s", - "⚔️ En conflicto (%d): %s", "⚔️ In conflict (%d): %s", - dispute_full, dispute_short, dispute_coord - ) - if dispute_line != "": - lines.append(dispute_line) - - var mig_line := _format_relation_category( - lang, - "🧭 Expedición migratoria hacia %s", "🧭 Migratory expedition toward %s", - "🧭 Migración (%d): %s", "🧭 Migration (%d): %s", - mig_full, mig_short, mig_coord - ) - if mig_line != "": - lines.append(mig_line) - - var sev_full: Array[String] = [] - var sev_short: Array[String] = [] - var sev_coord: Array[String] = [] - for dis in world_state.disused_connections: - var f_deme := str(dis.get("from_deme_id", "")) - var t_deme := str(dis.get("to_deme_id", "")) - var f_hex := str(dis.get("from_hex_id", "")) - var t_hex := str(dis.get("to_hex_id", "")) - var has_d := (f_deme != "" or t_deme != "") - var touches_dis := (f_deme == d.id or t_deme == d.id) if has_d else (f_hex == d.hex_id or t_hex == d.hex_id) - if touches_dis: - var other_d_id := t_deme if f_deme == d.id else f_deme - var target_h := t_hex if f_hex == d.hex_id else f_hex - var coord_s := "(%s)" % target_h - var full_s := coord_s - var short_s := coord_s - if other_d_id != "" and other_d_id != d.id and world_state.demes.has(other_d_id): - var od_dis: EvoTypes.Deme = world_state.demes[other_d_id] - full_s = _format_deme_with_hex(od_dis) - short_s = _format_short_deme_label(od_dis) - coord_s = "(%s)" % od_dis.hex_id - var age_val := int(dis.get("age", 0)) - if age_val > 0: - full_s = EvoUiStyle.tr_text(lang, "%s (hace %d gen.)" % [full_s, age_val], "%s (%d gen. ago)" % [full_s, age_val]) - if not sev_full.has(full_s): - sev_full.append(full_s) - sev_short.append(short_s) - sev_coord.append(coord_s) - - var sev_line := _format_relation_category( - lang, - "🚧 Camino cortado con %s", "🚧 Severed path with %s", - "🚧 Caminos cortados (%d): %s", "🚧 Severed paths (%d): %s", - sev_full, sev_short, sev_coord - ) - if sev_line != "": - lines.append(sev_line) - - if world_state.cells.has(d.hex_id): - var c: EvoTypes.HexCell = world_state.cells[d.hex_id] - var coh_full: Array[String] = [] - var coh_short: Array[String] = [] - var coh_coord: Array[String] = [] - for other_id in c.deme_ids: - if other_id != d.id and not mentioned_deme_ids.has(other_id) and world_state.demes.has(other_id): - var od: EvoTypes.Deme = world_state.demes[other_id] - var flbl := _format_deme_with_hex(od) - if not coh_full.has(flbl): - coh_full.append(flbl) - coh_short.append(_format_short_deme_label(od)) - coh_coord.append("(%s)" % od.hex_id) - var coh_line := _format_relation_category( - lang, - "🤝 Coexistiendo con %s", "🤝 Coexisting with %s", - "🤝 Coexistiendo (%d): %s", "🤝 Coexisting (%d): %s", - coh_full, coh_short, coh_coord - ) - if coh_line != "": - lines.append(coh_line) - - var neigh_full: Array[String] = [] - var neigh_short: Array[String] = [] - var neigh_coord: Array[String] = [] - var n_ids := HexMath.get_neighbor_ids(c.q, c.r, world_state.cells) - for nid in n_ids: - var nc: EvoTypes.HexCell = world_state.cells[nid] - for nd_id in nc.deme_ids: - if not mentioned_deme_ids.has(nd_id) and world_state.demes.has(nd_id): - var nd: EvoTypes.Deme = world_state.demes[nd_id] - var nlbl := _format_deme_with_hex(nd) - if not neigh_full.has(nlbl): - neigh_full.append(nlbl) - neigh_short.append(_format_short_deme_label(nd)) - neigh_coord.append("(%s)" % nd.hex_id) - var neigh_line := _format_relation_category( - lang, - "🏘️ Aldea vecina: %s", "🏘️ Neighbor village: %s", - "🏘️ Aldeas vecinas (%d): %s", "🏘️ Neighbor villages (%d): %s", - neigh_full, neigh_short, neigh_coord - ) - if neigh_line != "": - lines.append(neigh_line) - - if lines.is_empty(): - lines.append(EvoUiStyle.tr_text(lang, "🏞️ Sin caminos exteriores activos (aldea aislada)", "🏞️ No active outer roads (isolated village)")) - return lines - - -static func _format_deme_with_hex(od: EvoTypes.Deme) -> String: - var suffix := "(%s)" % od.hex_id - if od.name.ends_with(suffix): - return od.name - return "%s %s" % [od.name, suffix] - - -static func _format_short_deme_label(od: EvoTypes.Deme) -> String: - var suffix := "(%s)" % od.hex_id - var base := od.name - if base.ends_with(suffix): - base = base.substr(0, base.length() - suffix.length()).strip_edges() - if base.length() > 16: - base = base.substr(0, 15) + "…" - return "%s (%s)" % [base, od.hex_id] - - -static func _format_relation_category( - lang: String, - single_es: String, - single_en: String, - multi_es: String, - multi_en: String, - full_list: Array[String], - short_list: Array[String], - coord_list: Array[String] -) -> String: - var n := full_list.size() - if n == 0: - return "" - if n == 1: - return EvoUiStyle.tr_text(lang, single_es % full_list[0], single_en % full_list[0]) - if n == 2: - var joined_two := ", ".join(short_list) - return EvoUiStyle.tr_text(lang, multi_es % [n, joined_two], multi_en % [n, joined_two]) - var shown: Array[String] = [] - for i in range(mini(4, n)): - shown.append(coord_list[i]) - if n > 4: - shown.append(EvoUiStyle.tr_text(lang, "+%d más" % (n - 4), "+%d more" % (n - 4))) - var joined_many := ", ".join(shown) - return EvoUiStyle.tr_text(lang, multi_es % [n, joined_many], multi_en % [n, joined_many]) - - -static func _make_trait_tag(world_state: EvoTypes.WorldState, trait_id: String, is_cultural: bool, lang: String = "es") -> Control: - var t_name := trait_id - var tip := trait_id - if world_state != null and world_state.traits_catalog.has(trait_id): - var tr: EvoTypes.TraitDef = world_state.traits_catalog[trait_id] - t_name = EvoI18n.trait_name(lang, tr) - var t_desc := EvoI18n.trait_description(lang, tr) - var v := tr.vector - tip = "%s\n%s\nCost: %+.2f | Extr: %+.2f | Eco: %+.2f | Mob: %+.2f | Coh: %+.2f | Thr: %.2f" % [ - t_name, t_desc, - v.metabolic_cost, v.extraction_efficiency, v.ecological_impact, - v.spatial_mobility, v.social_cohesion, v.transmission_threshold - ] - var badge := PanelContainer.new() - var col := EvoUiStyle.COLOR_PURPLE_CULTURE if is_cultural else Color("#0d9488") - badge.add_theme_stylebox_override("panel", EvoUiStyle.make_badge_style(col)) - badge.tooltip_text = tip - - var lbl := Label.new() - var short_name := t_name - if short_name.length() > 30: - short_name = short_name.substr(0, 28) + "…" - lbl.text = ("🔥 " if is_cultural else "🧬 ") + short_name - lbl.add_theme_font_size_override("font_size", 10) - lbl.add_theme_color_override("font_color", Color.WHITE) - badge.add_child(lbl) - return badge - - -static func _add_milestone_bar(parent: VBoxContainer, label_str: String, right_str: String, pct: float, fill_col: Color) -> void: - var vb := VBoxContainer.new() - vb.add_theme_constant_override("separation", 2) - parent.add_child(vb) - - var hb := HBoxContainer.new() - vb.add_child(hb) - - var l1 := Label.new() - l1.text = label_str - l1.size_flags_horizontal = Control.SIZE_EXPAND_FILL - l1.clip_text = true - l1.add_theme_font_size_override("font_size", 11) - hb.add_child(l1) - - var l2 := Label.new() - l2.text = right_str - l2.add_theme_font_size_override("font_size", 11) - hb.add_child(l2) - - var bar := ProgressBar.new() - bar.min_value = 0.0 - bar.max_value = 100.0 - bar.value = pct - bar.show_percentage = false - bar.custom_minimum_size = Vector2(0, 9) - EvoUiStyle.style_progress_bar(bar, fill_col) - vb.add_child(bar) - - -static func _add_sheet_kv(parent: VBoxContainer, k_str: String, v_str: String) -> void: - var hb := HBoxContainer.new() - hb.add_theme_constant_override("separation", 6) - parent.add_child(hb) - - var kl := Label.new() - kl.text = k_str - kl.add_theme_font_size_override("font_size", 11) - kl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) - hb.add_child(kl) - - var vl := Label.new() - vl.text = v_str - vl.size_flags_horizontal = Control.SIZE_EXPAND_FILL - vl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - vl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - vl.add_theme_font_size_override("font_size", 11) - vl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - hb.add_child(vl) + return EvoHudInspectorFormatters.format_active_pacts_summary(world_state, lang, d) + + +static func build_deme_relations_lines(world_state: EvoTypes.WorldState, lang: String, d: EvoTypes.Deme) -> Array[Dictionary]: + return EvoHudInspectorFormatters.build_deme_relations_lines(world_state, lang, d) diff --git a/src/ui/hud/hud_settings_modals.gd b/src/ui/hud/hud_settings_modals.gd new file mode 100644 index 0000000..c2cebb9 --- /dev/null +++ b/src/ui/hud/hud_settings_modals.gd @@ -0,0 +1,344 @@ +class_name EvoHudSettingsModals +extends RefCounted + + +static func build_settings_popup(hud: Control) -> void: + hud._settings_popup = PanelContainer.new() + hud._settings_popup.visible = false + hud._settings_popup.anchor_left = 1.0 + hud._settings_popup.anchor_right = 1.0 + hud._settings_popup.anchor_top = 0.0 + hud._settings_popup.anchor_bottom = 0.0 + hud._settings_popup.offset_left = -736 + hud._settings_popup.offset_right = -364 + hud._settings_popup.offset_top = 10 + hud._settings_popup.offset_bottom = 450 + hud._settings_popup.z_index = 30 + hud._settings_popup.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(8)) + hud.add_child(hud._settings_popup) + + var inner := PanelContainer.new() + inner.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(8, false)) + hud._settings_popup.add_child(inner) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 6) + inner.add_child(vbox) + + # System 1 Ollama Models Diagnostic & Selection Card + var diag_card := PanelContainer.new() + diag_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_GOLD_BEVEL, 6)) + vbox.add_child(diag_card) + + var diag_vbox := VBoxContainer.new() + diag_vbox.add_theme_constant_override("separation", 5) + diag_card.add_child(diag_vbox) + + hud._models_diag_lbl = Label.new() + hud._models_diag_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + hud._models_diag_lbl.add_theme_font_size_override("font_size", 11) + hud._models_diag_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + diag_vbox.add_child(hud._models_diag_lbl) + + # Primary Decision Model Row + var p_row := HBoxContainer.new() + p_row.add_theme_constant_override("separation", 6) + diag_vbox.add_child(p_row) + + hud._primary_model_lbl = Label.new() + hud._primary_model_lbl.custom_minimum_size = Vector2(140, 0) + hud._primary_model_lbl.add_theme_font_size_override("font_size", 11) + hud._primary_model_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + p_row.add_child(hud._primary_model_lbl) + + hud._primary_model_opt = OptionButton.new() + hud._primary_model_opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hud._primary_model_opt.fit_to_longest_item = false + hud._primary_model_opt.clip_text = true + hud._primary_model_opt.add_theme_font_size_override("font_size", 11) + hud._primary_model_opt.item_selected.connect(hud._on_primary_model_selected) + p_row.add_child(hud._primary_model_opt) + + # Secondary Decision Model Row + var s_row := HBoxContainer.new() + s_row.add_theme_constant_override("separation", 6) + diag_vbox.add_child(s_row) + + hud._secondary_model_lbl = Label.new() + hud._secondary_model_lbl.custom_minimum_size = Vector2(140, 0) + hud._secondary_model_lbl.add_theme_font_size_override("font_size", 11) + hud._secondary_model_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + s_row.add_child(hud._secondary_model_lbl) + + hud._secondary_model_opt = OptionButton.new() + hud._secondary_model_opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hud._secondary_model_opt.fit_to_longest_item = false + hud._secondary_model_opt.clip_text = true + hud._secondary_model_opt.add_theme_font_size_override("font_size", 11) + hud._secondary_model_opt.item_selected.connect(hud._on_secondary_model_selected) + s_row.add_child(hud._secondary_model_opt) + + # Context Window Size (Free Input Field) + var ctx_row := HBoxContainer.new() + ctx_row.add_theme_constant_override("separation", 6) + diag_vbox.add_child(ctx_row) + + hud._context_window_lbl = Label.new() + hud._context_window_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hud._context_window_lbl.add_theme_font_size_override("font_size", 11) + hud._context_window_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + ctx_row.add_child(hud._context_window_lbl) + + hud._context_window_input = LineEdit.new() + hud._context_window_input.custom_minimum_size = Vector2(96, 24) + hud._context_window_input.text = str(EvoUserSettings.context_length) + hud._context_window_input.alignment = HORIZONTAL_ALIGNMENT_RIGHT + hud._context_window_input.add_theme_font_size_override("font_size", 11) + hud._context_window_input.text_submitted.connect(hud._on_context_window_submitted) + hud._context_window_input.focus_exited.connect(hud._on_context_window_focus_exited) + ctx_row.add_child(hud._context_window_input) + + # Dedicated Full-Width Refresh Models Button + hud._refresh_models_btn = Button.new() + hud._refresh_models_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hud._refresh_models_btn.add_theme_font_size_override("font_size", 11) + hud._refresh_models_btn.pressed.connect(hud._on_refresh_models_pressed) + diag_vbox.add_child(hud._refresh_models_btn) + + # Language Row + var lang_row := HBoxContainer.new() + lang_row.add_theme_constant_override("separation", 6) + vbox.add_child(lang_row) + + var lang_lbl := Label.new() + lang_lbl.text = "🌐 Idioma / Language:" + lang_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + lang_row.add_child(lang_lbl) + + hud._lang_es_btn = Button.new() + hud._lang_es_btn.text = "ES" + hud._lang_es_btn.pressed.connect(hud._on_lang_selected.bind("es")) + lang_row.add_child(hud._lang_es_btn) + + hud._lang_en_btn = Button.new() + hud._lang_en_btn.text = "EN" + hud._lang_en_btn.pressed.connect(hud._on_lang_selected.bind("en")) + lang_row.add_child(hud._lang_en_btn) + + hud._auto_pause_check = CheckBox.new() + hud._auto_pause_check.toggled.connect(hud._on_settings_toggled) + vbox.add_child(hud._auto_pause_check) + + hud._unlimited_pressure_check = CheckBox.new() + hud._unlimited_pressure_check.toggled.connect(hud._on_settings_toggled) + vbox.add_child(hud._unlimited_pressure_check) + + hud._free_sandbox_check = CheckBox.new() + hud._free_sandbox_check.toggled.connect(hud._on_settings_toggled) + vbox.add_child(hud._free_sandbox_check) + + hud._cam_lbl = Label.new() + hud._cam_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + vbox.add_child(hud._cam_lbl) + + var cam_flow := HFlowContainer.new() + cam_flow.add_theme_constant_override("h_separation", 5) + cam_flow.add_theme_constant_override("v_separation", 5) + vbox.add_child(cam_flow) + + for deg in [32.0, 48.0, 68.0]: + var b := Button.new() + b.text = "%d°" % int(deg) + b.pressed.connect(hud._on_cam_preset_pressed.bind("pitch", deg)) + cam_flow.add_child(b) + + var fit_cam_btn := Button.new() + fit_cam_btn.text = "Mapa" + fit_cam_btn.tooltip_text = "Vista de mapa completo (Tecla F / 0)" + fit_cam_btn.pressed.connect(hud._on_cam_preset_pressed.bind("fit", 0.0)) + cam_flow.add_child(fit_cam_btn) + + var reset_cam_btn := Button.new() + reset_cam_btn.text = "🎯 Reset" + reset_cam_btn.pressed.connect(hud._on_cam_preset_pressed.bind("reset", 0.0)) + cam_flow.add_child(reset_cam_btn) + + +static func build_missing_models_modal(hud: Control) -> void: + hud._missing_models_modal = ColorRect.new() + hud._missing_models_modal.visible = false + hud._missing_models_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + hud._missing_models_modal.color = Color(0.06, 0.04, 0.03, 0.72) + hud._missing_models_modal.mouse_filter = Control.MOUSE_FILTER_STOP + hud._missing_models_modal.z_index = 60 + hud.add_child(hud._missing_models_modal) + + var mm_card := PanelContainer.new() + mm_card.anchor_left = 0.5 + mm_card.anchor_right = 0.5 + mm_card.anchor_top = 0.5 + mm_card.anchor_bottom = 0.5 + mm_card.offset_left = -230 + mm_card.offset_right = 230 + mm_card.offset_top = -120 + mm_card.offset_bottom = 120 + mm_card.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(8)) + hud._missing_models_modal.add_child(mm_card) + + var mm_inner := PanelContainer.new() + mm_inner.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(10, false)) + mm_card.add_child(mm_inner) + + var mm_vbox := VBoxContainer.new() + mm_vbox.add_theme_constant_override("separation", 8) + mm_inner.add_child(mm_vbox) + + hud._missing_models_title_lbl = Label.new() + hud._missing_models_title_lbl.add_theme_font_size_override("font_size", 14) + hud._missing_models_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + hud._missing_models_title_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + mm_vbox.add_child(hud._missing_models_title_lbl) + + hud._missing_models_desc_lbl = Label.new() + hud._missing_models_desc_lbl.add_theme_font_size_override("font_size", 11) + hud._missing_models_desc_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) + hud._missing_models_desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + mm_vbox.add_child(hud._missing_models_desc_lbl) + + hud._missing_models_progress_bar = ProgressBar.new() + hud._missing_models_progress_bar.visible = false + hud._missing_models_progress_bar.min_value = 0.0 + hud._missing_models_progress_bar.max_value = 100.0 + hud._missing_models_progress_bar.value = 0.0 + hud._missing_models_progress_bar.show_percentage = true + hud._missing_models_progress_bar.custom_minimum_size = Vector2(0, 16) + EvoUiStyle.style_progress_bar(hud._missing_models_progress_bar, EvoUiStyle.COLOR_GREEN_OK) + mm_vbox.add_child(hud._missing_models_progress_bar) + + hud._missing_models_status_lbl = Label.new() + hud._missing_models_status_lbl.visible = false + hud._missing_models_status_lbl.add_theme_font_size_override("font_size", 11) + hud._missing_models_status_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) + hud._missing_models_status_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + mm_vbox.add_child(hud._missing_models_status_lbl) + + var mm_btns := HBoxContainer.new() + mm_btns.alignment = BoxContainer.ALIGNMENT_END + mm_btns.add_theme_constant_override("separation", 8) + mm_vbox.add_child(mm_btns) + + hud._missing_models_skip_btn = Button.new() + hud._missing_models_skip_btn.pressed.connect(hud._on_missing_models_skip_pressed) + mm_btns.add_child(hud._missing_models_skip_btn) + + hud._missing_models_download_btn = Button.new() + hud._missing_models_download_btn.pressed.connect(hud._on_missing_models_download_pressed) + mm_btns.add_child(hud._missing_models_download_btn) + + +static func refresh_settings_static_labels(hud: Control) -> void: + EvoUiStyle.apply_button_active(hud._lang_es_btn, hud._lang == "es") + EvoUiStyle.apply_button_active(hud._lang_en_btn, hud._lang == "en") + + hud._primary_model_lbl.text = EvoUiStyle.tr_text(hud._lang, "Primario (Táctico):", "Primary (Tactical):") + hud._secondary_model_lbl.text = EvoUiStyle.tr_text(hud._lang, "Secundario (Ecológico):", "Secondary (Ecological):") + hud._refresh_models_btn.text = EvoUiStyle.tr_text(hud._lang, "🔄 Actualizar modelos de decisión", "🔄 Refresh decision models") + hud._refresh_models_btn.tooltip_text = EvoUiStyle.tr_text( + hud._lang, + "Volver a consultar en Ollama los modelos de decisión descargados en la máquina", + "Re-query Ollama for downloaded decision models on this machine" + ) + hud._context_window_lbl.text = EvoUiStyle.tr_text(hud._lang, "Ventana de contexto (num_ctx):", "Context window (num_ctx):") + if not hud._context_window_input.has_focus(): + hud._context_window_input.text = str(EvoUserSettings.context_length) + refresh_decision_model_selectors(hud) + hud._auto_pause_check.text = EvoUiStyle.tr_text(hud._lang, "Auto-pausa en hitos", "Auto-pause on milestones") + hud._unlimited_pressure_check.text = EvoUiStyle.tr_text(hud._lang, "Energía Ilimitada", "Unlimited Energy") + hud._free_sandbox_check.text = EvoUiStyle.tr_text(hud._lang, "Sandbox Libre", "Free Sandbox") + hud._cam_lbl.text = EvoUiStyle.tr_text(hud._lang, "🎥 Ángulo y Rotación de Cámara:", "🎥 Camera Angle & Orbit:") + + +static func refresh_settings_popup(hud: Control) -> void: + if hud._world_state == null: + return + var p_model := EvoUserSettings.primary_model + var s_model := EvoUserSettings.secondary_model + var ctx_len := EvoUserSettings.context_length + var primary_count := 0 + var secondary_count := 0 + var avg_conf := 0.0 + for d_key in hud._world_state.demes.keys(): + var d: EvoTypes.Deme = hud._world_state.demes[d_key] + if p_model == s_model: + if EvoDecisionModel.resolve_deme_decision_model_tag(d.id, "__primary__", "__secondary__") == "__secondary__": + secondary_count += 1 + else: + primary_count += 1 + elif d.decision_model_tag == s_model: + secondary_count += 1 + elif d.decision_model_tag == p_model: + primary_count += 1 + elif EvoDecisionModel.resolve_deme_decision_model_tag(d.id, "__primary__", "__secondary__") == "__secondary__": + secondary_count += 1 + else: + primary_count += 1 + avg_conf += d.last_confidence + if not hud._world_state.demes.is_empty(): + avg_conf /= float(hud._world_state.demes.size()) + + hud._settings_btn.text = "" + hud._settings_btn.icon = EvoUiStyle.get_pixel_icon("settings_gear") + hud._settings_btn.tooltip_text = "🧠 %s (%d) · %s (%d) · ctx %d" % [p_model, primary_count, s_model, secondary_count, ctx_len] + hud._models_diag_lbl.text = EvoUiStyle.tr_text( + hud._lang, + "🧠 Motores Cognitivos:\n• %s (Táctico/Instinto): %d aldeas\n• %s (Ecológico/Estratégico): %d aldeas\n• Ventana de contexto: %d · Confianza media: %d%%" % [p_model, primary_count, s_model, secondary_count, ctx_len, int(round(avg_conf * 100.0))], + "🧠 Cognitive Engines:\n• %s (Tactical/Instinct): %d villages\n• %s (Ecological/Strategic): %d villages\n• Context window: %d · Avg confidence: %d%%" % [p_model, primary_count, s_model, secondary_count, ctx_len, int(round(avg_conf * 100.0))] + ) + if (hud._settings_popup != null and hud._settings_popup.visible) or (hud._primary_model_opt != null and hud._primary_model_opt.item_count == 0): + refresh_decision_model_selectors(hud) + + hud._auto_pause_check.set_pressed_no_signal(hud._world_state.auto_pause_on_milestone) + hud._unlimited_pressure_check.set_pressed_no_signal(hud._world_state.unlimited_pressure_mode) + hud._free_sandbox_check.set_pressed_no_signal(hud._world_state.free_sandbox_mode) + + +static func refresh_decision_model_selectors(hud: Control) -> void: + if hud._primary_model_opt == null or hud._secondary_model_opt == null: + return + var status := EvoDecisionModel.check_ollama_status(false) + var decision_models: Array = status["decision_models"] if status.has("decision_models") else [] + var p_model := EvoUserSettings.primary_model + var s_model := EvoUserSettings.secondary_model + + hud._primary_model_opt.clear() + hud._secondary_model_opt.clear() + + if decision_models.is_empty(): + var empty_lbl := EvoUiStyle.tr_text(hud._lang, "Sin modelos de decisión descargados", "No decision models downloaded") + hud._primary_model_opt.add_item("%s (%s)" % [p_model, empty_lbl], 0) + hud._primary_model_opt.set_item_metadata(0, p_model) + hud._primary_model_opt.disabled = true + hud._secondary_model_opt.add_item("%s (%s)" % [s_model, empty_lbl], 0) + hud._secondary_model_opt.set_item_metadata(0, s_model) + hud._secondary_model_opt.disabled = true + return + + hud._primary_model_opt.disabled = false + hud._secondary_model_opt.disabled = false + + var p_idx := 0 + var s_idx := 0 + for i in range(decision_models.size()): + var m_tag := str(decision_models[i]) + hud._primary_model_opt.add_item(m_tag, i) + hud._primary_model_opt.set_item_metadata(i, m_tag) + if m_tag == p_model: + p_idx = i + + hud._secondary_model_opt.add_item(m_tag, i) + hud._secondary_model_opt.set_item_metadata(i, m_tag) + if m_tag == s_model: + s_idx = i + + hud._primary_model_opt.select(p_idx) + hud._secondary_model_opt.select(s_idx) diff --git a/src/ui/hud/hud_settings_modals.gd.uid b/src/ui/hud/hud_settings_modals.gd.uid new file mode 100644 index 0000000..46f97f6 --- /dev/null +++ b/src/ui/hud/hud_settings_modals.gd.uid @@ -0,0 +1 @@ +uid://cgofbhge4t3ex diff --git a/src/ui/hud/hud_stats_strip.gd b/src/ui/hud/hud_stats_strip.gd new file mode 100644 index 0000000..16b87f8 --- /dev/null +++ b/src/ui/hud/hud_stats_strip.gd @@ -0,0 +1,390 @@ +class_name EvoHudStatsStrip +extends RefCounted + +const METRIC_IDS: Array[String] = [ + "population", + "food_ratio", + "salubrity_avg", + "rural_hexes" +] + + +class StatsHistoryChart extends Control: + var history: Array[Dictionary] = [] + var metric: String = "population" + var color_a: Color = Color("#10b981") + var color_b: Color = Color("#ef4444") + var name_a: String = "A" + var name_b: String = "B" + var lang: String = "es" + + func _draw() -> void: + var r := Rect2(Vector2.ZERO, size) + if r.size.x < 20.0 or r.size.y < 20.0: + return + draw_rect(r, Color("#fbf2de"), true) + draw_rect(r, EvoUiStyle.COLOR_PARCHMENT_BORDER, false, 1.0) + + var pad_l := 38.0 + var pad_r := 12.0 + var pad_t := 18.0 + var pad_b := 18.0 + var plot_w := maxf(10.0, r.size.x - pad_l - pad_r) + var plot_h := maxf(10.0, r.size.y - pad_t - pad_b) + var plot_rect := Rect2(pad_l, pad_t, plot_w, plot_h) + + var font := ThemeDB.fallback_font + var max_val := _default_ceiling(metric) + for snap in history: + var sa: Dictionary = snap["a"] + var sb: Dictionary = snap["b"] + max_val = maxf(max_val, maxf(float(sa[metric]), float(sb[metric]))) + + for i in range(3): + var frac := float(i) / 2.0 + var gy := plot_rect.position.y + plot_rect.size.y * (1.0 - frac) + draw_line( + Vector2(plot_rect.position.x, gy), + Vector2(plot_rect.end.x, gy), + Color(0.45, 0.30, 0.18, 0.22), + 1.0 + ) + var axis_lbl := _format_metric_value(metric, max_val * frac) + draw_string( + font, + Vector2(4.0, gy + 4.0), + axis_lbl, + HORIZONTAL_ALIGNMENT_LEFT, + pad_l - 6.0, + 9, + EvoUiStyle.COLOR_SEPIA + ) + + if history.is_empty(): + return + + var count := history.size() + var pts_a := PackedVector2Array() + var pts_b := PackedVector2Array() + for i in range(count): + var x_frac := float(i) / float(maxi(1, count - 1)) if count > 1 else 0.5 + var px := plot_rect.position.x + plot_rect.size.x * x_frac + var snap: Dictionary = history[i] + var val_a := float((snap["a"] as Dictionary)[metric]) + var val_b := float((snap["b"] as Dictionary)[metric]) + var py_a := plot_rect.position.y + plot_rect.size.y * (1.0 - clampf(val_a / max_val, 0.0, 1.0)) + var py_b := plot_rect.position.y + plot_rect.size.y * (1.0 - clampf(val_b / max_val, 0.0, 1.0)) + pts_a.append(Vector2(px, py_a)) + pts_b.append(Vector2(px, py_b)) + + if count >= 2: + draw_polyline(pts_a, color_a, 2.4, true) + draw_polyline(pts_b, color_b, 2.4, true) + draw_circle(pts_a[count - 1], 3.5, color_a) + draw_circle(pts_b[count - 1], 3.5, color_b) + + var last_snap: Dictionary = history[count - 1] + var last_a := _format_metric_value(metric, float((last_snap["a"] as Dictionary)[metric])) + var last_b := _format_metric_value(metric, float((last_snap["b"] as Dictionary)[metric])) + var header_a := "■ %s: %s" % [name_a, last_a] + var header_b := "■ %s: %s" % [name_b, last_b] + draw_string(font, Vector2(pad_l, 12.0), header_a, HORIZONTAL_ALIGNMENT_LEFT, plot_w * 0.48, 10, color_a.darkened(0.15)) + draw_string(font, Vector2(pad_l + plot_w * 0.50, 12.0), header_b, HORIZONTAL_ALIGNMENT_RIGHT, plot_w * 0.50, 10, color_b.darkened(0.15)) + + var gen_first := int(history[0]["generation"]) + var gen_last := int(last_snap["generation"]) + draw_string(font, Vector2(pad_l, r.size.y - 4.0), "G%d" % gen_first, HORIZONTAL_ALIGNMENT_LEFT, 48.0, 9, EvoUiStyle.COLOR_SEPIA) + draw_string(font, Vector2(plot_rect.end.x - 48.0, r.size.y - 4.0), "G%d" % gen_last, HORIZONTAL_ALIGNMENT_RIGHT, 48.0, 9, EvoUiStyle.COLOR_SEPIA) + + static func _default_ceiling(m: String) -> float: + match m: + "food_ratio": + return 1.2 + "salubrity_avg": + return 1.0 + "rural_hexes": + return 4.0 + return 100.0 + + static func _format_metric_value(m: String, val: float) -> String: + match m: + "food_ratio", "salubrity_avg": + return "%d%%" % int(round(val * 100.0)) + "rural_hexes", "population": + return "%d" % int(round(val)) + return "%.1f" % val + + +static func build_stats_strip(hud: Control) -> void: + hud._stats_strip_panel = PanelContainer.new() + hud._stats_strip_panel.anchor_left = 0.5 + hud._stats_strip_panel.anchor_right = 0.5 + hud._stats_strip_panel.anchor_top = 0.0 + hud._stats_strip_panel.anchor_bottom = 0.0 + hud._stats_strip_panel.offset_left = -255 + hud._stats_strip_panel.offset_right = 255 + hud._stats_strip_panel.offset_top = 10 + hud._stats_strip_panel.offset_bottom = 68 + hud._stats_strip_panel.z_index = 20 + hud._stats_strip_panel.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(4)) + hud.add_child(hud._stats_strip_panel) + + var inner := PanelContainer.new() + inner.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(5, false)) + hud._stats_strip_panel.add_child(inner) + + var main_hbox := HBoxContainer.new() + main_hbox.add_theme_constant_override("separation", 6) + inner.add_child(main_hbox) + + # Left: Strategy A + var a_vbox := VBoxContainer.new() + a_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + a_vbox.add_theme_constant_override("separation", 1) + main_hbox.add_child(a_vbox) + + var a_top := HBoxContainer.new() + a_top.add_theme_constant_override("separation", 4) + a_vbox.add_child(a_top) + + var a_swatch := ColorRect.new() + a_swatch.custom_minimum_size = Vector2(10, 10) + a_swatch.size_flags_vertical = Control.SIZE_SHRINK_CENTER + a_swatch.color = Color("#10b981") + a_top.add_child(a_swatch) + + var a_title_lbl := Label.new() + a_title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + a_title_lbl.clip_text = true + a_title_lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + a_title_lbl.add_theme_font_size_override("font_size", 11) + a_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + a_top.add_child(a_title_lbl) + + var a_sub_lbl := Label.new() + a_sub_lbl.clip_text = true + a_sub_lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + a_sub_lbl.add_theme_font_size_override("font_size", 10) + a_sub_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) + a_vbox.add_child(a_sub_lbl) + + # Center: Global Pacts / Vendettas / Wars + History Chart Toggle + var center_vbox := VBoxContainer.new() + center_vbox.alignment = BoxContainer.ALIGNMENT_CENTER + center_vbox.add_theme_constant_override("separation", 2) + main_hbox.add_child(center_vbox) + + var center_lbl := Label.new() + center_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + center_lbl.add_theme_font_size_override("font_size", 10) + center_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) + center_vbox.add_child(center_lbl) + + hud._stats_chart_toggle_btn = Button.new() + hud._stats_chart_toggle_btn.text = "📈" + hud._stats_chart_toggle_btn.custom_minimum_size = Vector2(40, 20) + hud._stats_chart_toggle_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + hud._stats_chart_toggle_btn.add_theme_font_size_override("font_size", 10) + hud._stats_chart_toggle_btn.pressed.connect(func() -> void: + hud._stats_chart_panel.visible = not hud._stats_chart_panel.visible + EvoUiStyle.apply_button_active(hud._stats_chart_toggle_btn, hud._stats_chart_panel.visible) + refresh_stats_strip(hud) + ) + center_vbox.add_child(hud._stats_chart_toggle_btn) + + # Right: Strategy B + var b_vbox := VBoxContainer.new() + b_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + b_vbox.add_theme_constant_override("separation", 1) + main_hbox.add_child(b_vbox) + + var b_top := HBoxContainer.new() + b_top.alignment = BoxContainer.ALIGNMENT_END + b_top.add_theme_constant_override("separation", 4) + b_vbox.add_child(b_top) + + var b_title_lbl := Label.new() + b_title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + b_title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + b_title_lbl.clip_text = true + b_title_lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + b_title_lbl.add_theme_font_size_override("font_size", 11) + b_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + b_top.add_child(b_title_lbl) + + var b_swatch := ColorRect.new() + b_swatch.custom_minimum_size = Vector2(10, 10) + b_swatch.size_flags_vertical = Control.SIZE_SHRINK_CENTER + b_swatch.color = Color("#ef4444") + b_top.add_child(b_swatch) + + var b_sub_lbl := Label.new() + b_sub_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + b_sub_lbl.clip_text = true + b_sub_lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + b_sub_lbl.add_theme_font_size_override("font_size", 10) + b_sub_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_SEPIA) + b_vbox.add_child(b_sub_lbl) + + # Collapsible History Chart Drawer right below the strip + hud._stats_chart_panel = PanelContainer.new() + hud._stats_chart_panel.visible = false + hud._stats_chart_panel.anchor_left = 0.5 + hud._stats_chart_panel.anchor_right = 0.5 + hud._stats_chart_panel.anchor_top = 0.0 + hud._stats_chart_panel.anchor_bottom = 0.0 + hud._stats_chart_panel.offset_left = -255 + hud._stats_chart_panel.offset_right = 255 + hud._stats_chart_panel.offset_top = 72 + hud._stats_chart_panel.offset_bottom = 242 + hud._stats_chart_panel.z_index = 25 + hud._stats_chart_panel.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(5)) + hud.add_child(hud._stats_chart_panel) + + var chart_inner := PanelContainer.new() + chart_inner.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(6, false)) + hud._stats_chart_panel.add_child(chart_inner) + + var chart_vbox := VBoxContainer.new() + chart_vbox.add_theme_constant_override("separation", 4) + chart_inner.add_child(chart_vbox) + + var pills_row := HBoxContainer.new() + pills_row.add_theme_constant_override("separation", 4) + chart_vbox.add_child(pills_row) + + var metric_buttons: Dictionary = {} + for m_id in METRIC_IDS: + var m_btn := Button.new() + m_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + m_btn.add_theme_font_size_override("font_size", 10) + m_btn.pressed.connect(func() -> void: + hud._stats_chart_metric = m_id + refresh_stats_strip(hud) + ) + pills_row.add_child(m_btn) + metric_buttons[m_id] = m_btn + + var chart_ctrl := StatsHistoryChart.new() + chart_ctrl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + chart_ctrl.size_flags_vertical = Control.SIZE_EXPAND_FILL + chart_ctrl.custom_minimum_size = Vector2(0, 114) + chart_vbox.add_child(chart_ctrl) + + hud.set_meta("_stats_a_swatch", a_swatch) + hud.set_meta("_stats_a_title", a_title_lbl) + hud.set_meta("_stats_a_sub", a_sub_lbl) + hud.set_meta("_stats_b_swatch", b_swatch) + hud.set_meta("_stats_b_title", b_title_lbl) + hud.set_meta("_stats_b_sub", b_sub_lbl) + hud.set_meta("_stats_center_lbl", center_lbl) + hud.set_meta("_stats_metric_btns", metric_buttons) + hud.set_meta("_stats_chart_ctrl", chart_ctrl) + + +static func refresh_stats_strip(hud: Control) -> void: + if hud._stats_strip_panel == null or hud._world_state == null: + return + var state: EvoTypes.WorldState = hud._world_state + var lang: String = hud._lang + var stats := EvoTypes.compute_world_stats(state) + var sa: Dictionary = stats["a"] + var sb: Dictionary = stats["b"] + + var col_a := Color("#10b981") + var col_b := Color("#ef4444") + var name_a := "A" + var name_b := "B" + if state.strategies_catalog.has(state.strategy_a_id): + var def_a: EvoTypes.StrategyDef = state.strategies_catalog[state.strategy_a_id] + col_a = Color.from_string(def_a.color, col_a) + name_a = _short_strategy_name(EvoI18n.strategy_name(lang, def_a)) + if state.strategies_catalog.has(state.strategy_b_id): + var def_b: EvoTypes.StrategyDef = state.strategies_catalog[state.strategy_b_id] + col_b = Color.from_string(def_b.color, col_b) + name_b = _short_strategy_name(EvoI18n.strategy_name(lang, def_b)) + + var a_swatch := hud.get_meta("_stats_a_swatch") as ColorRect + var a_title := hud.get_meta("_stats_a_title") as Label + var a_sub := hud.get_meta("_stats_a_sub") as Label + var b_swatch := hud.get_meta("_stats_b_swatch") as ColorRect + var b_title := hud.get_meta("_stats_b_title") as Label + var b_sub := hud.get_meta("_stats_b_sub") as Label + var center_lbl := hud.get_meta("_stats_center_lbl") as Label + + a_swatch.color = col_a + b_swatch.color = col_b + + a_title.text = _format_side_headline(name_a, sa) + a_sub.text = _format_side_subline(sa) + b_title.text = _format_side_headline(name_b, sb) + b_sub.text = _format_side_subline(sb) + + center_lbl.text = "🤝%d · 🩸%d · ⚔%d" % [ + int(stats["pacts"]), + int(stats["vendettas"]), + int(stats["wars"]) + ] + hud._stats_chart_toggle_btn.tooltip_text = EvoUiStyle.tr_text( + lang, + "Mostrar / ocultar gráficas históricas A vs. B", + "Show / hide A vs. B history charts" + ) + + var metric_buttons: Dictionary = hud.get_meta("_stats_metric_btns") + for m_id in METRIC_IDS: + var btn := metric_buttons[m_id] as Button + btn.text = _metric_label(lang, m_id) + EvoUiStyle.apply_button_active(btn, hud._stats_chart_metric == m_id) + + if hud._stats_chart_panel != null and hud._stats_chart_panel.visible: + var chart_ctrl := hud.get_meta("_stats_chart_ctrl") as StatsHistoryChart + if state.stats_history.is_empty(): + chart_ctrl.history = [stats] + else: + chart_ctrl.history = state.stats_history + chart_ctrl.metric = hud._stats_chart_metric + chart_ctrl.color_a = col_a + chart_ctrl.color_b = col_b + chart_ctrl.name_a = name_a + chart_ctrl.name_b = name_b + chart_ctrl.lang = lang + chart_ctrl.queue_redraw() + + +static func _short_strategy_name(full_name: String) -> String: + if full_name.length() <= 14: + return full_name + return full_name.substr(0, 13) + "…" + + +static func _format_side_headline(short_name: String, side: Dictionary) -> String: + var v_cnt := int(side["villages"]) + var pop := int(side["population"]) + var delta := int(side["pop_delta"]) + var delta_str := "(%+d)" % delta if delta != 0 else "" + return "%s · 🏘%d · 👥%d%s" % [short_name, v_cnt, pop, delta_str] + + +static func _format_side_subline(side: Dictionary) -> String: + var food_pct := int(round(float(side["food_ratio"]) * 100.0)) + var sal_pct := int(round(float(side["salubrity_avg"]) * 100.0)) + var rural := int(side["rural_hexes"]) + var inf := int(side["infected"]) + var base := "🍖%d%% · ✚%d%% · 🌾%d" % [food_pct, sal_pct, rural] + if inf > 0: + return base + (" · ☣%d" % inf) + return base + + +static func _metric_label(lang: String, metric_id: String) -> String: + match metric_id: + "population": + return EvoUiStyle.tr_text(lang, "👥 Población", "👥 Population") + "food_ratio": + return EvoUiStyle.tr_text(lang, "🍖 Alimento %", "🍖 Food %") + "salubrity_avg": + return EvoUiStyle.tr_text(lang, "✚ Salubridad %", "✚ Salubrity %") + "rural_hexes": + return EvoUiStyle.tr_text(lang, "🌾 Territorio", "🌾 Territory") + return metric_id diff --git a/src/ui/hud/hud_stats_strip.gd.uid b/src/ui/hud/hud_stats_strip.gd.uid new file mode 100644 index 0000000..9ba949a --- /dev/null +++ b/src/ui/hud/hud_stats_strip.gd.uid @@ -0,0 +1 @@ +uid://bg155a2est0vp diff --git a/src/ui/hud/hud_top_bar.gd b/src/ui/hud/hud_top_bar.gd index c7840da..fae7aa1 100644 --- a/src/ui/hud/hud_top_bar.gd +++ b/src/ui/hud/hud_top_bar.gd @@ -1,9 +1,6 @@ class_name EvoHudTopBar extends RefCounted -signal matchup_changed(strategy_a_id: String, strategy_b_id: String) -signal matchup_reset_requested() - const PRESET_LIST: Array[String] = [ "preset-rift-savanna", "preset-darwin-archipelago", @@ -18,17 +15,6 @@ const BASE_STRATEGY_ORDER: Array[String] = [ "strat-innovation" ] -var _hud: Control = null -var _suppress_preset_signal: bool = false -var _suppress_strategy_signal: bool = false - -var strategy_a_swatch: ColorRect -var strategy_a_option: OptionButton -var matchup_vs_label: Label -var strategy_b_swatch: ColorRect -var strategy_b_option: OptionButton -var matchup_reset_button: Button - class SeasonTidePortrait extends Control: var is_wet_warm: bool = true @@ -119,31 +105,6 @@ class SeasonTidePortrait extends Control: draw_rect(Rect2(corner, Vector2(2, 2)), EvoUiStyle.COLOR_GOLD_BRIGHT, true) -func _init(p_hud: Control = null) -> void: - _hud = p_hud - - -func refresh_strategies(state: EvoTypes.WorldState, lang: String = "es") -> void: - if _hud != null: - EvoHudTopBar.refresh_strategies_for_hud(_hud, state, lang) - - -func refresh(state: EvoTypes.WorldState, is_playing: bool = false) -> void: - if _hud != null: - _hud._is_playing = is_playing - refresh_strategies(state, _hud._lang) - EvoHudTopBar.update_static_labels(_hud) - EvoHudTopBar.update_top_bar_state(_hud) - - -func set_language(lang: String) -> void: - if _hud != null: - _hud._lang = lang - refresh_strategies(_hud._world_state, lang) - EvoHudTopBar.update_static_labels(_hud) - EvoHudTopBar.update_top_bar_state(_hud) - - static func build_top_left_valley_sign_and_medallions(hud: Control) -> void: var sign_frame := PanelContainer.new() sign_frame.anchor_left = 0.0 @@ -151,7 +112,7 @@ static func build_top_left_valley_sign_and_medallions(hud: Control) -> void: sign_frame.anchor_top = 0.0 sign_frame.anchor_bottom = 0.0 sign_frame.offset_left = 10 - sign_frame.offset_right = 420 + sign_frame.offset_right = 446 sign_frame.offset_top = 10 sign_frame.offset_bottom = 156 sign_frame.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(6)) @@ -241,13 +202,6 @@ static func build_top_left_valley_sign_and_medallions(hud: Control) -> void: hud.strategy_b_swatch = sw_b hud.strategy_b_option = opt_b hud.matchup_reset_button = reset_btn - if hud.top_bar != null: - hud.top_bar.strategy_a_swatch = sw_a - hud.top_bar.strategy_a_option = opt_a - hud.top_bar.matchup_vs_label = vs_lbl - hud.top_bar.strategy_b_swatch = sw_b - hud.top_bar.strategy_b_option = opt_b - hud.top_bar.matchup_reset_button = reset_btn # Row 3: 3 Artifact Buttons (Diario, Linajes, Forja) var artifacts_row := HBoxContainer.new() @@ -278,16 +232,23 @@ static func build_top_left_valley_sign_and_medallions(hud: Control) -> void: lens_hbox.add_theme_constant_override("separation", 4) hud._lens_strip_panel.add_child(lens_hbox) - for l_id in ["biome", "food", "health"]: + for l_id in ["biome", "food", "health", "diplomacy", "territory"]: var l_btn := Button.new() l_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - l_btn.add_theme_font_size_override("font_size", 11) + l_btn.add_theme_font_size_override("font_size", 10) l_btn.pressed.connect(hud._on_lens_pressed.bind(l_id)) lens_hbox.add_child(l_btn) hud._lens_buttons[l_id] = l_btn + var fit_map_btn := Button.new() + fit_map_btn.text = "Mapa" + fit_map_btn.tooltip_text = "Vista de mapa completo (Tecla F / 0)" + fit_map_btn.add_theme_font_size_override("font_size", 10) + fit_map_btn.pressed.connect(hud._on_cam_preset_pressed.bind("fit", 0.0)) + lens_hbox.add_child(fit_map_btn) + hud._legend_toggle_btn = Button.new() - hud._legend_toggle_btn.add_theme_font_size_override("font_size", 11) + hud._legend_toggle_btn.add_theme_font_size_override("font_size", 10) hud._legend_toggle_btn.pressed.connect(hud._on_legend_toggle_pressed) lens_hbox.add_child(hud._legend_toggle_btn) @@ -419,169 +380,7 @@ static func build_top_right_clock(hud: Control) -> void: static func build_settings_popup(hud: Control) -> void: - hud._settings_popup = PanelContainer.new() - hud._settings_popup.visible = false - hud._settings_popup.anchor_left = 1.0 - hud._settings_popup.anchor_right = 1.0 - hud._settings_popup.anchor_top = 0.0 - hud._settings_popup.anchor_bottom = 0.0 - hud._settings_popup.offset_left = -736 - hud._settings_popup.offset_right = -364 - hud._settings_popup.offset_top = 10 - hud._settings_popup.offset_bottom = 450 - hud._settings_popup.z_index = 30 - hud._settings_popup.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(8)) - hud.add_child(hud._settings_popup) - - var inner := PanelContainer.new() - inner.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_panel(8, false)) - hud._settings_popup.add_child(inner) - - var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 6) - inner.add_child(vbox) - - # System 1 Ollama Models Diagnostic & Selection Card - var diag_card := PanelContainer.new() - diag_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_GOLD_BEVEL, 6)) - vbox.add_child(diag_card) - - var diag_vbox := VBoxContainer.new() - diag_vbox.add_theme_constant_override("separation", 5) - diag_card.add_child(diag_vbox) - - hud._models_diag_lbl = Label.new() - hud._models_diag_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - hud._models_diag_lbl.add_theme_font_size_override("font_size", 11) - hud._models_diag_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - diag_vbox.add_child(hud._models_diag_lbl) - - # Primary Decision Model Row - var p_row := HBoxContainer.new() - p_row.add_theme_constant_override("separation", 6) - diag_vbox.add_child(p_row) - - hud._primary_model_lbl = Label.new() - hud._primary_model_lbl.custom_minimum_size = Vector2(140, 0) - hud._primary_model_lbl.add_theme_font_size_override("font_size", 11) - hud._primary_model_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - p_row.add_child(hud._primary_model_lbl) - - hud._primary_model_opt = OptionButton.new() - hud._primary_model_opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hud._primary_model_opt.fit_to_longest_item = false - hud._primary_model_opt.clip_text = true - hud._primary_model_opt.add_theme_font_size_override("font_size", 11) - hud._primary_model_opt.item_selected.connect(hud._on_primary_model_selected) - p_row.add_child(hud._primary_model_opt) - - # Secondary Decision Model Row - var s_row := HBoxContainer.new() - s_row.add_theme_constant_override("separation", 6) - diag_vbox.add_child(s_row) - - hud._secondary_model_lbl = Label.new() - hud._secondary_model_lbl.custom_minimum_size = Vector2(140, 0) - hud._secondary_model_lbl.add_theme_font_size_override("font_size", 11) - hud._secondary_model_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - s_row.add_child(hud._secondary_model_lbl) - - hud._secondary_model_opt = OptionButton.new() - hud._secondary_model_opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hud._secondary_model_opt.fit_to_longest_item = false - hud._secondary_model_opt.clip_text = true - hud._secondary_model_opt.add_theme_font_size_override("font_size", 11) - hud._secondary_model_opt.item_selected.connect(hud._on_secondary_model_selected) - s_row.add_child(hud._secondary_model_opt) - - # Context Window Size (Free Input Field) - var ctx_row := HBoxContainer.new() - ctx_row.add_theme_constant_override("separation", 6) - diag_vbox.add_child(ctx_row) - - hud._context_window_lbl = Label.new() - hud._context_window_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hud._context_window_lbl.add_theme_font_size_override("font_size", 11) - hud._context_window_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - ctx_row.add_child(hud._context_window_lbl) - - hud._context_window_input = LineEdit.new() - hud._context_window_input.custom_minimum_size = Vector2(96, 24) - hud._context_window_input.text = str(EvoUserSettings.context_length) - hud._context_window_input.alignment = HORIZONTAL_ALIGNMENT_RIGHT - hud._context_window_input.add_theme_font_size_override("font_size", 11) - hud._context_window_input.text_submitted.connect(hud._on_context_window_submitted) - hud._context_window_input.focus_exited.connect(hud._on_context_window_focus_exited) - ctx_row.add_child(hud._context_window_input) - - # Dedicated Full-Width Refresh Models Button - hud._refresh_models_btn = Button.new() - hud._refresh_models_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hud._refresh_models_btn.add_theme_font_size_override("font_size", 11) - hud._refresh_models_btn.pressed.connect(hud._on_refresh_models_pressed) - diag_vbox.add_child(hud._refresh_models_btn) - - # Language Row - var lang_row := HBoxContainer.new() - lang_row.add_theme_constant_override("separation", 6) - vbox.add_child(lang_row) - - var lang_lbl := Label.new() - lang_lbl.text = "🌐 Idioma / Language:" - lang_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL - lang_row.add_child(lang_lbl) - - hud._lang_es_btn = Button.new() - hud._lang_es_btn.text = "ES" - hud._lang_es_btn.pressed.connect(hud._on_lang_selected.bind("es")) - lang_row.add_child(hud._lang_es_btn) - - hud._lang_en_btn = Button.new() - hud._lang_en_btn.text = "EN" - hud._lang_en_btn.pressed.connect(hud._on_lang_selected.bind("en")) - lang_row.add_child(hud._lang_en_btn) - - hud._auto_pause_check = CheckBox.new() - hud._auto_pause_check.toggled.connect(hud._on_settings_toggled) - vbox.add_child(hud._auto_pause_check) - - hud._unlimited_pressure_check = CheckBox.new() - hud._unlimited_pressure_check.toggled.connect(hud._on_settings_toggled) - vbox.add_child(hud._unlimited_pressure_check) - - hud._free_sandbox_check = CheckBox.new() - hud._free_sandbox_check.toggled.connect(hud._on_settings_toggled) - vbox.add_child(hud._free_sandbox_check) - - hud._cam_lbl = Label.new() - hud._cam_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - vbox.add_child(hud._cam_lbl) - - var cam_flow := HFlowContainer.new() - cam_flow.add_theme_constant_override("h_separation", 5) - cam_flow.add_theme_constant_override("v_separation", 5) - vbox.add_child(cam_flow) - - for deg in [32.0, 48.0, 68.0]: - var b := Button.new() - b.text = "%d°" % int(deg) - b.pressed.connect(hud._on_cam_preset_pressed.bind("pitch", deg)) - cam_flow.add_child(b) - - var rot_q_btn := Button.new() - rot_q_btn.text = "↺ Q (-18°)" - rot_q_btn.pressed.connect(hud._on_cam_preset_pressed.bind("yaw", -18.0)) - cam_flow.add_child(rot_q_btn) - - var rot_e_btn := Button.new() - rot_e_btn.text = "↻ E (+18°)" - rot_e_btn.pressed.connect(hud._on_cam_preset_pressed.bind("yaw", 18.0)) - cam_flow.add_child(rot_e_btn) - - var reset_cam_btn := Button.new() - reset_cam_btn.text = "🎯 Reset" - reset_cam_btn.pressed.connect(hud._on_cam_preset_pressed.bind("reset", 0.0)) - cam_flow.add_child(reset_cam_btn) + EvoHudSettingsModals.build_settings_popup(hud) static func refresh_strategies_for_hud(hud: Control, state: EvoTypes.WorldState, lang: String = "es") -> void: @@ -610,8 +409,6 @@ static func refresh_strategies_for_hud(hud: Control, state: EvoTypes.WorldState, ordered_ids.append(extra_id) hud._suppress_strategy_signal = true - if hud.top_bar != null: - hud.top_bar._suppress_strategy_signal = true hud.strategy_a_option.clear() hud.strategy_b_option.clear() @@ -635,8 +432,6 @@ static func refresh_strategies_for_hud(hud: Control, state: EvoTypes.WorldState, hud.strategy_b_option.select(idx_b) hud._suppress_strategy_signal = false - if hud.top_bar != null: - hud.top_bar._suppress_strategy_signal = false var strat_a_id := ordered_ids[idx_a] var strat_b_id := ordered_ids[idx_b] @@ -712,34 +507,15 @@ static func update_static_labels(hud: Control) -> void: EvoUiStyle.apply_button_active(hud._play_btn, hud._is_playing) hud._step_btn.text = EvoUiStyle.tr_text(hud._lang, "⏭ +1", "⏭ +1") - hud._speed_btn.text = "2x" if hud._is_fast else "1x" - EvoUiStyle.apply_button_active(hud._speed_btn, hud._is_fast) + var speed_labels := ["🐢 0.5x", "▶ 1x", "⏩ 3x"] + hud._speed_btn.text = str(speed_labels[clampi(hud._speed_index, 0, 2)]) + EvoUiStyle.apply_button_active(hud._speed_btn, hud._speed_index != 1) hud._notebook_btn.text = EvoUiStyle.tr_text(hud._lang, "📖 Diario", "📖 Journal") hud._tree_btn.text = EvoUiStyle.tr_text(hud._lang, "🌳 Linajes", "🌳 Lineages") hud._lab_btn.text = EvoUiStyle.tr_text(hud._lang, "⚗️ Forja", "⚗️ Forge") - if hud._lens_cycle_btn != null: - hud._lens_cycle_btn.text = EvoUiStyle.tr_text(hud._lang, "👁️ Lente", "👁️ Lens") - EvoUiStyle.apply_button_active(hud._lang_es_btn, hud._lang == "es") - EvoUiStyle.apply_button_active(hud._lang_en_btn, hud._lang == "en") - - hud._primary_model_lbl.text = EvoUiStyle.tr_text(hud._lang, "Primario (Táctico):", "Primary (Tactical):") - hud._secondary_model_lbl.text = EvoUiStyle.tr_text(hud._lang, "Secundario (Ecológico):", "Secondary (Ecological):") - hud._refresh_models_btn.text = EvoUiStyle.tr_text(hud._lang, "🔄 Actualizar modelos de decisión", "🔄 Refresh decision models") - hud._refresh_models_btn.tooltip_text = EvoUiStyle.tr_text( - hud._lang, - "Volver a consultar en Ollama los modelos de decisión descargados en la máquina", - "Re-query Ollama for downloaded decision models on this machine" - ) - hud._context_window_lbl.text = EvoUiStyle.tr_text(hud._lang, "Ventana de contexto (num_ctx):", "Context window (num_ctx):") - if not hud._context_window_input.has_focus(): - hud._context_window_input.text = str(EvoUserSettings.context_length) - refresh_decision_model_selectors(hud) - hud._auto_pause_check.text = EvoUiStyle.tr_text(hud._lang, "Auto-pausa en hitos", "Auto-pause on milestones") - hud._unlimited_pressure_check.text = EvoUiStyle.tr_text(hud._lang, "Energía Ilimitada", "Unlimited Energy") - hud._free_sandbox_check.text = EvoUiStyle.tr_text(hud._lang, "Sandbox Libre", "Free Sandbox") - hud._cam_lbl.text = EvoUiStyle.tr_text(hud._lang, "🎥 Ángulo y Rotación de Cámara:", "🎥 Camera Angle & Orbit:") + EvoHudSettingsModals.refresh_settings_static_labels(hud) hud._radius_1_btn.text = "◯ 1x" hud._radius_1_btn.tooltip_text = EvoUiStyle.tr_text(hud._lang, "Pincel de 1 hexágono", "1-hex brush") @@ -748,13 +524,13 @@ static func update_static_labels(hud: Control) -> void: EvoUiStyle.apply_button_active(hud._radius_1_btn, hud._brush_radius == 1) EvoUiStyle.apply_button_active(hud._radius_2_btn, hud._brush_radius == 2) - (hud._lens_buttons["biome"] as Button).text = EvoUiStyle.tr_text(hud._lang, "🌿 Bioma", "🌿 Biome") - (hud._lens_buttons["food"] as Button).text = EvoUiStyle.tr_text(hud._lang, "🌾 Alimento", "🌾 Food") - (hud._lens_buttons["health"] as Button).text = EvoUiStyle.tr_text(hud._lang, "💚 Salud", "💚 Health") - for l_id in hud._lens_buttons.keys(): - EvoUiStyle.apply_button_active(hud._lens_buttons[l_id] as Button, hud._active_lens == l_id) + for l_id in ["biome", "food", "health", "diplomacy", "territory"]: + var l_btn := hud._lens_buttons[l_id] as Button + l_btn.text = EvoI18n.t(hud._lang, "lens." + l_id) + EvoUiStyle.apply_button_active(l_btn, hud._active_lens == l_id) - hud._legend_toggle_btn.text = EvoUiStyle.tr_text(hud._lang, "🗺️ Leyenda", "🗺️ Legend") + hud._legend_toggle_btn.text = "🗺️" + hud._legend_toggle_btn.tooltip_text = EvoUiStyle.tr_text(hud._lang, "🗺️ Leyenda", "🗺️ Legend") EvoUiStyle.apply_button_active(hud._legend_toggle_btn, hud._legend_panel.visible) hud._tab_deme_btn.text = EvoUiStyle.tr_text(hud._lang, "🧠 Aldea", "🧠 Village") @@ -764,9 +540,6 @@ static func update_static_labels(hud: Control) -> void: EvoUiStyle.apply_button_active(hud._tab_traits_btn, hud._inspector_tab == "traits") EvoUiStyle.apply_button_active(hud._tab_habitat_btn, hud._inspector_tab == "habitat") - if hud._villages_title_lbl != null: - hud._villages_title_lbl.text = EvoUiStyle.tr_text(hud._lang, "🏘️ Aldeas Vivas del Valle", "🏘️ Living Valley Villages") - static func update_top_bar_state(hud: Control) -> void: if hud._world_state == null: @@ -782,12 +555,8 @@ static func update_top_bar_state(hud: Control) -> void: var is_wet: bool = (hud._world_state.season == "wet_warm") if is_wet: hud._season_label.text = EvoUiStyle.tr_text(hud._lang, "☀️ Primavera Cálida", "☀️ Warm Spring") - if hud._season_icon != null: - hud._season_icon.texture = EvoUiStyle.get_pixel_icon("season_wet") else: hud._season_label.text = EvoUiStyle.tr_text(hud._lang, "❄️ Invierno Seco", "❄️ Dry Winter") - if hud._season_icon != null: - hud._season_icon.texture = EvoUiStyle.get_pixel_icon("season_dry") var sea_pct := int(round(hud._world_state.sea_level * 100.0)) hud._sea_level_label.text = "🌊 %d%%" % sea_pct @@ -800,85 +569,9 @@ static func update_top_bar_state(hud: Control) -> void: hud._pressure_label.text = "⚡ %d pts · +%d/gen" % [hud._world_state.selective_pressure_points, hud._world_state.selective_pressure_regen_per_turn] hud._pressure_bar.value = clampf(float(hud._world_state.selective_pressure_points), 0.0, 100.0) - # Compute System 1 Models summary dynamically using active primary and secondary models - var p_model := EvoUserSettings.primary_model - var s_model := EvoUserSettings.secondary_model - var ctx_len := EvoUserSettings.context_length - var primary_count := 0 - var secondary_count := 0 - var avg_conf := 0.0 - for d_key in hud._world_state.demes.keys(): - var d: EvoTypes.Deme = hud._world_state.demes[d_key] - if p_model == s_model: - if EvoDecisionModel.resolve_deme_decision_model_tag(d.id, "__primary__", "__secondary__") == "__secondary__": - secondary_count += 1 - else: - primary_count += 1 - elif d.decision_model_tag == s_model: - secondary_count += 1 - elif d.decision_model_tag == p_model: - primary_count += 1 - elif EvoDecisionModel.resolve_deme_decision_model_tag(d.id, "__primary__", "__secondary__") == "__secondary__": - secondary_count += 1 - else: - primary_count += 1 - avg_conf += d.last_confidence - if not hud._world_state.demes.is_empty(): - avg_conf /= float(hud._world_state.demes.size()) - - hud._settings_btn.text = "" - hud._settings_btn.icon = EvoUiStyle.get_pixel_icon("settings_gear") - hud._settings_btn.tooltip_text = "🧠 %s (%d) · %s (%d) · ctx %d" % [p_model, primary_count, s_model, secondary_count, ctx_len] - hud._models_diag_lbl.text = EvoUiStyle.tr_text( - hud._lang, - "🧠 Motores Cognitivos:\n• %s (Táctico/Instinto): %d aldeas\n• %s (Ecológico/Estratégico): %d aldeas\n• Ventana de contexto: %d · Confianza media: %d%%" % [p_model, primary_count, s_model, secondary_count, ctx_len, int(round(avg_conf * 100.0))], - "🧠 Cognitive Engines:\n• %s (Tactical/Instinct): %d villages\n• %s (Ecological/Strategic): %d villages\n• Context window: %d · Avg confidence: %d%%" % [p_model, primary_count, s_model, secondary_count, ctx_len, int(round(avg_conf * 100.0))] - ) - if (hud._settings_popup != null and hud._settings_popup.visible) or (hud._primary_model_opt != null and hud._primary_model_opt.item_count == 0): - refresh_decision_model_selectors(hud) - - hud._auto_pause_check.set_pressed_no_signal(hud._world_state.auto_pause_on_milestone) - hud._unlimited_pressure_check.set_pressed_no_signal(hud._world_state.unlimited_pressure_mode) - hud._free_sandbox_check.set_pressed_no_signal(hud._world_state.free_sandbox_mode) + EvoHudSettingsModals.refresh_settings_popup(hud) static func refresh_decision_model_selectors(hud: Control) -> void: - if hud._primary_model_opt == null or hud._secondary_model_opt == null: - return - var status := EvoDecisionModel.check_ollama_status(false) - var decision_models: Array = status["decision_models"] if status.has("decision_models") else [] - var p_model := EvoUserSettings.primary_model - var s_model := EvoUserSettings.secondary_model - - hud._primary_model_opt.clear() - hud._secondary_model_opt.clear() - - if decision_models.is_empty(): - var empty_lbl := EvoUiStyle.tr_text(hud._lang, "Sin modelos de decisión descargados", "No decision models downloaded") - hud._primary_model_opt.add_item("%s (%s)" % [p_model, empty_lbl], 0) - hud._primary_model_opt.set_item_metadata(0, p_model) - hud._primary_model_opt.disabled = true - hud._secondary_model_opt.add_item("%s (%s)" % [s_model, empty_lbl], 0) - hud._secondary_model_opt.set_item_metadata(0, s_model) - hud._secondary_model_opt.disabled = true - return + EvoHudSettingsModals.refresh_decision_model_selectors(hud) - hud._primary_model_opt.disabled = false - hud._secondary_model_opt.disabled = false - - var p_idx := 0 - var s_idx := 0 - for i in range(decision_models.size()): - var m_tag := str(decision_models[i]) - hud._primary_model_opt.add_item(m_tag, i) - hud._primary_model_opt.set_item_metadata(i, m_tag) - if m_tag == p_model: - p_idx = i - - hud._secondary_model_opt.add_item(m_tag, i) - hud._secondary_model_opt.set_item_metadata(i, m_tag) - if m_tag == s_model: - s_idx = i - - hud._primary_model_opt.select(p_idx) - hud._secondary_model_opt.select(s_idx) diff --git a/src/ui/lab/lab_strategy_editor.gd b/src/ui/lab/lab_strategy_editor.gd new file mode 100644 index 0000000..3ed4d29 --- /dev/null +++ b/src/ui/lab/lab_strategy_editor.gd @@ -0,0 +1,588 @@ +class_name EvoLabStrategyEditor +extends RefCounted + + +static func build_strategy_form(modal: Control, form_vbox: VBoxContainer) -> void: + modal._doctrine_row = HBoxContainer.new() + modal._doctrine_row.add_theme_constant_override("separation", 8) + form_vbox.add_child(modal._doctrine_row) + + modal._doctrine_lbl = Label.new() + modal._doctrine_lbl.custom_minimum_size = Vector2(110, 0) + modal._doctrine_row.add_child(modal._doctrine_lbl) + + modal.doctrine_edit = TextEdit.new() + modal.doctrine_edit.custom_minimum_size = Vector2(0, 52) + modal.doctrine_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + modal.doctrine_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + modal._doctrine_row.add_child(modal.doctrine_edit) + + modal._strategy_emblem_row = HBoxContainer.new() + modal._strategy_emblem_row.add_theme_constant_override("separation", 10) + form_vbox.add_child(modal._strategy_emblem_row) + + modal._strategy_icon_lbl = Label.new() + modal._strategy_icon_lbl.custom_minimum_size = Vector2(110, 0) + modal._strategy_emblem_row.add_child(modal._strategy_icon_lbl) + + modal.strategy_icon_option = OptionButton.new() + modal.strategy_icon_option.custom_minimum_size = Vector2(90, 0) + for i in range(modal.STRATEGY_ICONS.size()): + modal.strategy_icon_option.add_item(modal.STRATEGY_ICONS[i], i) + modal.strategy_icon_option.set_item_metadata(i, modal.STRATEGY_ICONS[i]) + modal._strategy_emblem_row.add_child(modal.strategy_icon_option) + + modal._strategy_color_lbl = Label.new() + modal._strategy_emblem_row.add_child(modal._strategy_color_lbl) + + modal.strategy_color_picker = ColorPickerButton.new() + modal.strategy_color_picker.custom_minimum_size = Vector2(56, 28) + modal.strategy_color_picker.edit_alpha = false + modal.strategy_color_picker.color = Color("#10B981") + modal._strategy_emblem_row.add_child(modal.strategy_color_picker) + + for swatch_hex in modal.STRATEGY_SWATCH_COLORS: + var swatch_btn := Button.new() + swatch_btn.custom_minimum_size = Vector2(22, 22) + var col := Color(swatch_hex) + swatch_btn.add_theme_stylebox_override("normal", EvoUiStyle.make_button_style(col, col.darkened(0.35), 1, 2, 2)) + swatch_btn.add_theme_stylebox_override("hover", EvoUiStyle.make_button_style(col.lightened(0.15), EvoUiStyle.COLOR_GOLD_BRIGHT, 2, 2, 2)) + swatch_btn.pressed.connect(func() -> void: modal.strategy_color_picker.color = col) + modal._strategy_emblem_row.add_child(swatch_btn) + + modal._strategy_traits_card = PanelContainer.new() + modal._strategy_traits_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_PARCHMENT_BORDER, 10)) + form_vbox.add_child(modal._strategy_traits_card) + + var strat_traits_outer := VBoxContainer.new() + strat_traits_outer.add_theme_constant_override("separation", 6) + modal._strategy_traits_card.add_child(strat_traits_outer) + + modal._strategy_traits_title_lbl = Label.new() + modal._strategy_traits_title_lbl.add_theme_font_size_override("font_size", 14) + modal._strategy_traits_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + strat_traits_outer.add_child(modal._strategy_traits_title_lbl) + + modal.strategy_traits_box = VBoxContainer.new() + modal.strategy_traits_box.add_theme_constant_override("separation", 4) + strat_traits_outer.add_child(modal.strategy_traits_box) + + modal._strategy_vector_card = PanelContainer.new() + modal._strategy_vector_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#f4e2b8"), EvoUiStyle.COLOR_GOLD_BEVEL, 10)) + form_vbox.add_child(modal._strategy_vector_card) + + var strat_vec_vbox := VBoxContainer.new() + strat_vec_vbox.add_theme_constant_override("separation", 4) + modal._strategy_vector_card.add_child(strat_vec_vbox) + + modal._strategy_vector_title_lbl = Label.new() + modal._strategy_vector_title_lbl.add_theme_font_size_override("font_size", 14) + modal._strategy_vector_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + strat_vec_vbox.add_child(modal._strategy_vector_title_lbl) + + modal.strategy_vector_label = Label.new() + modal.strategy_vector_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + modal.strategy_vector_label.add_theme_font_size_override("font_size", 12) + modal.strategy_vector_label.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) + strat_vec_vbox.add_child(modal.strategy_vector_label) + + +static func build_matchup_and_inject_rows(modal: Control, form_vbox: VBoxContainer) -> void: + modal._strategy_matchup_row = HBoxContainer.new() + modal._strategy_matchup_row.add_theme_constant_override("separation", 10) + form_vbox.add_child(modal._strategy_matchup_row) + + modal.use_strategy_a_button = Button.new() + modal.use_strategy_a_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + modal.use_strategy_a_button.pressed.connect(modal._on_use_strategy_slot_pressed.bind("a")) + modal._strategy_matchup_row.add_child(modal.use_strategy_a_button) + + modal.use_strategy_b_button = Button.new() + modal.use_strategy_b_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + modal.use_strategy_b_button.pressed.connect(modal._on_use_strategy_slot_pressed.bind("b")) + modal._strategy_matchup_row.add_child(modal.use_strategy_b_button) + + modal._inject_row = HBoxContainer.new() + modal._inject_row.add_theme_constant_override("separation", 8) + form_vbox.add_child(modal._inject_row) + + modal._inject_deme_lbl = Label.new() + modal._inject_row.add_child(modal._inject_deme_lbl) + + modal.inject_deme_option = OptionButton.new() + modal.inject_deme_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + modal.inject_deme_option.item_selected.connect(modal._on_inject_deme_option_selected) + modal._inject_row.add_child(modal.inject_deme_option) + + modal.inject_button = Button.new() + modal.inject_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + modal.inject_button.pressed.connect(modal._on_inject_pressed) + modal._inject_row.add_child(modal.inject_button) + + +static func init_new_strategy(modal: Control) -> void: + modal.id_edit.text = modal._next_custom_id("strat") + modal.name_edit.text = EvoUiStyle.tr_text(modal._lang, "Estrategia Personalizada", "Custom Strategy") + modal.desc_edit.text = EvoUiStyle.tr_text( + modal._lang, + "Doctrina bio-cultural diseñada en el laboratorio para el duelo de estrategias.", + "Bio-cultural doctrine crafted in the laboratory for the strategy duel." + ) + modal.doctrine_edit.text = EvoUiStyle.tr_text( + modal._lang, + "Busca hacer crecer el linaje mediante alianzas recíprocas, comercio de excedentes y defensa firme ante agresores.", + "Grow the lineage through reciprocal alliances, surplus trade, and firm defense against aggressors." + ) + select_strategy_icon(modal, "🛡️") + modal.strategy_color_picker.color = Color("#10B981") + rebuild_strategy_trait_checks(modal) + set_checked_strategy_trait_ids(modal, ["bio-k-strategy", "bio-social-instinct", "cul-communal-conservation"]) + + +static func save_strategy(modal: Control, clean_name: String, clean_desc: String) -> void: + var strat := build_strategy_from_form(modal) + strat.name = clean_name + strat.description = clean_desc + strat.doctrine = modal.doctrine_edit.text.strip_edges() + strat.origin = "user" + if modal._world_state != null: + modal._world_state.strategies_catalog[strat.id] = strat + EvoUserSettings.save_user_catalog(modal._world_state) + modal._selected_id = strat.id + modal.id_edit.text = strat.id + modal.strategy_saved.emit(strat.id) + modal.catalog_item_saved.emit("strategy", strat.to_dict()) + modal._rebuild_catalog_list() + modal._update_labels() + update_strategy_vector_preview(modal) + modal._show_saved_feedback(strat.id) + + +static func refresh_strategy_and_inject_labels(modal: Control, is_strat: bool) -> void: + modal._doctrine_lbl.text = EvoUiStyle.tr_text(modal._lang, "Doctrina:", "Doctrine:") + modal._strategy_icon_lbl.text = EvoI18n.t(modal._lang, "lab_strat_icon") + ":" + modal._strategy_color_lbl.text = EvoI18n.t(modal._lang, "lab_strat_color") + ":" + modal._strategy_traits_title_lbl.text = EvoI18n.t(modal._lang, "lab_strat_traits") + modal._strategy_vector_title_lbl.text = EvoI18n.t(modal._lang, "lab_strat_vector") + modal.use_strategy_a_button.text = EvoI18n.t(modal._lang, "lab_use_strat_a") + modal.use_strategy_b_button.text = EvoI18n.t(modal._lang, "lab_use_strat_b") + + modal._doctrine_row.visible = is_strat + modal._strategy_emblem_row.visible = is_strat + modal._strategy_traits_card.visible = is_strat + modal._strategy_vector_card.visible = is_strat + modal._strategy_matchup_row.visible = is_strat + modal._inject_row.visible = (modal._current_tab in ["biological", "cultural"]) + + modal._inject_deme_lbl.text = EvoUiStyle.tr_text(modal._lang, "Aldea:", "Village:") + modal.inject_button.text = EvoUiStyle.tr_text(modal._lang, "💉 Inyectar en Aldea", "💉 Inject into Village") + modal.inject_button.disabled = ( + modal._selected_deme_id == "" + or modal._world_state == null + or not modal._world_state.demes.has(modal._selected_deme_id) + ) + + +static func rebuild_strategy_trait_checks(modal: Control) -> void: + if modal.strategy_traits_box == null: + return + for child in modal.strategy_traits_box.get_children(): + child.queue_free() + modal.strategy_trait_checks.clear() + + var traits_cat: Dictionary = modal._world_state.traits_catalog if modal._world_state != null else EvoCatalog.create_base_traits_catalog(modal._lang) + + var bio_hdr := Label.new() + bio_hdr.text = "🧬 " + EvoI18n.t(modal._lang, "panel.traits_bio") + bio_hdr.add_theme_font_size_override("font_size", 13) + bio_hdr.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + modal.strategy_traits_box.add_child(bio_hdr) + + for tid in traits_cat.keys(): + var t: EvoTypes.TraitDef = traits_cat[tid] + if t.category != "biological": + continue + var cb := make_strategy_trait_checkbox(modal, t) + modal.strategy_trait_checks[t.id] = cb + modal.strategy_traits_box.add_child(cb) + + var cul_hdr := Label.new() + cul_hdr.text = "🔥 " + EvoI18n.t(modal._lang, "panel.traits_cul") + cul_hdr.add_theme_font_size_override("font_size", 13) + cul_hdr.add_theme_color_override("font_color", EvoUiStyle.COLOR_PURPLE_CULTURE) + modal.strategy_traits_box.add_child(cul_hdr) + + for tid in traits_cat.keys(): + var t: EvoTypes.TraitDef = traits_cat[tid] + if t.category != "cultural": + continue + var cb := make_strategy_trait_checkbox(modal, t) + modal.strategy_trait_checks[t.id] = cb + modal.strategy_traits_box.add_child(cb) + + +static func make_strategy_trait_checkbox(modal: Control, t: EvoTypes.TraitDef) -> CheckBox: + var cb := CheckBox.new() + var t_name := EvoI18n.trait_name(modal._lang, t) + var summary := format_trait_vector_summary(t.vector) + var culture_tag := "" + if t.enables_cultural_transmission: + culture_tag = " [★ Cultura]" if modal._lang != "en" else " [★ Culture]" + cb.text = "%s%s (%s)" % [t_name, culture_tag, summary] + cb.tooltip_text = EvoI18n.trait_description(modal._lang, t) + cb.toggled.connect(modal._on_strategy_trait_toggled.bind(t.id)) + return cb + + +static func format_trait_vector_summary(v: EvoTypes.BiophysicalVector) -> String: + var parts: Array[String] = [] + if not is_zero_approx(v.metabolic_cost): + parts.append("Cost %+.2f" % v.metabolic_cost) + if not is_zero_approx(v.extraction_efficiency): + parts.append("Ext %+.2f" % v.extraction_efficiency) + if not is_zero_approx(v.ecological_impact): + parts.append("Eco %+.2f" % v.ecological_impact) + if not is_zero_approx(v.spatial_mobility): + parts.append("Mob %+.2f" % v.spatial_mobility) + if not is_zero_approx(v.social_cohesion): + parts.append("Soc %+.2f" % v.social_cohesion) + return ", ".join(parts) if not parts.is_empty() else "0.00" + + +static func on_strategy_trait_toggled(modal: Control, is_checked: bool, trait_id: String) -> void: + if modal._updating_strategy_checks: + return + var traits_cat: Dictionary = modal._world_state.traits_catalog if modal._world_state != null else {} + modal._updating_strategy_checks = true + + if is_checked: + if trait_id == "bio-r-strategy" and modal.strategy_trait_checks.has("bio-k-strategy"): + (modal.strategy_trait_checks["bio-k-strategy"] as CheckBox).button_pressed = false + elif trait_id == "bio-k-strategy" and modal.strategy_trait_checks.has("bio-r-strategy"): + (modal.strategy_trait_checks["bio-r-strategy"] as CheckBox).button_pressed = false + + if traits_cat.has(trait_id): + var tr: EvoTypes.TraitDef = traits_cat[trait_id] + for ex_id in tr.mutually_exclusive_with: + if modal.strategy_trait_checks.has(ex_id): + (modal.strategy_trait_checks[ex_id] as CheckBox).button_pressed = false + for other_id in traits_cat.keys(): + var other_tr: EvoTypes.TraitDef = traits_cat[other_id] + if other_tr.mutually_exclusive_with.has(trait_id) and modal.strategy_trait_checks.has(other_id): + (modal.strategy_trait_checks[other_id] as CheckBox).button_pressed = false + + if tr.category == "cultural" and not has_checked_cultural_capacity(modal, traits_cat): + var enabled_any := false + for bid in ["bio-social-instinct", "bio-encephalization"]: + if modal.strategy_trait_checks.has(bid): + var can_enable := not traits_cat.has(bid) or (traits_cat[bid] as EvoTypes.TraitDef).enables_cultural_transmission + if can_enable: + (modal.strategy_trait_checks[bid] as CheckBox).button_pressed = true + enabled_any = true + break + if not enabled_any: + for bid in traits_cat.keys(): + var btr: EvoTypes.TraitDef = traits_cat[bid] + if btr.category == "biological" and btr.enables_cultural_transmission and modal.strategy_trait_checks.has(bid): + (modal.strategy_trait_checks[bid] as CheckBox).button_pressed = true + break + else: + var is_culture_enabler := false + if traits_cat.has(trait_id): + var tr: EvoTypes.TraitDef = traits_cat[trait_id] + is_culture_enabler = (tr.category == "biological" and tr.enables_cultural_transmission) + else: + is_culture_enabler = trait_id in ["bio-social-instinct", "bio-encephalization"] + if is_culture_enabler and not has_checked_cultural_capacity(modal, traits_cat): + for cid in modal.strategy_trait_checks.keys(): + var is_cul := str(cid).begins_with("cul-") + if traits_cat.has(cid): + is_cul = (traits_cat[cid] as EvoTypes.TraitDef).category == "cultural" + if is_cul: + (modal.strategy_trait_checks[cid] as CheckBox).button_pressed = false + + modal._updating_strategy_checks = false + update_strategy_vector_preview(modal) + + +static func has_checked_cultural_capacity(modal: Control, traits_cat: Dictionary) -> bool: + for tid in modal.strategy_trait_checks.keys(): + var cb: CheckBox = modal.strategy_trait_checks[tid] + if not cb.button_pressed: + continue + if traits_cat.has(tid): + var tr: EvoTypes.TraitDef = traits_cat[tid] + if tr.category == "biological" and tr.enables_cultural_transmission: + return true + elif tid in ["bio-social-instinct", "bio-encephalization"]: + return true + return false + + +static func get_checked_strategy_trait_ids(modal: Control) -> Array[String]: + var res: Array[String] = [] + for tid in modal.strategy_trait_checks.keys(): + if (modal.strategy_trait_checks[tid] as CheckBox).button_pressed: + res.append(str(tid)) + return res + + +static func set_checked_strategy_trait_ids(modal: Control, trait_ids: Array) -> void: + modal._updating_strategy_checks = true + for tid in modal.strategy_trait_checks.keys(): + (modal.strategy_trait_checks[tid] as CheckBox).button_pressed = trait_ids.has(tid) + modal._updating_strategy_checks = false + update_strategy_vector_preview(modal) + + +static func are_traits_mutually_exclusive(tid_a: String, tid_b: String, traits_cat: Dictionary) -> bool: + if (tid_a == "bio-r-strategy" and tid_b == "bio-k-strategy") or (tid_a == "bio-k-strategy" and tid_b == "bio-r-strategy"): + return true + if traits_cat.has(tid_a) and (traits_cat[tid_a] as EvoTypes.TraitDef).mutually_exclusive_with.has(tid_b): + return true + if traits_cat.has(tid_b) and (traits_cat[tid_b] as EvoTypes.TraitDef).mutually_exclusive_with.has(tid_a): + return true + return false + + +static func build_strategy_from_form(modal: Control) -> EvoTypes.StrategyDef: + var strat := EvoTypes.StrategyDef.new() + strat.id = modal._resolve_target_id("strat") + strat.name = modal.name_edit.text.strip_edges() + if strat.name == "": + strat.name = "Custom Strategy" + strat.description = modal.desc_edit.text.strip_edges() + strat.doctrine = modal.doctrine_edit.text.strip_edges() + strat.icon = get_selected_strategy_icon(modal) + strat.color = "#" + modal.strategy_color_picker.color.to_html(false).to_upper() + + var traits_cat: Dictionary = modal._world_state.traits_catalog if modal._world_state != null else {} + var bio_ids: Array[String] = [] + var cul_ids: Array[String] = [] + var accepted_ids: Array[String] = [] + for tid_var in modal.strategy_trait_checks.keys(): + var tid := str(tid_var) + var cb: CheckBox = modal.strategy_trait_checks[tid_var] + if not cb.button_pressed: + continue + var has_conflict := false + for kept_id in accepted_ids: + if are_traits_mutually_exclusive(tid, kept_id, traits_cat): + has_conflict = true + break + if has_conflict: + continue + accepted_ids.append(tid) + var is_cul := tid.begins_with("cul-") + if traits_cat.has(tid): + is_cul = (traits_cat[tid] as EvoTypes.TraitDef).category == "cultural" + if is_cul: + cul_ids.append(tid) + else: + bio_ids.append(tid) + + if not cul_ids.is_empty(): + var has_cap := false + for bid in bio_ids: + if traits_cat.has(bid): + if (traits_cat[bid] as EvoTypes.TraitDef).enables_cultural_transmission: + has_cap = true + break + elif bid in ["bio-social-instinct", "bio-encephalization"]: + has_cap = true + break + if not has_cap: + var fallback_bio := "" + for cand in ["bio-social-instinct", "bio-encephalization"]: + if not traits_cat.has(cand) or (traits_cat[cand] as EvoTypes.TraitDef).enables_cultural_transmission: + fallback_bio = cand + break + if fallback_bio == "": + for tid in traits_cat.keys(): + var t: EvoTypes.TraitDef = traits_cat[tid] + if t.category == "biological" and t.enables_cultural_transmission: + fallback_bio = t.id + break + if fallback_bio != "" and not bio_ids.has(fallback_bio): + bio_ids.append(fallback_bio) + elif fallback_bio == "": + cul_ids.clear() + + strat.biological_trait_ids = bio_ids + strat.cultural_trait_ids = cul_ids + return strat + + +static func update_strategy_vector_preview(modal: Control) -> void: + if modal.strategy_vector_label == null: + return + var temp_strat := build_strategy_from_form(modal) + var traits_cat: Dictionary = modal._world_state.traits_catalog if modal._world_state != null else EvoCatalog.create_base_traits_catalog(modal._lang) + var vec := EvoCatalog.compute_strategy_vector(temp_strat, traits_cat) + modal.strategy_vector_label.text = ( + "%s: %+.2f | %s: %+.2f | %s: %+.2f\n%s: %+.2f | %s: %+.2f | %s: %.2f" % [ + EvoI18n.t(modal._lang, "axis.metabolic_cost"), vec.metabolic_cost, + EvoI18n.t(modal._lang, "axis.extraction_efficiency"), vec.extraction_efficiency, + EvoI18n.t(modal._lang, "axis.ecological_impact"), vec.ecological_impact, + EvoI18n.t(modal._lang, "axis.spatial_mobility"), vec.spatial_mobility, + EvoI18n.t(modal._lang, "axis.social_cohesion"), vec.social_cohesion, + EvoI18n.t(modal._lang, "axis.transmission_threshold"), vec.transmission_threshold + ] + ) + + +static func select_strategy_icon(modal: Control, icon: String) -> void: + if modal.strategy_icon_option == null: + return + for i in range(modal.strategy_icon_option.item_count): + if modal.strategy_icon_option.get_item_text(i) == icon: + modal.strategy_icon_option.select(i) + return + if modal.strategy_icon_option.item_count > 0: + modal.strategy_icon_option.select(0) + + +static func get_selected_strategy_icon(modal: Control) -> String: + if modal.strategy_icon_option == null: + return "🛡️" + var idx: int = modal.strategy_icon_option.selected + if idx >= 0 and idx < modal.strategy_icon_option.item_count: + return modal.strategy_icon_option.get_item_text(idx) + return "🛡️" + + +static func is_strategy_form_dirty(modal: Control, existing: EvoTypes.StrategyDef) -> bool: + var form_strat := build_strategy_from_form(modal) + if form_strat.id != existing.id or form_strat.name != existing.name or form_strat.description != existing.description or form_strat.doctrine != existing.doctrine: + return true + if form_strat.icon != existing.icon or form_strat.color.to_upper() != existing.color.to_upper(): + return true + if form_strat.biological_trait_ids != existing.biological_trait_ids or form_strat.cultural_trait_ids != existing.cultural_trait_ids: + return true + return false + + +static func on_use_strategy_slot_pressed(modal: Control, slot: String) -> void: + if modal._current_tab != "strategy": + return + var target_id: String = modal.id_edit.text.strip_edges() + if target_id == "": + target_id = modal._selected_id + if target_id == "" or modal._world_state == null or not modal._world_state.strategies_catalog.has(target_id) or is_strategy_form_dirty(modal, modal._world_state.strategies_catalog[target_id]): + modal._save_current_item() + if modal._world_state != null: + if slot == "a": + modal._world_state.strategy_a_id = modal._selected_id + elif slot == "b": + modal._world_state.strategy_b_id = modal._selected_id + modal.strategy_assigned.emit(slot, modal._selected_id) + modal._feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) + modal._feedback_lbl.text = "%s: %s" % [ + EvoI18n.t(modal._lang, "lab_use_strat_a" if slot == "a" else "lab_use_strat_b"), + modal._selected_id + ] + + +static func on_catalog_strategy_clicked(modal: Control, strategy_id: String) -> void: + if modal._world_state == null or not modal._world_state.strategies_catalog.has(strategy_id): + return + var strat: EvoTypes.StrategyDef = modal._world_state.strategies_catalog[strategy_id] + modal._selected_id = strat.id + modal.id_edit.text = strat.id + modal.name_edit.text = EvoI18n.strategy_name(modal._lang, strat) + modal.desc_edit.text = EvoI18n.strategy_description(modal._lang, strat) + modal.doctrine_edit.text = EvoI18n.strategy_doctrine(modal._lang, strat) + select_strategy_icon(modal, strat.icon) + modal.strategy_color_picker.color = Color.from_string(strat.color, Color("#10B981")) + + var combined_traits: Array[String] = [] + for bid in strat.biological_trait_ids: + combined_traits.append(bid) + for cid in strat.cultural_trait_ids: + combined_traits.append(cid) + set_checked_strategy_trait_ids(modal, combined_traits) + + modal._feedback_lbl.text = "" + modal._update_labels() + modal._rebuild_catalog_list() + update_strategy_vector_preview(modal) + + +static func rebuild_inject_deme_options(modal: Control) -> void: + if modal.inject_deme_option == null: + return + modal.inject_deme_option.clear() + if modal._world_state == null or modal._world_state.demes.is_empty(): + return + var idx := 0 + var selected_idx := 0 + for did in modal._world_state.demes.keys(): + var d: EvoTypes.Deme = modal._world_state.demes[did] + modal.inject_deme_option.add_item(d.name, idx) + modal.inject_deme_option.set_item_metadata(idx, d.id) + if d.id == modal._selected_deme_id: + selected_idx = idx + idx += 1 + modal.inject_deme_option.select(selected_idx) + modal._selected_deme_id = str(modal.inject_deme_option.get_item_metadata(selected_idx)) + + +static func on_inject_deme_option_selected(modal: Control, index: int) -> void: + if modal.inject_deme_option == null or index < 0 or index >= modal.inject_deme_option.item_count: + return + modal._selected_deme_id = str(modal.inject_deme_option.get_item_metadata(index)) + modal._update_labels() + + +static func is_selected_builtin_trait_unmodified(modal: Control) -> bool: + if modal._world_state == null or modal._selected_id == "" or not modal._world_state.traits_catalog.has(modal._selected_id): + return false + var existing: EvoTypes.TraitDef = modal._world_state.traits_catalog[modal._selected_id] + if existing.origin not in ["catalog", "base"]: + return false + var target_id: String = modal.id_edit.text.strip_edges() + if target_id != "" and target_id != existing.id: + return false + var clean_name: String = modal.name_edit.text.strip_edges() + if clean_name != EvoI18n.trait_name(modal._lang, existing).strip_edges() and clean_name != existing.name.strip_edges(): + return false + var clean_desc: String = modal.desc_edit.text.strip_edges() + if clean_desc != EvoI18n.trait_description(modal._lang, existing).strip_edges() and clean_desc != existing.description.strip_edges(): + return false + if modal._current_tab == "biological" and modal.enables_culture_check.button_pressed != existing.enables_cultural_transmission: + return false + var form_vec: EvoTypes.BiophysicalVector = modal._read_vector_from_sliders() + var ex_vec := existing.vector + return ( + is_equal_approx(form_vec.metabolic_cost, snappedf(ex_vec.metabolic_cost, 0.01)) + and is_equal_approx(form_vec.extraction_efficiency, snappedf(ex_vec.extraction_efficiency, 0.01)) + and is_equal_approx(form_vec.ecological_impact, snappedf(ex_vec.ecological_impact, 0.01)) + and is_equal_approx(form_vec.spatial_mobility, snappedf(ex_vec.spatial_mobility, 0.01)) + and is_equal_approx(form_vec.social_cohesion, snappedf(ex_vec.social_cohesion, 0.01)) + and is_equal_approx(form_vec.transmission_threshold, snappedf(ex_vec.transmission_threshold, 0.01)) + ) + + +static func on_inject_pressed(modal: Control) -> void: + if modal._current_tab not in ["biological", "cultural"] or modal._selected_deme_id == "": + return + if modal._current_tab == "cultural" and modal._world_state != null and modal._world_state.demes.has(modal._selected_deme_id): + var target_deme: EvoTypes.Deme = modal._world_state.demes[modal._selected_deme_id] + if not EvoCatalog.deme_has_cultural_capacity(target_deme, modal._world_state.traits_catalog): + modal._feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GOLD_WARN) + modal._feedback_lbl.text = EvoUiStyle.tr_text( + modal._lang, + "⚠ La aldea seleccionada carece de capacidad cultural (requiere Encefalización o Sociabilidad Instintiva).", + "⚠ Selected village lacks cultural capacity (requires Encephalization or Instinctive Sociability)." + ) + return + if not is_selected_builtin_trait_unmodified(modal): + modal._save_current_item() + modal._feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) + modal.inject_trait_requested.emit(modal._selected_deme_id, modal._selected_id) + modal._update_labels() + modal._feedback_lbl.text = EvoUiStyle.tr_text( + modal._lang, + "💉 Rasgo '%s' inyectado en la aldea seleccionada." % modal.name_edit.text, + "💉 Trait '%s' injected into selected village." % modal.name_edit.text + ) diff --git a/src/ui/lab/lab_strategy_editor.gd.uid b/src/ui/lab/lab_strategy_editor.gd.uid new file mode 100644 index 0000000..20d2555 --- /dev/null +++ b/src/ui/lab/lab_strategy_editor.gd.uid @@ -0,0 +1 @@ +uid://b64ottawo3jig diff --git a/src/ui/lab/lab_thermo_form.gd b/src/ui/lab/lab_thermo_form.gd new file mode 100644 index 0000000..8336b6d --- /dev/null +++ b/src/ui/lab/lab_thermo_form.gd @@ -0,0 +1,272 @@ +class_name EvoLabThermoForm +extends RefCounted + + +static func build_thermo_form(modal: Control, form_vbox: VBoxContainer) -> void: + modal.enables_culture_check = CheckBox.new() + form_vbox.add_child(modal.enables_culture_check) + + # 6 Sliders box (for traits) + modal._sliders_card = PanelContainer.new() + modal._sliders_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_PARCHMENT_BORDER, 10)) + form_vbox.add_child(modal._sliders_card) + + var sliders_vbox := VBoxContainer.new() + sliders_vbox.add_theme_constant_override("separation", 6) + modal._sliders_card.add_child(sliders_vbox) + + for s_key in modal.SLIDER_KEYS: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + sliders_vbox.add_child(row) + + var t_lbl := Label.new() + t_lbl.custom_minimum_size = Vector2(210, 0) + t_lbl.add_theme_font_size_override("font_size", 12) + row.add_child(t_lbl) + modal._slider_title_labels[s_key] = t_lbl + + var slider := HSlider.new() + slider.min_value = 0.0 if s_key == "transmission_threshold" else -1.0 + slider.max_value = 1.0 + slider.step = 0.01 + slider.value = 0.20 if s_key == "transmission_threshold" else 0.0 + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slider.value_changed.connect(modal._on_slider_value_changed.bind(s_key)) + row.add_child(slider) + modal.sliders[s_key] = slider + + var v_lbl := Label.new() + v_lbl.custom_minimum_size = Vector2(54, 0) + v_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + v_lbl.add_theme_font_size_override("font_size", 12) + row.add_child(v_lbl) + modal._slider_val_labels[s_key] = v_lbl + + # Live Thermodynamic Meter (for traits & decisions) + modal._thermo_card = PanelContainer.new() + modal._thermo_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#f4e2b8"), EvoUiStyle.COLOR_GOLD_BEVEL, 10)) + form_vbox.add_child(modal._thermo_card) + + var thermo_vbox := VBoxContainer.new() + thermo_vbox.add_theme_constant_override("separation", 6) + modal._thermo_card.add_child(thermo_vbox) + + var thermo_top := HBoxContainer.new() + thermo_vbox.add_child(thermo_top) + + modal._thermo_title_lbl = Label.new() + modal._thermo_title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + modal._thermo_title_lbl.add_theme_font_size_override("font_size", 14) + modal._thermo_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) + thermo_top.add_child(modal._thermo_title_lbl) + + modal.auto_balance_button = Button.new() + modal.auto_balance_button.pressed.connect(modal._auto_balance_vector) + thermo_top.add_child(modal.auto_balance_button) + + modal.balance_bar = ProgressBar.new() + modal.balance_bar.min_value = 0.0 + modal.balance_bar.max_value = 1.8 + modal.balance_bar.show_percentage = false + modal.balance_bar.custom_minimum_size = Vector2(0, 14) + thermo_vbox.add_child(modal.balance_bar) + + modal._thermo_metrics_lbl = Label.new() + modal._thermo_metrics_lbl.add_theme_font_size_override("font_size", 12) + modal._thermo_metrics_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) + thermo_vbox.add_child(modal._thermo_metrics_lbl) + + modal.balance_status_label = Label.new() + modal.balance_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + modal.balance_status_label.add_theme_font_size_override("font_size", 12) + thermo_vbox.add_child(modal.balance_status_label) + + +static func init_new_trait_or_decision(modal: Control) -> void: + if modal._current_tab == "biological": + modal.id_edit.text = modal._next_custom_id("bio") + modal.name_edit.text = EvoUiStyle.tr_text(modal._lang, "Adaptación Metabólica", "Metabolic Adaptation") + modal.desc_edit.text = EvoUiStyle.tr_text( + modal._lang, + "Rasgo biológico experimental diseñado en el laboratorio.", + "Experimental biological trait crafted in the lab." + ) + modal.enables_culture_check.button_pressed = false + set_vector_to_sliders(modal, EvoTypes.BiophysicalVector.new(0.15, 0.30, 0.05, 0.20, 0.10, 0.20)) + else: + modal.id_edit.text = modal._next_custom_id("cul") + modal.name_edit.text = EvoUiStyle.tr_text(modal._lang, "Tradición Artesanal", "Artisanal Tradition") + modal.desc_edit.text = EvoUiStyle.tr_text( + modal._lang, + "Práctica cultural transmitida socialmente entre generaciones.", + "Socially transmitted cultural practice across generations." + ) + set_vector_to_sliders(modal, EvoTypes.BiophysicalVector.new(0.20, 0.35, 0.15, 0.10, 0.35, 0.45)) + + +static func save_trait_or_decision(modal: Control, clean_name: String, clean_desc: String) -> void: + var raw_vec := read_vector_from_sliders(modal) + var free_mode: bool = modal._world_state.free_sandbox_mode if modal._world_state != null else false + var final_vec := raw_vec if free_mode else EvoUiStyle.balance_vector(raw_vec) + set_vector_to_sliders(modal, final_vec) + update_thermodynamics_preview(modal) + + var prefix := "bio" if modal._current_tab == "biological" else "cul" + var tr := EvoTypes.TraitDef.new() + tr.id = modal._resolve_target_id(prefix) + tr.name = clean_name + tr.description = clean_desc + tr.category = modal._current_tab + tr.origin = "user" + tr.vector = final_vec + tr.enables_cultural_transmission = modal.enables_culture_check.button_pressed if modal._current_tab == "biological" else false + if modal._world_state != null and modal._world_state.traits_catalog.has(tr.id): + var prev_tr: EvoTypes.TraitDef = modal._world_state.traits_catalog[tr.id] + tr.mutually_exclusive_with = prev_tr.mutually_exclusive_with.duplicate() + if modal._world_state != null: + modal._world_state.traits_catalog[tr.id] = tr + EvoUserSettings.save_user_catalog(modal._world_state) + modal._selected_id = tr.id + modal.id_edit.text = tr.id + modal._rebuild_strategy_trait_checks() + modal.catalog_item_saved.emit("trait", tr.to_dict()) + modal._rebuild_catalog_list() + modal._update_labels() + modal._show_saved_feedback(tr.id) + + +static func refresh_thermo_labels(modal: Control, is_strat: bool) -> void: + modal.enables_culture_check.text = EvoUiStyle.tr_text( + modal._lang, + "Habilita transmisión cultural (enablesCulturalTransmission)", + "Enables cultural transmission (enablesCulturalTransmission)" + ) + + modal.enables_culture_check.visible = (modal._current_tab == "biological") + modal._sliders_card.visible = not is_strat + modal._thermo_card.visible = not is_strat + + var slider_names_es := { + "metabolic_cost": "Coste Metabólico (-1 a +1)", + "extraction_efficiency": "Eficiencia de Extracción (-1 a +1)", + "ecological_impact": "Impacto Ecológico (-1 a +1)", + "spatial_mobility": "Movilidad Espacial (-1 a +1)", + "social_cohesion": "Cohesión Social (-1 a +1)", + "transmission_threshold": "Umbral de Transmisión (0 a +1)" + } + var slider_names_en := { + "metabolic_cost": "Metabolic Cost (-1 to +1)", + "extraction_efficiency": "Extraction Efficiency (-1 to +1)", + "ecological_impact": "Ecological Impact (-1 to +1)", + "spatial_mobility": "Spatial Mobility (-1 to +1)", + "social_cohesion": "Social Cohesion (-1 to +1)", + "transmission_threshold": "Transmission Threshold (0 to +1)" + } + for s_key in modal.SLIDER_KEYS: + var lbl: Label = modal._slider_title_labels[s_key] + lbl.text = str(slider_names_en[s_key]) if modal._lang == "en" else str(slider_names_es[s_key]) + + modal._thermo_title_lbl.text = EvoUiStyle.tr_text( + modal._lang, + "⚖️ Medidor de Equilibrio Termodinámico en Vivo", + "⚖️ Live Thermodynamic Balance Meter" + ) + modal.auto_balance_button.text = EvoUiStyle.tr_text(modal._lang, "Auto-Equilibrar Vector", "Auto-Balance Vector") + + +static func auto_balance_vector(modal: Control) -> void: + var vec := read_vector_from_sliders(modal) + var balanced := EvoUiStyle.balance_vector(vec) + set_vector_to_sliders(modal, balanced) + update_thermodynamics_preview(modal) + + +static func on_catalog_trait_clicked(modal: Control, trait_id: String) -> void: + if modal._world_state == null or not modal._world_state.traits_catalog.has(trait_id): + return + var tr: EvoTypes.TraitDef = modal._world_state.traits_catalog[trait_id] + modal._selected_id = tr.id + modal.id_edit.text = tr.id + modal.name_edit.text = EvoI18n.trait_name(modal._lang, tr) + modal.desc_edit.text = EvoI18n.trait_description(modal._lang, tr) + modal.enables_culture_check.button_pressed = tr.enables_cultural_transmission + set_vector_to_sliders(modal, tr.vector) + modal._feedback_lbl.text = "" + modal._update_labels() + modal._rebuild_catalog_list() + update_thermodynamics_preview(modal) + + +static func set_vector_to_sliders(modal: Control, vec: EvoTypes.BiophysicalVector) -> void: + modal._updating_sliders = true + (modal.sliders["metabolic_cost"] as HSlider).value = vec.metabolic_cost + (modal.sliders["extraction_efficiency"] as HSlider).value = vec.extraction_efficiency + (modal.sliders["ecological_impact"] as HSlider).value = vec.ecological_impact + (modal.sliders["spatial_mobility"] as HSlider).value = vec.spatial_mobility + (modal.sliders["social_cohesion"] as HSlider).value = vec.social_cohesion + (modal.sliders["transmission_threshold"] as HSlider).value = vec.transmission_threshold + modal._updating_sliders = false + refresh_slider_value_labels(modal) + + +static func read_vector_from_sliders(modal: Control) -> EvoTypes.BiophysicalVector: + return EvoTypes.BiophysicalVector.new( + snappedf((modal.sliders["metabolic_cost"] as HSlider).value, 0.01), + snappedf((modal.sliders["extraction_efficiency"] as HSlider).value, 0.01), + snappedf((modal.sliders["ecological_impact"] as HSlider).value, 0.01), + snappedf((modal.sliders["spatial_mobility"] as HSlider).value, 0.01), + snappedf((modal.sliders["social_cohesion"] as HSlider).value, 0.01), + snappedf((modal.sliders["transmission_threshold"] as HSlider).value, 0.01) + ) + + +static func refresh_slider_value_labels(modal: Control) -> void: + for s_key in modal.SLIDER_KEYS: + var val: float = (modal.sliders[s_key] as HSlider).value + var lbl: Label = modal._slider_val_labels[s_key] + lbl.text = "%+.2f" % val if s_key != "transmission_threshold" else "%.2f" % val + + +static func update_thermodynamics_preview(modal: Control) -> void: + if modal._thermo_metrics_lbl == null: + return + refresh_slider_value_labels(modal) + var vec := read_vector_from_sliders(modal) + var free_mode: bool = modal._world_state.free_sandbox_mode if modal._world_state != null else false + var eval_res := EvoUiStyle.evaluate_thermodynamics(vec, free_mode) + var a_plus: float = float(eval_res["a_plus"]) + var c_actual: float = float(eval_res["c_actual"]) + var c_req: float = float(eval_res["c_req"]) + var is_bal: bool = bool(eval_res["is_balanced"]) + var bal_vec: EvoTypes.BiophysicalVector = eval_res["balanced_vector"] + + modal.balance_bar.value = clampf(a_plus, modal.balance_bar.min_value, modal.balance_bar.max_value) + EvoUiStyle.style_progress_bar(modal.balance_bar, EvoUiStyle.COLOR_GREEN_OK if is_bal else EvoUiStyle.COLOR_AMBER_WARN) + + modal._thermo_metrics_lbl.text = EvoUiStyle.tr_text( + modal._lang, + "Ventajas Positivas (A+): %.2f | Coste Actual (C_actual): %.2f | Coste Requerido (C_req): %.2f" % [a_plus, c_actual, c_req], + "Positive Advantages (A+): %.2f | Actual Cost (C_actual): %.2f | Required Cost (C_req): %.2f" % [a_plus, c_actual, c_req] + ) + + if is_bal: + modal.balance_status_label.text = EvoUiStyle.tr_text( + modal._lang, + "✓ Cumple la Ley de Conservación Termodinámica (C_actual >= C_req).", + "✓ Complies with the Law of Thermodynamic Conservation (C_actual >= C_req)." + ) + modal.balance_status_label.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) + else: + modal.balance_status_label.text = EvoUiStyle.tr_text( + modal._lang, + "⚠ Exceso de ventaja sin coste: al guardar se auto-equilibrará a Coste Metabólico = %+.2f, Impacto Eco = %+.2f." % [bal_vec.metabolic_cost, bal_vec.ecological_impact], + "⚠ Uncompensated advantage: saving will auto-balance to Metabolic Cost = %+.2f, Eco Impact = %+.2f." % [bal_vec.metabolic_cost, bal_vec.ecological_impact] + ) + modal.balance_status_label.add_theme_color_override("font_color", EvoUiStyle.COLOR_AMBER_WARN) + + +static func on_slider_value_changed(modal: Control, _val: float, _key: String) -> void: + if modal._updating_sliders: + return + update_thermodynamics_preview(modal) diff --git a/src/ui/lab/lab_thermo_form.gd.uid b/src/ui/lab/lab_thermo_form.gd.uid new file mode 100644 index 0000000..ed8fe44 --- /dev/null +++ b/src/ui/lab/lab_thermo_form.gd.uid @@ -0,0 +1 @@ +uid://d0d66708dfe8p diff --git a/src/ui/lab_modal.gd b/src/ui/lab_modal.gd index 22b9476..53223a6 100644 --- a/src/ui/lab_modal.gd +++ b/src/ui/lab_modal.gd @@ -5,7 +5,6 @@ signal catalog_item_saved(item_type: String, item_data: Dictionary) signal inject_trait_requested(deme_id: String, trait_id: String) signal strategy_saved(strategy_id: String) signal strategy_assigned(slot: String, strategy_id: String) -signal modal_closed() const SLIDER_KEYS: Array[String] = [ "metabolic_cost", @@ -33,28 +32,16 @@ var _updating_sliders: bool = false var _updating_strategy_checks: bool = false var _custom_counter: int = 0 -var _active_tab: String: - get: - return _current_tab - set(value): - _current_tab = value - -var _editing_id: String: - get: - return _selected_id - set(value): - _selected_id = value - # Public controls required by HUD and test suite var tab_strategy_btn: Button var tab_bio_btn: Button var tab_cul_btn: Button -var tab_dec_btn: Button var item_list: VBoxContainer var new_item_button: Button var id_edit: LineEdit var name_edit: LineEdit var desc_edit: TextEdit +var doctrine_edit: TextEdit var enables_culture_check: CheckBox var sliders: Dictionary = {} var balance_bar: ProgressBar @@ -80,9 +67,8 @@ var _form_header_lbl: Label var _id_lbl: Label var _name_lbl: Label var _desc_lbl: Label -var _archetype_row: HBoxContainer -var _archetype_lbl: Label -var _archetype_option: OptionButton +var _doctrine_row: HBoxContainer +var _doctrine_lbl: Label var _strategy_emblem_row: HBoxContainer var _strategy_icon_lbl: Label @@ -158,7 +144,7 @@ func _build_ui() -> void: _close_btn.pressed.connect(close_modal) header_hbox.add_child(_close_btn) - # 4 Tabs bar + # 3 Tabs bar var tabs_hbox := HBoxContainer.new() tabs_hbox.add_theme_constant_override("separation", 8) main_vbox.add_child(tabs_hbox) @@ -175,10 +161,6 @@ func _build_ui() -> void: tab_cul_btn.pressed.connect(_switch_tab.bind("cultural")) tabs_hbox.add_child(tab_cul_btn) - tab_dec_btn = Button.new() - tab_dec_btn.pressed.connect(_switch_tab.bind("decision")) - tabs_hbox.add_child(tab_dec_btn) - # Body split: Left catalog list + Right editor var body_hbox := HBoxContainer.new() body_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -269,169 +251,8 @@ func _build_ui() -> void: desc_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY desc_row.add_child(desc_edit) - # Strategy Emblem & Color row - _strategy_emblem_row = HBoxContainer.new() - _strategy_emblem_row.add_theme_constant_override("separation", 10) - form_vbox.add_child(_strategy_emblem_row) - - _strategy_icon_lbl = Label.new() - _strategy_icon_lbl.custom_minimum_size = Vector2(110, 0) - _strategy_emblem_row.add_child(_strategy_icon_lbl) - - strategy_icon_option = OptionButton.new() - strategy_icon_option.custom_minimum_size = Vector2(90, 0) - for i in range(STRATEGY_ICONS.size()): - strategy_icon_option.add_item(STRATEGY_ICONS[i], i) - strategy_icon_option.set_item_metadata(i, STRATEGY_ICONS[i]) - _strategy_emblem_row.add_child(strategy_icon_option) - - _strategy_color_lbl = Label.new() - _strategy_emblem_row.add_child(_strategy_color_lbl) - - strategy_color_picker = ColorPickerButton.new() - strategy_color_picker.custom_minimum_size = Vector2(56, 28) - strategy_color_picker.edit_alpha = false - strategy_color_picker.color = Color("#10B981") - _strategy_emblem_row.add_child(strategy_color_picker) - - for swatch_hex in STRATEGY_SWATCH_COLORS: - var swatch_btn := Button.new() - swatch_btn.custom_minimum_size = Vector2(22, 22) - var col := Color(swatch_hex) - swatch_btn.add_theme_stylebox_override("normal", EvoUiStyle.make_button_style(col, col.darkened(0.35), 1, 2, 2)) - swatch_btn.add_theme_stylebox_override("hover", EvoUiStyle.make_button_style(col.lightened(0.15), EvoUiStyle.COLOR_GOLD_BRIGHT, 2, 2, 2)) - swatch_btn.pressed.connect(func() -> void: strategy_color_picker.color = col) - _strategy_emblem_row.add_child(swatch_btn) - - # Strategy Traits Selection Box - _strategy_traits_card = PanelContainer.new() - _strategy_traits_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_PARCHMENT_BORDER, 10)) - form_vbox.add_child(_strategy_traits_card) - - var strat_traits_outer := VBoxContainer.new() - strat_traits_outer.add_theme_constant_override("separation", 6) - _strategy_traits_card.add_child(strat_traits_outer) - - _strategy_traits_title_lbl = Label.new() - _strategy_traits_title_lbl.add_theme_font_size_override("font_size", 14) - _strategy_traits_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - strat_traits_outer.add_child(_strategy_traits_title_lbl) - - strategy_traits_box = VBoxContainer.new() - strategy_traits_box.add_theme_constant_override("separation", 4) - strat_traits_outer.add_child(strategy_traits_box) - - # Strategy Combined Biophysical Vector Preview - _strategy_vector_card = PanelContainer.new() - _strategy_vector_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#f4e2b8"), EvoUiStyle.COLOR_GOLD_BEVEL, 10)) - form_vbox.add_child(_strategy_vector_card) - - var strat_vec_vbox := VBoxContainer.new() - strat_vec_vbox.add_theme_constant_override("separation", 4) - _strategy_vector_card.add_child(strat_vec_vbox) - - _strategy_vector_title_lbl = Label.new() - _strategy_vector_title_lbl.add_theme_font_size_override("font_size", 14) - _strategy_vector_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - strat_vec_vbox.add_child(_strategy_vector_title_lbl) - - strategy_vector_label = Label.new() - strategy_vector_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - strategy_vector_label.add_theme_font_size_override("font_size", 12) - strategy_vector_label.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - strat_vec_vbox.add_child(strategy_vector_label) - - # Trait / Decision specific controls - enables_culture_check = CheckBox.new() - form_vbox.add_child(enables_culture_check) - - _archetype_row = HBoxContainer.new() - _archetype_row.add_theme_constant_override("separation", 8) - form_vbox.add_child(_archetype_row) - - _archetype_lbl = Label.new() - _archetype_lbl.custom_minimum_size = Vector2(110, 0) - _archetype_row.add_child(_archetype_lbl) - - _archetype_option = OptionButton.new() - _archetype_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _archetype_row.add_child(_archetype_option) - - # 6 Sliders box (for traits & decisions) - _sliders_card = PanelContainer.new() - _sliders_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#fbf2de"), EvoUiStyle.COLOR_PARCHMENT_BORDER, 10)) - form_vbox.add_child(_sliders_card) - - var sliders_vbox := VBoxContainer.new() - sliders_vbox.add_theme_constant_override("separation", 6) - _sliders_card.add_child(sliders_vbox) - - for s_key in SLIDER_KEYS: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - sliders_vbox.add_child(row) - - var t_lbl := Label.new() - t_lbl.custom_minimum_size = Vector2(210, 0) - t_lbl.add_theme_font_size_override("font_size", 12) - row.add_child(t_lbl) - _slider_title_labels[s_key] = t_lbl - - var slider := HSlider.new() - slider.min_value = 0.0 if s_key == "transmission_threshold" else -1.0 - slider.max_value = 1.0 - slider.step = 0.01 - slider.value = 0.20 if s_key == "transmission_threshold" else 0.0 - slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL - slider.value_changed.connect(_on_slider_value_changed.bind(s_key)) - row.add_child(slider) - sliders[s_key] = slider - - var v_lbl := Label.new() - v_lbl.custom_minimum_size = Vector2(54, 0) - v_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - v_lbl.add_theme_font_size_override("font_size", 12) - row.add_child(v_lbl) - _slider_val_labels[s_key] = v_lbl - - # Live Thermodynamic Meter (for traits & decisions) - _thermo_card = PanelContainer.new() - _thermo_card.add_theme_stylebox_override("panel", EvoUiStyle.make_parchment_card(Color("#f4e2b8"), EvoUiStyle.COLOR_GOLD_BEVEL, 10)) - form_vbox.add_child(_thermo_card) - - var thermo_vbox := VBoxContainer.new() - thermo_vbox.add_theme_constant_override("separation", 6) - _thermo_card.add_child(thermo_vbox) - - var thermo_top := HBoxContainer.new() - thermo_vbox.add_child(thermo_top) - - _thermo_title_lbl = Label.new() - _thermo_title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _thermo_title_lbl.add_theme_font_size_override("font_size", 14) - _thermo_title_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - thermo_top.add_child(_thermo_title_lbl) - - auto_balance_button = Button.new() - auto_balance_button.pressed.connect(_auto_balance_vector) - thermo_top.add_child(auto_balance_button) - - balance_bar = ProgressBar.new() - balance_bar.min_value = 0.0 - balance_bar.max_value = 1.8 - balance_bar.show_percentage = false - balance_bar.custom_minimum_size = Vector2(0, 14) - thermo_vbox.add_child(balance_bar) - - _thermo_metrics_lbl = Label.new() - _thermo_metrics_lbl.add_theme_font_size_override("font_size", 12) - _thermo_metrics_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_INK) - thermo_vbox.add_child(_thermo_metrics_lbl) - - balance_status_label = Label.new() - balance_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - balance_status_label.add_theme_font_size_override("font_size", 12) - thermo_vbox.add_child(balance_status_label) + EvoLabStrategyEditor.build_strategy_form(self, form_vbox) + EvoLabThermoForm.build_thermo_form(self, form_vbox) # Bottom Actions var actions_hbox := HBoxContainer.new() @@ -443,43 +264,13 @@ func _build_ui() -> void: save_item_button.pressed.connect(_save_current_item) actions_hbox.add_child(save_item_button) - _strategy_matchup_row = HBoxContainer.new() - _strategy_matchup_row.add_theme_constant_override("separation", 10) - form_vbox.add_child(_strategy_matchup_row) - - use_strategy_a_button = Button.new() - use_strategy_a_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - use_strategy_a_button.pressed.connect(_on_use_strategy_slot_pressed.bind("a")) - _strategy_matchup_row.add_child(use_strategy_a_button) - - use_strategy_b_button = Button.new() - use_strategy_b_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - use_strategy_b_button.pressed.connect(_on_use_strategy_slot_pressed.bind("b")) - _strategy_matchup_row.add_child(use_strategy_b_button) - - _inject_row = HBoxContainer.new() - _inject_row.add_theme_constant_override("separation", 8) - form_vbox.add_child(_inject_row) - - _inject_deme_lbl = Label.new() - _inject_row.add_child(_inject_deme_lbl) - - inject_deme_option = OptionButton.new() - inject_deme_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL - inject_deme_option.item_selected.connect(_on_inject_deme_option_selected) - _inject_row.add_child(inject_deme_option) - - inject_button = Button.new() - inject_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - inject_button.pressed.connect(_on_inject_pressed) - _inject_row.add_child(inject_button) + EvoLabStrategyEditor.build_matchup_and_inject_rows(self, form_vbox) _feedback_lbl = Label.new() _feedback_lbl.add_theme_font_size_override("font_size", 12) _feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) form_vbox.add_child(_feedback_lbl) - _rebuild_archetype_options() _rebuild_strategy_trait_checks() _start_new_item() @@ -494,7 +285,6 @@ func setup(state: EvoTypes.WorldState, lang: String = "es", selected_deme_id: St elif _world_state != null and not _world_state.demes.has(_selected_deme_id): _selected_deme_id = str(_world_state.demes.keys()[0]) if not _world_state.demes.is_empty() else "" _feedback_lbl.text = "" - _rebuild_archetype_options() _rebuild_inject_deme_options() _rebuild_strategy_trait_checks() if _selected_id == "": @@ -533,7 +323,6 @@ func set_language(lang: String) -> void: _lang = lang if _title_label == null: return - _rebuild_archetype_options() _rebuild_inject_deme_options() var checked_ids := _get_checked_strategy_trait_ids() _rebuild_strategy_trait_checks() @@ -548,7 +337,6 @@ func set_language(lang: String) -> void: func close_modal() -> void: visible = false - modal_closed.emit() func _switch_tab(tab: String) -> void: @@ -566,46 +354,9 @@ func _start_new_item() -> void: _feedback_lbl.text = "" if _current_tab == "strategy": - id_edit.text = _next_custom_id("strat") - name_edit.text = EvoUiStyle.tr_text(_lang, "Estrategia Personalizada", "Custom Strategy") - desc_edit.text = EvoUiStyle.tr_text( - _lang, - "Doctrina bio-cultural diseñada en el laboratorio para el duelo de estrategias.", - "Bio-cultural doctrine crafted in the laboratory for the strategy duel." - ) - _select_strategy_icon("🛡️") - strategy_color_picker.color = Color("#10B981") - _rebuild_strategy_trait_checks() - _set_checked_strategy_trait_ids(["bio-k-strategy", "bio-social-instinct", "cul-communal-conservation"]) - elif _current_tab == "biological": - id_edit.text = _next_custom_id("bio") - name_edit.text = EvoUiStyle.tr_text(_lang, "Adaptación Metabólica", "Metabolic Adaptation") - desc_edit.text = EvoUiStyle.tr_text( - _lang, - "Rasgo biológico experimental diseñado en el laboratorio.", - "Experimental biological trait crafted in the lab." - ) - enables_culture_check.button_pressed = false - _set_vector_to_sliders(EvoTypes.BiophysicalVector.new(0.15, 0.30, 0.05, 0.20, 0.10, 0.20)) - elif _current_tab == "cultural": - id_edit.text = _next_custom_id("cul") - name_edit.text = EvoUiStyle.tr_text(_lang, "Tradición Artesanal", "Artisanal Tradition") - desc_edit.text = EvoUiStyle.tr_text( - _lang, - "Práctica cultural transmitida socialmente entre generaciones.", - "Socially transmitted cultural practice across generations." - ) - _set_vector_to_sliders(EvoTypes.BiophysicalVector.new(0.20, 0.35, 0.15, 0.10, 0.35, 0.45)) + EvoLabStrategyEditor.init_new_strategy(self) else: - id_edit.text = _next_custom_id("dec") - name_edit.text = EvoUiStyle.tr_text(_lang, "Estrategia Adaptativa", "Adaptive Strategy") - desc_edit.text = EvoUiStyle.tr_text( - _lang, - "Patrón conductual colectivo evaluado por las aldeas.", - "Collective behavioral pattern evaluated by villages." - ) - _select_archetype("custom") - _set_vector_to_sliders(EvoTypes.BiophysicalVector.new(0.20, 0.35, 0.10, 0.15, 0.20, 0.25)) + EvoLabThermoForm.init_new_trait_or_decision(self) _update_labels() _rebuild_catalog_list() @@ -622,79 +373,9 @@ func _save_current_item() -> void: var clean_desc := desc_edit.text.strip_edges() if _current_tab == "strategy": - var strat := _build_strategy_from_form() - strat.name = clean_name - strat.description = clean_desc - strat.origin = "user" - if _world_state != null: - _world_state.strategies_catalog[strat.id] = strat - EvoUserSettings.save_user_catalog(_world_state) - _selected_id = strat.id - id_edit.text = strat.id - strategy_saved.emit(strat.id) - catalog_item_saved.emit("strategy", strat.to_dict()) - _rebuild_catalog_list() - _update_labels() - _update_strategy_vector_preview() - _show_saved_feedback(strat.id) - return - - var raw_vec := _read_vector_from_sliders() - var free_mode := _world_state.free_sandbox_mode if _world_state != null else false - var final_vec := raw_vec if free_mode else EvoUiStyle.balance_vector(raw_vec) - _set_vector_to_sliders(final_vec) - _update_thermodynamics_preview() - - if _current_tab == "decision": - var dec := EvoTypes.DecisionDef.new() - dec.id = _resolve_target_id("dec") - dec.name = clean_name - dec.description = clean_desc - dec.archetype = _get_selected_archetype() - dec.origin = "user" - dec.vector = final_vec - if _world_state != null and _world_state.decisions_catalog.has(dec.id): - var prev_dec: EvoTypes.DecisionDef = _world_state.decisions_catalog[dec.id] - dec.required_trait_ids = prev_dec.required_trait_ids.duplicate() - if _world_state != null: - _world_state.decisions_catalog[dec.id] = dec - EvoUserSettings.save_user_catalog(_world_state) - _selected_id = dec.id - id_edit.text = dec.id - catalog_item_saved.emit("decision", dec.to_dict()) - _rebuild_catalog_list() - _update_labels() - _show_saved_feedback(dec.id) + EvoLabStrategyEditor.save_strategy(self, clean_name, clean_desc) else: - var prefix := "bio" if _current_tab == "biological" else "cul" - var tr := EvoTypes.TraitDef.new() - tr.id = _resolve_target_id(prefix) - tr.name = clean_name - tr.description = clean_desc - tr.category = _current_tab - tr.origin = "user" - tr.vector = final_vec - tr.enables_cultural_transmission = enables_culture_check.button_pressed if _current_tab == "biological" else false - if _world_state != null and _world_state.traits_catalog.has(tr.id): - var prev_tr: EvoTypes.TraitDef = _world_state.traits_catalog[tr.id] - tr.mutually_exclusive_with = prev_tr.mutually_exclusive_with.duplicate() - if _world_state != null: - _world_state.traits_catalog[tr.id] = tr - EvoUserSettings.save_user_catalog(_world_state) - _selected_id = tr.id - id_edit.text = tr.id - _rebuild_strategy_trait_checks() - catalog_item_saved.emit("trait", tr.to_dict()) - _rebuild_catalog_list() - _update_labels() - _show_saved_feedback(tr.id) - - -func _auto_balance_vector() -> void: - var vec := _read_vector_from_sliders() - var balanced := EvoUiStyle.balance_vector(vec) - _set_vector_to_sliders(balanced) - _update_thermodynamics_preview() + EvoLabThermoForm.save_trait_or_decision(self, clean_name, clean_desc) func _catalog_has_id(candidate: String) -> bool: @@ -734,344 +415,6 @@ func _show_saved_feedback(saved_id: String) -> void: ) -func _rebuild_strategy_trait_checks() -> void: - if strategy_traits_box == null: - return - for child in strategy_traits_box.get_children(): - child.queue_free() - strategy_trait_checks.clear() - - var traits_cat: Dictionary = _world_state.traits_catalog if _world_state != null else EvoCatalog.create_base_traits_catalog(_lang) - - var bio_hdr := Label.new() - bio_hdr.text = "🧬 " + EvoI18n.t(_lang, "panel.traits_bio") - bio_hdr.add_theme_font_size_override("font_size", 13) - bio_hdr.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK) - strategy_traits_box.add_child(bio_hdr) - - for tid in traits_cat.keys(): - var t: EvoTypes.TraitDef = traits_cat[tid] - if t.category != "biological": - continue - var cb := _make_strategy_trait_checkbox(t) - strategy_trait_checks[t.id] = cb - strategy_traits_box.add_child(cb) - - var cul_hdr := Label.new() - cul_hdr.text = "🔥 " + EvoI18n.t(_lang, "panel.traits_cul") - cul_hdr.add_theme_font_size_override("font_size", 13) - cul_hdr.add_theme_color_override("font_color", EvoUiStyle.COLOR_PURPLE_CULTURE) - strategy_traits_box.add_child(cul_hdr) - - for tid in traits_cat.keys(): - var t: EvoTypes.TraitDef = traits_cat[tid] - if t.category != "cultural": - continue - var cb := _make_strategy_trait_checkbox(t) - strategy_trait_checks[t.id] = cb - strategy_traits_box.add_child(cb) - - -func _make_strategy_trait_checkbox(t: EvoTypes.TraitDef) -> CheckBox: - var cb := CheckBox.new() - var t_name := EvoI18n.trait_name(_lang, t) - var summary := _format_trait_vector_summary(t.vector) - var culture_tag := "" - if t.enables_cultural_transmission: - culture_tag = " [★ Cultura]" if _lang != "en" else " [★ Culture]" - cb.text = "%s%s (%s)" % [t_name, culture_tag, summary] - cb.tooltip_text = EvoI18n.trait_description(_lang, t) - cb.toggled.connect(_on_strategy_trait_toggled.bind(t.id)) - return cb - - -func _format_trait_vector_summary(v: EvoTypes.BiophysicalVector) -> String: - var parts: Array[String] = [] - if not is_zero_approx(v.metabolic_cost): - parts.append("Cost %+.2f" % v.metabolic_cost) - if not is_zero_approx(v.extraction_efficiency): - parts.append("Ext %+.2f" % v.extraction_efficiency) - if not is_zero_approx(v.ecological_impact): - parts.append("Eco %+.2f" % v.ecological_impact) - if not is_zero_approx(v.spatial_mobility): - parts.append("Mob %+.2f" % v.spatial_mobility) - if not is_zero_approx(v.social_cohesion): - parts.append("Soc %+.2f" % v.social_cohesion) - return ", ".join(parts) if not parts.is_empty() else "0.00" - - -func _on_strategy_trait_toggled(is_checked: bool, trait_id: String) -> void: - if _updating_strategy_checks: - return - var traits_cat: Dictionary = _world_state.traits_catalog if _world_state != null else {} - _updating_strategy_checks = true - - if is_checked: - if trait_id == "bio-r-strategy" and strategy_trait_checks.has("bio-k-strategy"): - (strategy_trait_checks["bio-k-strategy"] as CheckBox).button_pressed = false - elif trait_id == "bio-k-strategy" and strategy_trait_checks.has("bio-r-strategy"): - (strategy_trait_checks["bio-r-strategy"] as CheckBox).button_pressed = false - - if traits_cat.has(trait_id): - var tr: EvoTypes.TraitDef = traits_cat[trait_id] - for ex_id in tr.mutually_exclusive_with: - if strategy_trait_checks.has(ex_id): - (strategy_trait_checks[ex_id] as CheckBox).button_pressed = false - for other_id in traits_cat.keys(): - var other_tr: EvoTypes.TraitDef = traits_cat[other_id] - if other_tr.mutually_exclusive_with.has(trait_id) and strategy_trait_checks.has(other_id): - (strategy_trait_checks[other_id] as CheckBox).button_pressed = false - - if tr.category == "cultural" and not _has_checked_cultural_capacity(traits_cat): - var enabled_any := false - for bid in ["bio-social-instinct", "bio-encephalization"]: - if strategy_trait_checks.has(bid): - var can_enable := not traits_cat.has(bid) or (traits_cat[bid] as EvoTypes.TraitDef).enables_cultural_transmission - if can_enable: - (strategy_trait_checks[bid] as CheckBox).button_pressed = true - enabled_any = true - break - if not enabled_any: - for bid in traits_cat.keys(): - var btr: EvoTypes.TraitDef = traits_cat[bid] - if btr.category == "biological" and btr.enables_cultural_transmission and strategy_trait_checks.has(bid): - (strategy_trait_checks[bid] as CheckBox).button_pressed = true - break - else: - var is_culture_enabler := false - if traits_cat.has(trait_id): - var tr: EvoTypes.TraitDef = traits_cat[trait_id] - is_culture_enabler = (tr.category == "biological" and tr.enables_cultural_transmission) - else: - is_culture_enabler = trait_id in ["bio-social-instinct", "bio-encephalization"] - if is_culture_enabler and not _has_checked_cultural_capacity(traits_cat): - for cid in strategy_trait_checks.keys(): - var is_cul := str(cid).begins_with("cul-") - if traits_cat.has(cid): - is_cul = (traits_cat[cid] as EvoTypes.TraitDef).category == "cultural" - if is_cul: - (strategy_trait_checks[cid] as CheckBox).button_pressed = false - - _updating_strategy_checks = false - _update_strategy_vector_preview() - - -func _has_checked_cultural_capacity(traits_cat: Dictionary) -> bool: - for tid in strategy_trait_checks.keys(): - var cb: CheckBox = strategy_trait_checks[tid] - if not cb.button_pressed: - continue - if traits_cat.has(tid): - var tr: EvoTypes.TraitDef = traits_cat[tid] - if tr.category == "biological" and tr.enables_cultural_transmission: - return true - elif tid in ["bio-social-instinct", "bio-encephalization"]: - return true - return false - - -func _get_checked_strategy_trait_ids() -> Array[String]: - var res: Array[String] = [] - for tid in strategy_trait_checks.keys(): - if (strategy_trait_checks[tid] as CheckBox).button_pressed: - res.append(str(tid)) - return res - - -func _set_checked_strategy_trait_ids(trait_ids: Array) -> void: - _updating_strategy_checks = true - for tid in strategy_trait_checks.keys(): - (strategy_trait_checks[tid] as CheckBox).button_pressed = trait_ids.has(tid) - _updating_strategy_checks = false - _update_strategy_vector_preview() - - -func _are_traits_mutually_exclusive(tid_a: String, tid_b: String, traits_cat: Dictionary) -> bool: - if (tid_a == "bio-r-strategy" and tid_b == "bio-k-strategy") or (tid_a == "bio-k-strategy" and tid_b == "bio-r-strategy"): - return true - if traits_cat.has(tid_a) and (traits_cat[tid_a] as EvoTypes.TraitDef).mutually_exclusive_with.has(tid_b): - return true - if traits_cat.has(tid_b) and (traits_cat[tid_b] as EvoTypes.TraitDef).mutually_exclusive_with.has(tid_a): - return true - return false - - -func _build_strategy_from_form() -> EvoTypes.StrategyDef: - var strat := EvoTypes.StrategyDef.new() - strat.id = _resolve_target_id("strat") - strat.name = name_edit.text.strip_edges() - if strat.name == "": - strat.name = "Custom Strategy" - strat.description = desc_edit.text.strip_edges() - strat.icon = _get_selected_strategy_icon() - strat.color = "#" + strategy_color_picker.color.to_html(false).to_upper() - - var traits_cat: Dictionary = _world_state.traits_catalog if _world_state != null else {} - var bio_ids: Array[String] = [] - var cul_ids: Array[String] = [] - var accepted_ids: Array[String] = [] - for tid_var in strategy_trait_checks.keys(): - var tid := str(tid_var) - var cb: CheckBox = strategy_trait_checks[tid_var] - if not cb.button_pressed: - continue - var has_conflict := false - for kept_id in accepted_ids: - if _are_traits_mutually_exclusive(tid, kept_id, traits_cat): - has_conflict = true - break - if has_conflict: - continue - accepted_ids.append(tid) - var is_cul := tid.begins_with("cul-") - if traits_cat.has(tid): - is_cul = (traits_cat[tid] as EvoTypes.TraitDef).category == "cultural" - if is_cul: - cul_ids.append(tid) - else: - bio_ids.append(tid) - - if not cul_ids.is_empty(): - var has_cap := false - for bid in bio_ids: - if traits_cat.has(bid): - if (traits_cat[bid] as EvoTypes.TraitDef).enables_cultural_transmission: - has_cap = true - break - elif bid in ["bio-social-instinct", "bio-encephalization"]: - has_cap = true - break - if not has_cap: - var fallback_bio := "" - for cand in ["bio-social-instinct", "bio-encephalization"]: - if not traits_cat.has(cand) or (traits_cat[cand] as EvoTypes.TraitDef).enables_cultural_transmission: - fallback_bio = cand - break - if fallback_bio == "": - for tid in traits_cat.keys(): - var t: EvoTypes.TraitDef = traits_cat[tid] - if t.category == "biological" and t.enables_cultural_transmission: - fallback_bio = t.id - break - if fallback_bio != "" and not bio_ids.has(fallback_bio): - bio_ids.append(fallback_bio) - elif fallback_bio == "": - cul_ids.clear() - - strat.biological_trait_ids = bio_ids - strat.cultural_trait_ids = cul_ids - return strat - - -func _update_strategy_vector_preview() -> void: - if strategy_vector_label == null: - return - var temp_strat := _build_strategy_from_form() - var traits_cat: Dictionary = _world_state.traits_catalog if _world_state != null else EvoCatalog.create_base_traits_catalog(_lang) - var vec := EvoCatalog.compute_strategy_vector(temp_strat, traits_cat) - strategy_vector_label.text = ( - "%s: %+.2f | %s: %+.2f | %s: %+.2f\n%s: %+.2f | %s: %+.2f | %s: %.2f" % [ - EvoI18n.t(_lang, "axis.metabolic_cost"), vec.metabolic_cost, - EvoI18n.t(_lang, "axis.extraction_efficiency"), vec.extraction_efficiency, - EvoI18n.t(_lang, "axis.ecological_impact"), vec.ecological_impact, - EvoI18n.t(_lang, "axis.spatial_mobility"), vec.spatial_mobility, - EvoI18n.t(_lang, "axis.social_cohesion"), vec.social_cohesion, - EvoI18n.t(_lang, "axis.transmission_threshold"), vec.transmission_threshold - ] - ) - - -func _select_strategy_icon(icon: String) -> void: - if strategy_icon_option == null: - return - for i in range(strategy_icon_option.item_count): - if strategy_icon_option.get_item_text(i) == icon: - strategy_icon_option.select(i) - return - if strategy_icon_option.item_count > 0: - strategy_icon_option.select(0) - - -func _get_selected_strategy_icon() -> String: - if strategy_icon_option == null: - return "🛡️" - var idx := strategy_icon_option.selected - if idx >= 0 and idx < strategy_icon_option.item_count: - return strategy_icon_option.get_item_text(idx) - return "🛡️" - - -func _is_strategy_form_dirty(existing: EvoTypes.StrategyDef) -> bool: - var form_strat := _build_strategy_from_form() - if form_strat.id != existing.id or form_strat.name != existing.name or form_strat.description != existing.description: - return true - if form_strat.icon != existing.icon or form_strat.color.to_upper() != existing.color.to_upper(): - return true - if form_strat.biological_trait_ids != existing.biological_trait_ids or form_strat.cultural_trait_ids != existing.cultural_trait_ids: - return true - return false - - -func _on_use_strategy_slot_pressed(slot: String) -> void: - if _current_tab != "strategy": - return - var target_id := id_edit.text.strip_edges() - if target_id == "": - target_id = _selected_id - if target_id == "" or _world_state == null or not _world_state.strategies_catalog.has(target_id) or _is_strategy_form_dirty(_world_state.strategies_catalog[target_id]): - _save_current_item() - if _world_state != null: - if slot == "a": - _world_state.strategy_a_id = _selected_id - elif slot == "b": - _world_state.strategy_b_id = _selected_id - strategy_assigned.emit(slot, _selected_id) - _feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) - _feedback_lbl.text = "%s: %s" % [ - EvoI18n.t(_lang, "lab_use_strat_a" if slot == "a" else "lab_use_strat_b"), - _selected_id - ] - - -func _rebuild_archetype_options() -> void: - if _archetype_option == null: - return - var prev_idx := maxi(0, _archetype_option.selected) - _archetype_option.clear() - for i in range(EvoTypes.ARCHETYPES.size()): - var arch: String = EvoTypes.ARCHETYPES[i] - _archetype_option.add_item(EvoUiStyle.get_archetype_label(arch, _lang), i) - _archetype_option.set_item_metadata(i, arch) - if prev_idx < _archetype_option.item_count: - _archetype_option.select(prev_idx) - - -func _rebuild_inject_deme_options() -> void: - if inject_deme_option == null: - return - inject_deme_option.clear() - if _world_state == null or _world_state.demes.is_empty(): - return - var idx := 0 - var selected_idx := 0 - for did in _world_state.demes.keys(): - var d: EvoTypes.Deme = _world_state.demes[did] - inject_deme_option.add_item(d.name, idx) - inject_deme_option.set_item_metadata(idx, d.id) - if d.id == _selected_deme_id: - selected_idx = idx - idx += 1 - inject_deme_option.select(selected_idx) - _selected_deme_id = str(inject_deme_option.get_item_metadata(selected_idx)) - - -func _on_inject_deme_option_selected(index: int) -> void: - if inject_deme_option == null or index < 0 or index >= inject_deme_option.item_count: - return - _selected_deme_id = str(inject_deme_option.get_item_metadata(index)) - _update_labels() - - func _update_labels() -> void: if _title_label == null: return @@ -1079,19 +422,17 @@ func _update_labels() -> void: _title_label.text = EvoUiStyle.tr_text( _lang, - "⚗️ Forja de Estrategias, Rasgos y Decisiones — Laboratorio Evolutivo", - "⚗️ Strategy, Trait & Decision Forge — Evolutionary Laboratory" + "⚗️ Forja de Estrategias y Rasgos — Laboratorio Evolutivo", + "⚗️ Strategy & Trait Forge — Evolutionary Laboratory" ) _close_btn.text = EvoUiStyle.tr_text(_lang, "✕ Cerrar", "✕ Close") tab_strategy_btn.text = EvoI18n.t(_lang, "lab_tab_strat") tab_bio_btn.text = EvoUiStyle.tr_text(_lang, "🧬 Rasgos Biológicos", "🧬 Biological Traits") tab_cul_btn.text = EvoUiStyle.tr_text(_lang, "🔥 Tradiciones Culturales", "🔥 Cultural Traditions") - tab_dec_btn.text = EvoUiStyle.tr_text(_lang, "⚖️ Decisiones Conductuales", "⚖️ Behavioral Decisions") EvoUiStyle.apply_button_active(tab_strategy_btn, _current_tab == "strategy") EvoUiStyle.apply_button_active(tab_bio_btn, _current_tab == "biological") EvoUiStyle.apply_button_active(tab_cul_btn, _current_tab == "cultural") - EvoUiStyle.apply_button_active(tab_dec_btn, _current_tab == "decision") new_item_button.text = EvoI18n.t(_lang, "lab_new_strat") if is_strat else EvoUiStyle.tr_text(_lang, "+ Crear Nuevo", "+ Create New") _id_lbl.text = "ID:" @@ -1099,61 +440,10 @@ func _update_labels() -> void: _name_lbl.text = EvoUiStyle.tr_text(_lang, "Nombre:", "Name:") _desc_lbl.text = EvoUiStyle.tr_text(_lang, "Descripción:", "Description:") - _strategy_icon_lbl.text = EvoI18n.t(_lang, "lab_strat_icon") + ":" - _strategy_color_lbl.text = EvoI18n.t(_lang, "lab_strat_color") + ":" - _strategy_traits_title_lbl.text = EvoI18n.t(_lang, "lab_strat_traits") - _strategy_vector_title_lbl.text = EvoI18n.t(_lang, "lab_strat_vector") - use_strategy_a_button.text = EvoI18n.t(_lang, "lab_use_strat_a") - use_strategy_b_button.text = EvoI18n.t(_lang, "lab_use_strat_b") + EvoLabStrategyEditor.refresh_strategy_and_inject_labels(self, is_strat) + EvoLabThermoForm.refresh_thermo_labels(self, is_strat) - enables_culture_check.text = EvoUiStyle.tr_text( - _lang, - "Habilita transmisión cultural (enablesCulturalTransmission)", - "Enables cultural transmission (enablesCulturalTransmission)" - ) - _archetype_lbl.text = EvoUiStyle.tr_text(_lang, "Arquetipo:", "Archetype:") - - _strategy_emblem_row.visible = is_strat - _strategy_traits_card.visible = is_strat - _strategy_vector_card.visible = is_strat - _strategy_matchup_row.visible = is_strat - - enables_culture_check.visible = (_current_tab == "biological") - _archetype_row.visible = (_current_tab == "decision") - _sliders_card.visible = not is_strat - _thermo_card.visible = not is_strat - _inject_row.visible = (_current_tab in ["biological", "cultural"]) - - var slider_names_es := { - "metabolic_cost": "Coste Metabólico (-1 a +1)", - "extraction_efficiency": "Eficiencia de Extracción (-1 a +1)", - "ecological_impact": "Impacto Ecológico (-1 a +1)", - "spatial_mobility": "Movilidad Espacial (-1 a +1)", - "social_cohesion": "Cohesión Social (-1 a +1)", - "transmission_threshold": "Umbral de Transmisión (0 a +1)" - } - var slider_names_en := { - "metabolic_cost": "Metabolic Cost (-1 to +1)", - "extraction_efficiency": "Extraction Efficiency (-1 to +1)", - "ecological_impact": "Ecological Impact (-1 to +1)", - "spatial_mobility": "Spatial Mobility (-1 to +1)", - "social_cohesion": "Social Cohesion (-1 to +1)", - "transmission_threshold": "Transmission Threshold (0 to +1)" - } - for s_key in SLIDER_KEYS: - var lbl: Label = _slider_title_labels[s_key] - lbl.text = str(slider_names_en[s_key]) if _lang == "en" else str(slider_names_es[s_key]) - - _thermo_title_lbl.text = EvoUiStyle.tr_text( - _lang, - "⚖️ Medidor de Equilibrio Termodinámico en Vivo", - "⚖️ Live Thermodynamic Balance Meter" - ) - auto_balance_button.text = EvoUiStyle.tr_text(_lang, "Auto-Equilibrar Vector", "Auto-Balance Vector") save_item_button.text = EvoI18n.t(_lang, "lab_save_strat") if is_strat else EvoUiStyle.tr_text(_lang, "💾 Guardar en el Catálogo", "💾 Save to Catalog") - _inject_deme_lbl.text = EvoUiStyle.tr_text(_lang, "Aldea:", "Village:") - inject_button.text = EvoUiStyle.tr_text(_lang, "💉 Inyectar en Aldea", "💉 Inject into Village") - inject_button.disabled = (_selected_deme_id == "" or _world_state == null or not _world_state.demes.has(_selected_deme_id)) if _selected_id == "": _form_header_lbl.text = EvoI18n.t(_lang, "lab_new_strat") if is_strat else EvoUiStyle.tr_text(_lang, "✨ Nuevo Elemento Experimental", "✨ New Experimental Item") @@ -1188,16 +478,6 @@ func _rebuild_catalog_list() -> void: btn.add_theme_stylebox_override("hover", EvoUiStyle.make_button_style(bg_col.lightened(0.08), accent_col, 3)) btn.pressed.connect(_on_catalog_strategy_clicked.bind(strat.id)) item_list.add_child(btn) - elif _current_tab == "decision": - for dec_id in _world_state.decisions_catalog.keys(): - var dec: EvoTypes.DecisionDef = _world_state.decisions_catalog[dec_id] - var btn := Button.new() - btn.text = "%s [%s]" % [EvoI18n.decision_name(_lang, dec), dec.archetype] - btn.alignment = HORIZONTAL_ALIGNMENT_LEFT - btn.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - EvoUiStyle.apply_button_active(btn, _selected_id == dec.id) - btn.pressed.connect(_on_catalog_decision_clicked.bind(dec.id)) - item_list.add_child(btn) else: for t_id in _world_state.traits_catalog.keys(): var t: EvoTypes.TraitDef = _world_state.traits_catalog[t_id] @@ -1217,209 +497,76 @@ func _load_selected_item_into_form() -> void: return if _current_tab == "strategy" and _world_state.strategies_catalog.has(_selected_id): _on_catalog_strategy_clicked(_selected_id) - elif _current_tab == "decision" and _world_state.decisions_catalog.has(_selected_id): - _on_catalog_decision_clicked(_selected_id) elif _world_state.traits_catalog.has(_selected_id): _on_catalog_trait_clicked(_selected_id) -func _on_catalog_strategy_clicked(strategy_id: String) -> void: - if _world_state == null or not _world_state.strategies_catalog.has(strategy_id): - return - var strat: EvoTypes.StrategyDef = _world_state.strategies_catalog[strategy_id] - _selected_id = strat.id - id_edit.text = strat.id - name_edit.text = EvoI18n.strategy_name(_lang, strat) - desc_edit.text = EvoI18n.strategy_description(_lang, strat) - _select_strategy_icon(strat.icon) - strategy_color_picker.color = Color.from_string(strat.color, Color("#10B981")) - - var combined_traits: Array[String] = [] - for bid in strat.biological_trait_ids: - combined_traits.append(bid) - for cid in strat.cultural_trait_ids: - combined_traits.append(cid) - _set_checked_strategy_trait_ids(combined_traits) +func _rebuild_strategy_trait_checks() -> void: + EvoLabStrategyEditor.rebuild_strategy_trait_checks(self) - _feedback_lbl.text = "" - _update_labels() - _rebuild_catalog_list() - _update_strategy_vector_preview() +func _on_strategy_trait_toggled(is_checked: bool, trait_id: String) -> void: + EvoLabStrategyEditor.on_strategy_trait_toggled(self, is_checked, trait_id) -func _on_catalog_trait_clicked(trait_id: String) -> void: - if _world_state == null or not _world_state.traits_catalog.has(trait_id): - return - var tr: EvoTypes.TraitDef = _world_state.traits_catalog[trait_id] - _selected_id = tr.id - id_edit.text = tr.id - name_edit.text = EvoI18n.trait_name(_lang, tr) - desc_edit.text = EvoI18n.trait_description(_lang, tr) - enables_culture_check.button_pressed = tr.enables_cultural_transmission - _set_vector_to_sliders(tr.vector) - _feedback_lbl.text = "" - _update_labels() - _rebuild_catalog_list() - _update_thermodynamics_preview() +func _get_checked_strategy_trait_ids() -> Array[String]: + return EvoLabStrategyEditor.get_checked_strategy_trait_ids(self) -func _on_catalog_decision_clicked(decision_id: String) -> void: - if _world_state == null or not _world_state.decisions_catalog.has(decision_id): - return - var dec: EvoTypes.DecisionDef = _world_state.decisions_catalog[decision_id] - _selected_id = dec.id - id_edit.text = dec.id - name_edit.text = EvoI18n.decision_name(_lang, dec) - desc_edit.text = EvoI18n.decision_description(_lang, dec) - _select_archetype(dec.archetype) - _set_vector_to_sliders(dec.vector) - _feedback_lbl.text = "" - _update_labels() - _rebuild_catalog_list() - _update_thermodynamics_preview() +func _set_checked_strategy_trait_ids(trait_ids: Array) -> void: + EvoLabStrategyEditor.set_checked_strategy_trait_ids(self, trait_ids) -func _set_vector_to_sliders(vec: EvoTypes.BiophysicalVector) -> void: - _updating_sliders = true - (sliders["metabolic_cost"] as HSlider).value = vec.metabolic_cost - (sliders["extraction_efficiency"] as HSlider).value = vec.extraction_efficiency - (sliders["ecological_impact"] as HSlider).value = vec.ecological_impact - (sliders["spatial_mobility"] as HSlider).value = vec.spatial_mobility - (sliders["social_cohesion"] as HSlider).value = vec.social_cohesion - (sliders["transmission_threshold"] as HSlider).value = vec.transmission_threshold - _updating_sliders = false - _refresh_slider_value_labels() +func _build_strategy_from_form() -> EvoTypes.StrategyDef: + return EvoLabStrategyEditor.build_strategy_from_form(self) -func _read_vector_from_sliders() -> EvoTypes.BiophysicalVector: - return EvoTypes.BiophysicalVector.new( - snappedf((sliders["metabolic_cost"] as HSlider).value, 0.01), - snappedf((sliders["extraction_efficiency"] as HSlider).value, 0.01), - snappedf((sliders["ecological_impact"] as HSlider).value, 0.01), - snappedf((sliders["spatial_mobility"] as HSlider).value, 0.01), - snappedf((sliders["social_cohesion"] as HSlider).value, 0.01), - snappedf((sliders["transmission_threshold"] as HSlider).value, 0.01) - ) +func _update_strategy_vector_preview() -> void: + EvoLabStrategyEditor.update_strategy_vector_preview(self) -func _refresh_slider_value_labels() -> void: - for s_key in SLIDER_KEYS: - var val: float = (sliders[s_key] as HSlider).value - var lbl: Label = _slider_val_labels[s_key] - lbl.text = "%+.2f" % val if s_key != "transmission_threshold" else "%.2f" % val +func _on_use_strategy_slot_pressed(slot: String) -> void: + EvoLabStrategyEditor.on_use_strategy_slot_pressed(self, slot) -func _update_thermodynamics_preview() -> void: - if _thermo_metrics_lbl == null: - return - _refresh_slider_value_labels() - var vec := _read_vector_from_sliders() - var free_mode := _world_state.free_sandbox_mode if _world_state != null else false - var eval_res := EvoUiStyle.evaluate_thermodynamics(vec, free_mode) - var a_plus: float = float(eval_res["a_plus"]) - var c_actual: float = float(eval_res["c_actual"]) - var c_req: float = float(eval_res["c_req"]) - var is_bal: bool = bool(eval_res["is_balanced"]) - var bal_vec: EvoTypes.BiophysicalVector = eval_res["balanced_vector"] - - balance_bar.value = clampf(a_plus, balance_bar.min_value, balance_bar.max_value) - EvoUiStyle.style_progress_bar(balance_bar, EvoUiStyle.COLOR_GREEN_OK if is_bal else EvoUiStyle.COLOR_AMBER_WARN) - - _thermo_metrics_lbl.text = EvoUiStyle.tr_text( - _lang, - "Ventajas Positivas (A+): %.2f | Coste Actual (C_actual): %.2f | Coste Requerido (C_req): %.2f" % [a_plus, c_actual, c_req], - "Positive Advantages (A+): %.2f | Actual Cost (C_actual): %.2f | Required Cost (C_req): %.2f" % [a_plus, c_actual, c_req] - ) - if is_bal: - balance_status_label.text = EvoUiStyle.tr_text( - _lang, - "✓ Cumple la Ley de Conservación Termodinámica (C_actual >= C_req).", - "✓ Complies with the Law of Thermodynamic Conservation (C_actual >= C_req)." - ) - balance_status_label.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) - else: - balance_status_label.text = EvoUiStyle.tr_text( - _lang, - "⚠ Exceso de ventaja sin coste: al guardar se auto-equilibrará a Coste Metabólico = %+.2f, Impacto Eco = %+.2f." % [bal_vec.metabolic_cost, bal_vec.ecological_impact], - "⚠ Uncompensated advantage: saving will auto-balance to Metabolic Cost = %+.2f, Eco Impact = %+.2f." % [bal_vec.metabolic_cost, bal_vec.ecological_impact] - ) - balance_status_label.add_theme_color_override("font_color", EvoUiStyle.COLOR_AMBER_WARN) +func _on_catalog_strategy_clicked(strategy_id: String) -> void: + EvoLabStrategyEditor.on_catalog_strategy_clicked(self, strategy_id) -func _select_archetype(archetype: String) -> void: - for i in range(_archetype_option.item_count): - if str(_archetype_option.get_item_metadata(i)) == archetype: - _archetype_option.select(i) - return +func _rebuild_inject_deme_options() -> void: + EvoLabStrategyEditor.rebuild_inject_deme_options(self) -func _get_selected_archetype() -> String: - var idx := _archetype_option.selected - if idx >= 0 and idx < _archetype_option.item_count: - return str(_archetype_option.get_item_metadata(idx)) - return "custom" +func _on_inject_deme_option_selected(index: int) -> void: + EvoLabStrategyEditor.on_inject_deme_option_selected(self, index) -func _on_slider_value_changed(_val: float, _key: String) -> void: - if _updating_sliders: - return - _update_thermodynamics_preview() +func _on_inject_pressed() -> void: + EvoLabStrategyEditor.on_inject_pressed(self) -func _is_selected_builtin_trait_unmodified() -> bool: - if _world_state == null or _selected_id == "" or not _world_state.traits_catalog.has(_selected_id): - return false - var existing: EvoTypes.TraitDef = _world_state.traits_catalog[_selected_id] - if existing.origin not in ["catalog", "base"]: - return false - var target_id := id_edit.text.strip_edges() - if target_id != "" and target_id != existing.id: - return false - var clean_name := name_edit.text.strip_edges() - if clean_name != EvoI18n.trait_name(_lang, existing).strip_edges() and clean_name != existing.name.strip_edges(): - return false - var clean_desc := desc_edit.text.strip_edges() - if clean_desc != EvoI18n.trait_description(_lang, existing).strip_edges() and clean_desc != existing.description.strip_edges(): - return false - if _current_tab == "biological" and enables_culture_check.button_pressed != existing.enables_cultural_transmission: - return false - var form_vec := _read_vector_from_sliders() - var ex_vec := existing.vector - return ( - is_equal_approx(form_vec.metabolic_cost, snappedf(ex_vec.metabolic_cost, 0.01)) - and is_equal_approx(form_vec.extraction_efficiency, snappedf(ex_vec.extraction_efficiency, 0.01)) - and is_equal_approx(form_vec.ecological_impact, snappedf(ex_vec.ecological_impact, 0.01)) - and is_equal_approx(form_vec.spatial_mobility, snappedf(ex_vec.spatial_mobility, 0.01)) - and is_equal_approx(form_vec.social_cohesion, snappedf(ex_vec.social_cohesion, 0.01)) - and is_equal_approx(form_vec.transmission_threshold, snappedf(ex_vec.transmission_threshold, 0.01)) - ) +func _auto_balance_vector() -> void: + EvoLabThermoForm.auto_balance_vector(self) -func _on_inject_pressed() -> void: - if _current_tab not in ["biological", "cultural"] or _selected_deme_id == "": - return - if _current_tab == "cultural" and _world_state != null and _world_state.demes.has(_selected_deme_id): - var target_deme: EvoTypes.Deme = _world_state.demes[_selected_deme_id] - if not EvoCatalog.deme_has_cultural_capacity(target_deme, _world_state.traits_catalog): - _feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GOLD_WARN) - _feedback_lbl.text = EvoUiStyle.tr_text( - _lang, - "⚠ La aldea seleccionada carece de capacidad cultural (requiere Encefalización o Sociabilidad Instintiva).", - "⚠ Selected village lacks cultural capacity (requires Encephalization or Instinctive Sociability)." - ) - return - if not _is_selected_builtin_trait_unmodified(): - _save_current_item() - if _world_state != null: - EvoCatalog.inject_trait_into_deme(_world_state, _selected_deme_id, _selected_id, _lang) - _feedback_lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_GREEN_OK) - inject_trait_requested.emit(_selected_deme_id, _selected_id) - _feedback_lbl.text = EvoUiStyle.tr_text( - _lang, - "💉 Rasgo '%s' inyectado en la aldea seleccionada." % name_edit.text, - "💉 Trait '%s' injected into selected village." % name_edit.text - ) +func _on_catalog_trait_clicked(trait_id: String) -> void: + EvoLabThermoForm.on_catalog_trait_clicked(self, trait_id) + + +func _set_vector_to_sliders(vec: EvoTypes.BiophysicalVector) -> void: + EvoLabThermoForm.set_vector_to_sliders(self, vec) + + +func _read_vector_from_sliders() -> EvoTypes.BiophysicalVector: + return EvoLabThermoForm.read_vector_from_sliders(self) + + +func _update_thermodynamics_preview() -> void: + EvoLabThermoForm.update_thermodynamics_preview(self) + + +func _on_slider_value_changed(val: float, key: String) -> void: + EvoLabThermoForm.on_slider_value_changed(self, val, key) func _on_backdrop_input(event: InputEvent) -> void: diff --git a/src/ui/main_hud.gd b/src/ui/main_hud.gd index fa7fb44..87b2583 100644 --- a/src/ui/main_hud.gd +++ b/src/ui/main_hud.gd @@ -4,12 +4,10 @@ extends Control signal preset_selected(preset_id: String) signal matchup_changed(strategy_a_id: String, strategy_b_id: String) signal matchup_reset_requested() -signal export_json_requested(file_path: String) -signal import_json_requested(file_path: String) signal state_imported(new_state: EvoTypes.WorldState) signal play_pause_toggled(is_playing: bool) signal step_generation_requested() -signal speed_changed(is_fast: bool) +signal speed_changed(speed_index: int) signal language_changed(lang: String) signal settings_changed(auto_pause: bool, unlimited_pressure: bool, free_sandbox: bool) signal decision_models_changed() @@ -28,14 +26,10 @@ const PRESET_LIST: Array[String] = EvoHudTopBar.PRESET_LIST const DEFAULT_EXPORT_PATH: String = "user://evolution_world_state.json" const HOTBAR_SLOTS_DEF: Array[Dictionary] = EvoHudHotbarDock.HOTBAR_SLOTS_DEF -var top_bar: EvoHudTopBar -var inspector_panel: EvoHudInspectorPanel -var hotbar_dock: EvoHudHotbarDock - var _world_state: EvoTypes.WorldState = null var _lang: String = "es" var _is_playing: bool = false -var _is_fast: bool = false +var _speed_index: int = 1 var _suppress_strategy_signal: bool = false var _active_tool: String = "inspect" @@ -46,13 +40,8 @@ var _active_lens: String = "biome" var _selected_hex_id: String = "0,0" var _selected_deme_id: String = "" var _highlighted_lineage_id: String = "" -var _last_seen_chronicle_count: int = 0 - -var _step_1_done: bool = false -var _step_2_done: bool = false -var _step_3_done: bool = false -# Top-Left Valley Sign, Strategy Matchup Selector, Artifacts & Living Village Medallions +# Top-Left Valley Sign, Strategy Matchup Selector & Artifacts var _preset_option: OptionButton var strategy_a_swatch: ColorRect var strategy_a_option: OptionButton @@ -65,21 +54,15 @@ var _import_btn: Button var _notebook_btn: Button var _tree_btn: Button var _lab_btn: Button -var _lens_cycle_btn: Button var _lens_strip_panel: PanelContainer var _lens_buttons: Dictionary = {} # lens_id -> Button var _legend_toggle_btn: Button var _legend_panel: PanelContainer var _legend_vbox: VBoxContainer -var _villages_title_lbl: Label -var _villages_list_vbox: VBoxContainer -var _village_buttons_pool: Array[Button] = [] -var _empty_villages_lbl: Label var _last_hotbar_visual_key: String = "" # Top-Right Clock & Cognitive Engine Widget var _season_portrait: EvoHudTopBar.SeasonTidePortrait -var _season_icon: TextureRect var _gen_label: Label var _season_label: Label var _sea_level_label: Label @@ -110,8 +93,6 @@ var _cam_lbl: Label # Bottom-Center 10-Slot Inventory Hotbar var _hotbar_slot_buttons: Array[Button] = [] var _tool_buttons: Dictionary = {} # tool_id -> Button -var _equipped_tool_banner: PanelContainer -var _equipped_tool_lbl: Label var _sub_options_panel: PanelContainer var _sub_options_flow: HFlowContainer var _radius_1_btn: Button @@ -128,7 +109,6 @@ var _village_selector_opt: OptionButton var deme_selector_option: OptionButton var _village_next_btn: Button var _inspector_header_swatch: ColorRect -var _inspector_header_title: Label var _inspector_header_hl_btn: Button var _inspector_model_badge_lbl: Label var _strategy_badge_panel: PanelContainer @@ -141,15 +121,21 @@ var _deme_sheet_vbox: VBoxContainer var _traits_sheet_vbox: VBoxContainer var _hex_sheet_vbox: VBoxContainer -# Banners, Toasts & First Steps +# Top-Center A-vs-B Stats Strip & History Chart +var _stats_strip_panel: PanelContainer +var _stats_chart_panel: PanelContainer +var _stats_chart_toggle_btn: Button +var _stats_chart_metric: String = "population" + +# Banners & Live Chronicle Feed var _milestone_banner: PanelContainer var _banner_title_lbl: Label var _banner_summary_lbl: Label -var _toast_vbox: VBoxContainer -var _first_steps_panel: PanelContainer -var _step1_lbl: Label -var _step2_lbl: Label -var _step3_lbl: Label +var _last_shown_macro_outcome: String = "" +var _event_feed_panel: PanelContainer +var _event_feed_vbox: VBoxContainer +var _event_feed_filter: String = "all" +var _event_feed_collapsed: bool = false var _missing_models_modal: Control var _missing_models_title_lbl: Label var _missing_models_desc_lbl: Label @@ -179,8 +165,7 @@ func _ready() -> void: set_process_unhandled_key_input(true) -func _process(delta: float) -> void: - EvoHudBannersToasts.process_toasts(_toast_vbox, delta) +func _process(_delta: float) -> void: _poll_ollama_startup_and_download() @@ -246,6 +231,11 @@ func _unhandled_key_input(event: InputEvent) -> void: return if (notebook_modal != null and notebook_modal.visible) or (tree_modal != null and tree_modal.visible) or (lab_modal != null and lab_modal.visible): return + if ke.keycode == KEY_TAB: + _on_lens_cycle_btn_pressed() + if get_viewport() != null: + get_viewport().set_input_as_handled() + return var slot_idx := -1 match ke.keycode: KEY_1, KEY_KP_1: @@ -270,24 +260,20 @@ func _unhandled_key_input(event: InputEvent) -> void: slot_idx = 9 if slot_idx >= 0: _on_hotbar_slot_pressed(slot_idx) - if get_viewport() != null: + if get_viewport() != null and ke.keycode != KEY_0 and ke.keycode != KEY_KP_0: get_viewport().set_input_as_handled() func _build_ui() -> void: - top_bar = EvoHudTopBar.new(self) - top_bar.matchup_changed.connect(func(a: String, b: String) -> void: matchup_changed.emit(a, b)) - top_bar.matchup_reset_requested.connect(func() -> void: matchup_reset_requested.emit()) - inspector_panel = EvoHudInspectorPanel.new(self) - hotbar_dock = EvoHudHotbarDock.new(self) - EvoHudTopBar.build_top_left_valley_sign_and_medallions(self) + EvoHudStatsStrip.build_stats_strip(self) EvoHudTopBar.build_top_right_clock(self) EvoHudTopBar.build_settings_popup(self) EvoHudHotbarDock.build_bottom_hotbar(self) EvoHudHotbarDock.build_legend_panel(self) EvoHudInspectorPanel.build_right_scroll_inspector(self) EvoHudBannersToasts.build_banners_and_toasts(self) + EvoHudBannersToasts.build_event_feed_panel(self) _build_modals() _update_static_labels() _rebuild_sub_tool_options() @@ -326,14 +312,11 @@ func setup(world_state: EvoTypes.WorldState, lang: String = "es") -> void: func set_world_state(world_state: EvoTypes.WorldState) -> void: _world_state = world_state + _last_shown_macro_outcome = "" if _milestone_banner != null: _milestone_banner.visible = false - if _toast_vbox != null: - for child in _toast_vbox.get_children(): - child.queue_free() if _world_state != null: refresh_strategies(_world_state, _lang) - _last_seen_chronicle_count = _world_state.chronicle.size() if _selected_deme_id == "" or not _world_state.demes.has(_selected_deme_id): if not _world_state.demes.is_empty(): var first_id := str(_world_state.demes.keys()[0]) @@ -355,23 +338,19 @@ func refresh(world_state: EvoTypes.WorldState = null, is_playing_override: Varia if _world_state == null: return - if _world_state.chronicle.size() > _last_seen_chronicle_count: - for i in range(_last_seen_chronicle_count, _world_state.chronicle.size()): - var entry: EvoTypes.ChronicleEntry = _world_state.chronicle[i] - push_chronicle_toast(entry) - _last_seen_chronicle_count = _world_state.chronicle.size() - elif _world_state.chronicle.size() < _last_seen_chronicle_count: - _last_seen_chronicle_count = _world_state.chronicle.size() - - if _world_state.macro_outcome_type != "": + if _world_state.macro_outcome_type == "": + _last_shown_macro_outcome = "" + elif _world_state.macro_outcome_type != _last_shown_macro_outcome: + _last_shown_macro_outcome = _world_state.macro_outcome_type show_milestone_banner(_world_state.macro_outcome_title, _world_state.macro_outcome_summary, true) + EvoHudStatsStrip.refresh_stats_strip(self) + EvoHudBannersToasts.refresh_event_feed(self) _update_top_bar_state() _rebuild_villages_list() _rebuild_active_inspector_sheet() if _legend_panel != null and _legend_panel.visible: _rebuild_legend_content() - _update_first_steps_labels() _update_hotbar_visuals() if notebook_modal != null and notebook_modal.visible: @@ -382,12 +361,18 @@ func refresh(world_state: EvoTypes.WorldState = null, is_playing_override: Varia lab_modal.refresh(_world_state, _selected_deme_id) -func refresh_strategies(state: EvoTypes.WorldState, lang: String = "es") -> void: - EvoHudTopBar.refresh_strategies_for_hud(self, state, lang) +func refresh_inspector_only(state: EvoTypes.WorldState = null) -> void: + if state != null: + _world_state = state + if _world_state == null: + return + _update_top_bar_state() + _rebuild_villages_list() + _rebuild_active_inspector_sheet() -func refresh_legend(state: EvoTypes.WorldState, lang: String = "es") -> void: - EvoHudHotbarDock.refresh_legend_for_hud(self, state, lang) +func refresh_strategies(state: EvoTypes.WorldState, lang: String = "es") -> void: + EvoHudTopBar.refresh_strategies_for_hud(self, state, lang) func _rebuild_active_inspector_sheet() -> void: @@ -406,7 +391,6 @@ func select_hex(hex_id: String) -> void: if not cell.deme_ids.is_empty(): if not cell.deme_ids.has(_selected_deme_id): _selected_deme_id = cell.deme_ids[0] - _step_1_done = true if _inspector_tab == "habitat": _set_inspector_tab("deme") else: @@ -414,7 +398,6 @@ func select_hex(hex_id: String) -> void: _set_inspector_tab("habitat") _rebuild_villages_list() _rebuild_active_inspector_sheet() - _update_first_steps_labels() func select_deme(deme_id: String) -> void: @@ -423,14 +406,12 @@ func select_deme(deme_id: String) -> void: _selected_deme_id = deme_id var d: EvoTypes.Deme = _world_state.demes[deme_id] _selected_hex_id = d.hex_id - _step_1_done = true if _inspector_minimized: _on_toggle_inspector_minimize() if _inspector_tab == "habitat": _set_inspector_tab("deme") _rebuild_villages_list() _rebuild_active_inspector_sheet() - _update_first_steps_labels() func set_playing_state(is_playing: bool) -> void: @@ -453,16 +434,6 @@ func set_language(lang: String) -> void: refresh(_world_state) -func mark_tool_used() -> void: - _step_2_done = true - _update_first_steps_labels() - - -func mark_generation_advanced() -> void: - _step_3_done = true - _update_first_steps_labels() - - func get_active_tool() -> String: return _active_tool @@ -475,10 +446,6 @@ func get_brush_radius() -> int: return _brush_radius -func get_active_lens() -> String: - return _active_lens - - func open_notebook_modal() -> void: notebook_modal.open_modal(_world_state, _lang) @@ -499,14 +466,9 @@ func show_milestone_banner(title: String, summary: String, is_macro: bool = fals EvoHudBannersToasts.show_milestone_banner(self, title, summary, is_macro) -func push_chronicle_toast(entry: EvoTypes.ChronicleEntry) -> void: - EvoHudBannersToasts.push_chronicle_toast(self, entry) - - func _update_static_labels() -> void: EvoHudTopBar.update_static_labels(self) _update_hotbar_visuals() - _update_first_steps_labels() func _update_top_bar_state() -> void: @@ -537,14 +499,10 @@ func _rebuild_hex_sheet() -> void: EvoHudInspectorPanel.rebuild_hex_sheet(self) -func _build_deme_relations_lines(d: EvoTypes.Deme) -> Array[String]: +func _build_deme_relations_lines(d: EvoTypes.Deme) -> Array[Dictionary]: return EvoHudInspectorPanel.build_deme_relations_lines(_world_state, _lang, d) -func _update_first_steps_labels() -> void: - EvoHudBannersToasts.update_first_steps_labels(self) - - func _on_strategy_option_selected(_index: int) -> void: if _suppress_strategy_signal or strategy_a_option == null or strategy_b_option == null: return @@ -552,17 +510,11 @@ func _on_strategy_option_selected(_index: int) -> void: var sel_b := maxi(0, strategy_b_option.selected) var strat_a_id := str(strategy_a_option.get_item_metadata(sel_a)) if strategy_a_option.item_count > 0 else EvoCatalog.DEFAULT_STRATEGY_A_ID var strat_b_id := str(strategy_b_option.get_item_metadata(sel_b)) if strategy_b_option.item_count > 0 else EvoCatalog.DEFAULT_STRATEGY_B_ID - if top_bar != null: - top_bar.matchup_changed.emit(strat_a_id, strat_b_id) - else: - matchup_changed.emit(strat_a_id, strat_b_id) + matchup_changed.emit(strat_a_id, strat_b_id) func _on_matchup_reset_pressed() -> void: - if top_bar != null: - top_bar.matchup_reset_requested.emit() - else: - matchup_reset_requested.emit() + matchup_reset_requested.emit() func _on_village_selector_item_selected(index: int) -> void: @@ -585,7 +537,7 @@ func _on_village_cycle_pressed(step: int) -> void: func _inject_trait_into_deme(deme_id: String, trait_id: String) -> void: if _world_state != null: - EvoCatalog.inject_trait_into_deme(_world_state, deme_id, trait_id, _lang) + EvoCatalog.inject_trait_into_deme(_world_state, deme_id, trait_id) inject_trait_requested.emit(deme_id, trait_id) refresh(_world_state) @@ -629,15 +581,13 @@ func _on_hotbar_slot_pressed(slot_idx: int) -> void: var sub := str(def["sub"]) if sub != "": _active_sub_mode = sub - if _active_tool != "inspect": - _step_2_done = true _update_static_labels() _rebuild_sub_tool_options() tool_changed.emit(_active_tool, _active_sub_mode, _brush_radius) func _on_lens_cycle_btn_pressed() -> void: - var order := ["biome", "food", "health"] + var order := ["biome", "food", "health", "diplomacy", "territory"] var next_idx := (order.find(_active_lens) + 1) % order.size() _on_lens_pressed(str(order[next_idx])) @@ -659,7 +609,6 @@ func _on_export_pressed() -> void: EvoUiStyle.tr_text(_lang, "Guardado en %s" % DEFAULT_EXPORT_PATH, "Saved to %s" % DEFAULT_EXPORT_PATH), false ) - export_json_requested.emit(DEFAULT_EXPORT_PATH) func _on_import_pressed() -> void: @@ -673,15 +622,12 @@ func _on_import_pressed() -> void: var imported := EvoTypes.WorldState.from_dict(parsed) set_world_state(imported) state_imported.emit(imported) - import_json_requested.emit(DEFAULT_EXPORT_PATH) func _on_play_pressed() -> void: if _ollama_startup_blocked or (_missing_models_modal != null and _missing_models_modal.visible): return _is_playing = not _is_playing - if _is_playing: - _step_3_done = true _update_static_labels() play_pause_toggled.emit(_is_playing) @@ -689,15 +635,13 @@ func _on_play_pressed() -> void: func _on_step_pressed() -> void: if _ollama_startup_blocked or (_missing_models_modal != null and _missing_models_modal.visible): return - _step_3_done = true - _update_first_steps_labels() step_generation_requested.emit() func _on_speed_pressed() -> void: - _is_fast = not _is_fast + _speed_index = (_speed_index + 1) % 3 _update_static_labels() - speed_changed.emit(_is_fast) + speed_changed.emit(_speed_index) func _on_tree_btn_pressed() -> void: @@ -831,8 +775,6 @@ func _on_tool_button_pressed(tool_id: String) -> void: _active_sub_mode = "raise" elif tool_id == "adjust_carrying_capacity" and not (_active_sub_mode in ["enrich", "deplete"]): _active_sub_mode = "enrich" - elif tool_id != "inspect": - _step_2_done = true _update_static_labels() _rebuild_sub_tool_options() tool_changed.emit(_active_tool, _active_sub_mode, _brush_radius) @@ -840,16 +782,12 @@ func _on_tool_button_pressed(tool_id: String) -> void: func _on_sub_mode_selected(sub_mode: String) -> void: _active_sub_mode = sub_mode - _step_2_done = true _rebuild_sub_tool_options() _update_hotbar_visuals() - _update_first_steps_labels() tool_changed.emit(_active_tool, _active_sub_mode, _brush_radius) func _on_sea_level_btn_pressed(delta: float) -> void: - _step_2_done = true - _update_first_steps_labels() sea_level_change_requested.emit(delta) @@ -862,6 +800,8 @@ func _on_brush_radius_pressed(rad: int) -> void: func _on_lens_pressed(lens_id: String) -> void: _active_lens = lens_id _update_static_labels() + if _legend_panel != null and _legend_panel.visible: + _rebuild_legend_content() lens_changed.emit(_active_lens) diff --git a/src/ui/notebook_modal.gd b/src/ui/notebook_modal.gd index 80c3b37..7a173af 100644 --- a/src/ui/notebook_modal.gd +++ b/src/ui/notebook_modal.gd @@ -2,7 +2,6 @@ class_name EvoNotebookModal extends Control signal jump_to_hex_requested(hex_id: String, deme_id: String) -signal modal_closed() const FILTER_KEYS: Array[String] = [ "all", @@ -21,6 +20,7 @@ const FILTER_KEYS: Array[String] = [ "tragedy_of_commons", "cultural_loss", "extinction", + "epidemic", "single_survivor", "ecological_equilibrium" ] @@ -161,7 +161,6 @@ func set_filter(filter_key: String) -> void: func close_modal() -> void: visible = false - modal_closed.emit() func _update_labels() -> void: @@ -176,14 +175,29 @@ func _update_labels() -> void: _update_filter_buttons() +func _get_filter_label(f_key: String) -> String: + if f_key == "epidemic": + return EvoUiStyle.tr_text(_lang, "Epidemias", "Epidemics") + if f_key in ["war", "raid", "pact", "betrayal", "fortify", "merge", "tribute", "siege"]: + return EvoI18n.t(_lang, "filter_" + f_key) + return EvoUiStyle.get_event_type_label(f_key, _lang) + + +func _get_event_icon_tex(event_type: String) -> ImageTexture: + if event_type == "epidemic": + return EvoUiStyle.get_pixel_icon("status_starving") + if event_type in ["war", "siege", "raid", "betrayal"]: + return EvoUiStyle.get_pixel_icon("icon_dispute") + if event_type in ["pact", "merge", "walled_union", "symbiosis"]: + return EvoUiStyle.get_pixel_icon("icon_cooperate") + return EvoUiStyle.get_pixel_icon("icon_innovate") + + func _update_filter_buttons() -> void: for f_key in FILTER_KEYS: if _filter_buttons.has(f_key): var btn: Button = _filter_buttons[f_key] - if f_key in ["war", "raid", "pact", "betrayal", "fortify", "merge", "tribute", "siege"]: - btn.text = EvoI18n.t(_lang, "filter_" + f_key) - else: - btn.text = EvoUiStyle.get_event_type_label(f_key, _lang) + btn.text = _get_filter_label(f_key) EvoUiStyle.apply_button_active(btn, _active_filter == f_key) @@ -243,6 +257,16 @@ func _create_entry_card(entry: EvoTypes.ChronicleEntry) -> Control: top_hbox.add_theme_constant_override("separation", 8) vbox.add_child(top_hbox) + var icon_tex := _get_event_icon_tex(entry.event_type) + if icon_tex != null: + var ev_icon := TextureRect.new() + ev_icon.custom_minimum_size = Vector2(20, 20) + ev_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + ev_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + ev_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + ev_icon.texture = icon_tex + top_hbox.add_child(ev_icon) + var badge := PanelContainer.new() badge.add_theme_stylebox_override("panel", EvoUiStyle.make_badge_style(ev_color)) top_hbox.add_child(badge) diff --git a/src/ui/tree_modal.gd b/src/ui/tree_modal.gd index bc34644..06409e2 100644 --- a/src/ui/tree_modal.gd +++ b/src/ui/tree_modal.gd @@ -2,7 +2,6 @@ class_name EvoTreeModal extends Control signal lineage_highlight_requested(lineage_id: String) -signal modal_closed() var _world_state: EvoTypes.WorldState = null var _lang: String = "es" @@ -349,7 +348,6 @@ func set_language(lang: String) -> void: func close_modal() -> void: visible = false - modal_closed.emit() func _update_labels() -> void: diff --git a/src/ui/ui_style.gd b/src/ui/ui_style.gd index 437e8b0..b007956 100644 --- a/src/ui/ui_style.gd +++ b/src/ui/ui_style.gd @@ -7,7 +7,6 @@ const COLOR_WOOD_LIGHT := Color("#7a4524") const COLOR_WOOD_PLANK := Color("#6b3b1e") const COLOR_GOLD_BEVEL := Color("#d99b43") const COLOR_GOLD_BRIGHT := Color("#f59e0b") -const COLOR_BRONZE_BORDER := Color("#b07228") const COLOR_PARCHMENT := Color("#f6e7c8") const COLOR_PARCHMENT_ALT := Color("#edd6a8") @@ -352,28 +351,28 @@ static func style_progress_bar(bar: ProgressBar, fill_color: Color) -> void: bar.add_theme_stylebox_override("fill", make_progress_fill(fill_color)) -static func event_color(ev_type: String) -> Color: - return get_event_type_color(ev_type) - - -static func event_label(ev_type: String, lang: String = "es") -> String: - return get_event_type_label(ev_type, lang) - - static func get_event_type_color(event_type: String) -> Color: match event_type: "war": return Color("ef4444") + "war_repelled": + return Color("#ea580c") "raid": return Color("f97316") + "pillage": + return Color("#ea580c") "pact": return Color("10b981") "betrayal": return Color("be123c") + "vendetta": + return Color("#be123c") "fortify", "defense": return Color("#3b82f6") - "merge": + "merge", "walled_union", "absorption": return Color("#eab308") + "colonize": + return Color("#0ea5e9") "tribute": return Color("#f59e0b") "siege": @@ -386,12 +385,18 @@ static func get_event_type_color(event_type: String) -> Color: return Color("#2563eb") "symbiosis": return Color("#15803d") - "tragedy_of_commons": + "tragedy_of_commons", "famine": return Color("#d97706") "cultural_loss": return Color("#c2410c") "extinction": return Color("#b91c1c") + "epidemic": + return Color("#65a30d") + "epidemic_spread": + return Color("#84cc16") + "epidemic_recovered": + return Color("#06b6d4") "total_extinction": return Color("#991b1b") "single_survivor": @@ -407,16 +412,26 @@ static func get_event_type_label(event_type: String, lang: String = "es") -> Str return tr_text(lang, "Todos", "All") "war": return EvoI18n.t(lang, "event_war") + "war_repelled": + return tr_text(lang, "Guerra Repelida", "War Repelled") "raid": return EvoI18n.t(lang, "event_raid") + "pillage": + return tr_text(lang, "Saqueo Rural", "Rural Pillage") "pact": return EvoI18n.t(lang, "event_pact") "betrayal": return EvoI18n.t(lang, "event_betrayal") + "vendetta": + return tr_text(lang, "Vendetta", "Vendetta") "fortify", "defense": return EvoI18n.t(lang, "event_fortify") "merge": return EvoI18n.t(lang, "event_merge") + "absorption": + return tr_text(lang, "Absorción Urbana", "Urban Absorption") + "colonize": + return tr_text(lang, "Colonización", "Colonization") "tribute": return EvoI18n.t(lang, "event_tribute") "siege": @@ -431,10 +446,20 @@ static func get_event_type_label(event_type: String, lang: String = "es") -> Str return tr_text(lang, "Simbiosis", "Symbiosis") "tragedy_of_commons": return tr_text(lang, "Tragedia de los Comunes", "Tragedy of the Commons") + "famine": + return tr_text(lang, "Hambruna", "Famine") "cultural_loss": return tr_text(lang, "Pérdida Cultural", "Cultural Loss") "extinction": return tr_text(lang, "Extinción", "Extinction") + "epidemic": + return tr_text(lang, "Epidemia", "Epidemic") + "epidemic_spread": + return tr_text(lang, "Contagio Epidémico", "Epidemic Contagion") + "epidemic_recovered": + return tr_text(lang, "Inmunidad Adquirida", "Immunity Acquired") + "walled_union": + return tr_text(lang, "Unión Amurallada", "Walled Union") "total_extinction": return EvoI18n.t(lang, "chronicle.type.total_extinction") "single_survivor": @@ -516,33 +541,6 @@ static func get_archetype_label(archetype: String, lang: String = "es") -> Strin return archetype -static func get_decision_icon_key(decision_id: String, state: EvoTypes.WorldState) -> String: - var arch := "forage" - if state != null and state.decisions_catalog.has(decision_id): - arch = (state.decisions_catalog[decision_id] as EvoTypes.DecisionDef).archetype - elif "cooperate" in decision_id: - arch = "cooperate" - elif "migrate" in decision_id or "migration" in decision_id: - arch = "migrate" - elif "exchange" in decision_id or "symbiosis" in decision_id: - arch = "exchange" - elif "dispute" in decision_id: - arch = "dispute" - elif "overexploit" in decision_id: - arch = "overexploit" - elif "innovate" in decision_id: - arch = "innovate" - elif "war" in decision_id: - arch = "war" - elif "raid" in decision_id: - arch = "raid" - elif "pact" in decision_id: - arch = "pact" - elif "betrayal" in decision_id: - arch = "betrayal" - return "icon_" + arch - - static func get_vital_status_label(status: String, lang: String = "es") -> String: match status: "thriving": @@ -569,28 +567,13 @@ static func get_vital_status_heart(status: String) -> String: return "💚" -static func get_vital_status_color(status: String) -> Color: - match status: - "thriving": - return Color("#16a34a") - "stable": - return Color("#0284c7") - "struggling", "declining": - return Color("#d97706") - "starving": - return Color("#dc2626") - return COLOR_SEPIA - - -static func get_village_tier_label(population: int, lang: String = "es", merged_count: int = 0) -> String: - if merged_count >= 1 and population >= 200: - return tr_text(lang, "V · Metrópolis", "V · Metropolis") - if population >= 150: - return tr_text(lang, "IV · Poblado", "IV · Settlement") - if population >= 85: - return tr_text(lang, "III · Aldea", "III · Village") - if population >= 35: - return tr_text(lang, "II · Caserío", "II · Hamlet") +static func get_village_tier_label(population: int, lang: String = "es", merged_count: int = 0, defense_tier: int = 0, walled_union_id: String = "") -> String: + if population >= 380 or (population >= 280 and (defense_tier >= 2 or merged_count >= 1 or walled_union_id != "")): + return tr_text(lang, "IV · Metrópolis", "IV · Metropolis") + if population >= 240 or (population >= 170 and (defense_tier >= 1 or merged_count >= 1)): + return tr_text(lang, "III · Villa", "III · Town") + if population >= 120 or defense_tier == 1: + return tr_text(lang, "II · Aldea", "II · Village") return tr_text(lang, "I · Campamento", "I · Camp") @@ -623,8 +606,6 @@ static func get_pixel_icon(icon_key: String) -> ImageTexture: "adjust_carrying_capacity": "tool_fertility", "sea_level": "tool_sealevel", "slot_sealevel": "tool_sealevel", - "season_wet": "season_wet", - "season_dry": "season_dry", "tropical_forest": "tree_tropical", "slot_tropical": "tree_tropical", "savanna": "tree_savanna", @@ -635,27 +616,19 @@ static func get_pixel_icon(icon_key: String) -> ImageTexture: "slot_tundra": "tree_tundra", "high_mountain": "rock_mountain", "coastal_shallows": "coastal_reeds", - "status_thriving": "status_thriving", - "status_stable": "status_stable", - "status_struggling": "status_struggling", "status_starving": "status_starving", "icon_forage": "icon_forage", - "icon_overexploit": "icon_overexploit", - "icon_migrate": "icon_migrate", "icon_cooperate": "icon_cooperate", - "icon_exchange": "icon_exchange", "icon_dispute": "icon_dispute", "icon_innovate": "icon_innovate", - "icon_custom": "icon_custom", "icon_war": "icon_war", "icon_raid": "icon_raid", "icon_pact": "icon_pact", - "icon_betrayal": "icon_betrayal", - "icon_defense": "icon_defense", - "icon_fortify": "icon_defense", + "icon_vendetta": "icon_vendetta", "icon_merge": "icon_merge", "icon_tribute": "icon_tribute", - "icon_siege": "icon_siege" + "icon_epidemic": "icon_epidemic", + "icon_immune": "icon_immune" } if sprite_map.has(icon_key): var tex_from_gen := EvoPixelArtGen.get_texture(str(sprite_map[icon_key])) diff --git a/src/world/camera_controller.gd b/src/world/camera_controller.gd index 7a50d0b..0ed5ef5 100644 --- a/src/world/camera_controller.gd +++ b/src/world/camera_controller.gd @@ -5,19 +5,19 @@ const MIN_PITCH_DEG: float = 28.0 const MAX_PITCH_DEG: float = 75.0 const DEFAULT_PITCH_DEG: float = 48.0 const DEFAULT_YAW_DEG: float = 0.0 -const MIN_ZOOM: float = 0.28 +const MIN_ZOOM: float = 0.12 const MAX_ZOOM: float = 3.00 const DEFAULT_ZOOM: float = 0.82 const DEFAULT_PAN: Vector2 = Vector2(-175.0, 26.0) const MAX_PAN_RADIUS: float = 3800.0 const MAX_COLUMN_HEIGHT_PX: float = 96.0 +const FULL_MAP_WORLD_SIZE: Vector2 = Vector2(6235.38, 4178.0) var yaw_deg: float = DEFAULT_YAW_DEG var pitch_deg: float = DEFAULT_PITCH_DEG var zoom_level: float = DEFAULT_ZOOM var pan_offset: Vector2 = DEFAULT_PAN -var _target_yaw_deg: float = DEFAULT_YAW_DEG var _target_pitch_deg: float = DEFAULT_PITCH_DEG var _target_zoom: float = DEFAULT_ZOOM var _target_pan: Vector2 = DEFAULT_PAN @@ -28,6 +28,10 @@ var _is_orbiting: bool = false var _drag_last_pos: Vector2 = Vector2.ZERO +func is_dragging() -> bool: + return _is_panning or _is_orbiting + + func _ready() -> void: set_process(true) set_process_unhandled_input(true) @@ -65,7 +69,7 @@ func _process(delta: float) -> void: if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): _target_pan.x -= pan_speed - _clamp_targets() + clamp_target() var lerp_t: float = clampf(delta * 12.0, 0.0, 1.0) pan_offset = pan_offset.lerp(_target_pan, lerp_t) @@ -75,13 +79,12 @@ func _process(delta: float) -> void: if absf(_target_zoom - zoom_level) < 0.001: zoom_level = _target_zoom - # Yaw/pitch changes force a geometry rebuild, so snap once visually settled - # instead of easing asymptotically for dozens of frames. + # Pitch changes force a geometry rebuild, so snap once visually settled + # instead of easing asymptotically for dozens of frames. Yaw is locked at 0.0. pitch_deg = lerpf(pitch_deg, _target_pitch_deg, lerp_t) if absf(_target_pitch_deg - pitch_deg) < 0.05: pitch_deg = _target_pitch_deg - var yaw_diff: float = wrapf(_target_yaw_deg - yaw_deg, -180.0, 180.0) - yaw_deg = _target_yaw_deg if absf(yaw_diff) < 0.05 else fposmod(yaw_deg + yaw_diff * lerp_t, 360.0) + yaw_deg = 0.0 func _unhandled_input(event: InputEvent) -> void: @@ -120,30 +123,23 @@ func _unhandled_input(event: InputEvent) -> void: var delta_pos: Vector2 = mm.position - _drag_last_pos _drag_last_pos = mm.position if _is_orbiting: - _target_yaw_deg = fposmod(_target_yaw_deg + delta_pos.x * 0.45, 360.0) _target_pitch_deg = clampf(_target_pitch_deg - delta_pos.y * 0.25, MIN_PITCH_DEG, MAX_PITCH_DEG) - yaw_deg = _target_yaw_deg + yaw_deg = 0.0 pitch_deg = _target_pitch_deg elif _is_panning: _target_pan += delta_pos - _clamp_targets() + clamp_target() pan_offset = _target_pan elif event is InputEventKey: - if get_viewport() != null and get_viewport().gui_get_focus_owner() is LineEdit: - return var ke := event as InputEventKey if not ke.pressed or ke.echo: return match ke.keycode: - KEY_Q: - rotate_by(-18.0) - KEY_E: - rotate_by(18.0) KEY_R: tilt_by(8.0) - KEY_F: - tilt_by(-8.0) + KEY_F, KEY_0, KEY_KP_0: + apply_preset("fit") KEY_PLUS, KEY_EQUAL, KEY_KP_ADD: zoom_by(0.20) KEY_MINUS, KEY_KP_SUBTRACT: @@ -152,14 +148,13 @@ func _unhandled_input(event: InputEvent) -> void: reset_camera() -# Yaw rotation + pitch foreshortening of the ground plane. Zoom and pan are applied +# Pitch foreshortening of the ground plane. Zoom and pan are applied # by the renderer as a node transform, so they never invalidate cached geometry. func ground_transform() -> Transform2D: - var yaw_rad := deg_to_rad(yaw_deg) var sin_p := sin(deg_to_rad(pitch_deg)) return Transform2D( - Vector2(cos(yaw_rad), sin(yaw_rad) * sin_p), - Vector2(-sin(yaw_rad), cos(yaw_rad) * sin_p), + Vector2(1.0, 0.0), + Vector2(0.0, sin_p), Vector2.ZERO ) @@ -169,13 +164,7 @@ func height_scale() -> float: func get_depth(world_xy: Vector2, z_height: float) -> float: - var yaw_rad := deg_to_rad(yaw_deg) - var rot_y: float = world_xy.x * sin(yaw_rad) + world_xy.y * cos(yaw_rad) - return rot_y + z_height * 0.02 - - -func rotate_by(delta_deg: float) -> void: - _target_yaw_deg = fposmod(_target_yaw_deg + delta_deg, 360.0) + return world_xy.y + z_height * 0.02 func tilt_by(delta_deg: float) -> void: @@ -186,6 +175,37 @@ func set_pitch_preset(target_deg: float) -> void: _target_pitch_deg = clampf(target_deg, MIN_PITCH_DEG, MAX_PITCH_DEG) +func fit_full_map() -> void: + var vp := get_viewport_rect().size if is_inside_tree() else Vector2(1600.0, 960.0) + if vp.x <= 0.0 or vp.y <= 0.0: + vp = Vector2(1600.0, 960.0) + var avail_w := maxf(320.0, vp.x - 420.0) + var avail_h := maxf(240.0, vp.y - 210.0) + var fit_zoom := clampf( + minf(avail_w / FULL_MAP_WORLD_SIZE.x, avail_h / FULL_MAP_WORLD_SIZE.y), + MIN_ZOOM, + MAX_ZOOM + ) + _target_zoom = fit_zoom + zoom_level = fit_zoom + _target_pan = Vector2.ZERO + pan_offset = Vector2.ZERO + + +func apply_preset(preset_id: String) -> void: + match preset_id: + "fit", "map", "full": + fit_full_map() + "1", "low", "32": + set_pitch_preset(32.0) + "2", "mid", "48": + set_pitch_preset(48.0) + "3", "high", "68": + set_pitch_preset(68.0) + "4", "reset", "default": + reset_camera() + + func zoom_by(delta_zoom: float, pivot_screen_offset: Vector2 = Vector2.ZERO) -> void: var next_zoom := clampf(_target_zoom + delta_zoom, MIN_ZOOM, MAX_ZOOM) if absf(next_zoom - _target_zoom) < 0.0001: @@ -193,11 +213,10 @@ func zoom_by(delta_zoom: float, pivot_screen_offset: Vector2 = Vector2.ZERO) -> var ratio := next_zoom / _target_zoom _target_pan = pivot_screen_offset + (_target_pan - pivot_screen_offset) * ratio _target_zoom = next_zoom - _clamp_targets() + clamp_target() func reset_camera() -> void: - _target_yaw_deg = DEFAULT_YAW_DEG _target_pitch_deg = DEFAULT_PITCH_DEG _target_zoom = DEFAULT_ZOOM _target_pan = DEFAULT_PAN @@ -207,22 +226,36 @@ func focus_on_hex(q: int, r: int, altitude: float) -> void: var world_xy := HexMath.axial_to_world(q, r) var tier: float = clampf(ceilf(altitude * 10.0), 1.0, 10.0) var z_height: float = (tier / 10.0) * MAX_COLUMN_HEIGHT_PX - var yaw_rad := deg_to_rad(_target_yaw_deg) var pitch_rad := deg_to_rad(_target_pitch_deg) - var rot_x: float = world_xy.x * cos(yaw_rad) - world_xy.y * sin(yaw_rad) - var rot_y: float = world_xy.x * sin(yaw_rad) + world_xy.y * cos(yaw_rad) var proj_offset := Vector2( - rot_x * _target_zoom, - (rot_y * sin(pitch_rad) - z_height * cos(pitch_rad) * 1.18) * _target_zoom + world_xy.x * _target_zoom, + (world_xy.y * sin(pitch_rad) - z_height * cos(pitch_rad) * 1.18) * _target_zoom ) _target_pan = -proj_offset - _clamp_targets() + clamp_target() -func _clamp_targets() -> void: +func clamp_target() -> void: + yaw_deg = 0.0 _target_pitch_deg = clampf(_target_pitch_deg, MIN_PITCH_DEG, MAX_PITCH_DEG) _target_zoom = clampf(_target_zoom, MIN_ZOOM, MAX_ZOOM) + var vp := get_viewport_rect().size if is_inside_tree() else Vector2(1600.0, 960.0) + if vp.x <= 0.0 or vp.y <= 0.0: + vp = Vector2(1600.0, 960.0) + var half_vp := vp * 0.5 + var limit_x := (FULL_MAP_WORLD_SIZE.x * 0.5) * _target_zoom + var limit_y := (FULL_MAP_WORLD_SIZE.y * 0.5) * _target_zoom + if half_vp.x > limit_x: + var slack_x := maxf(120.0, (half_vp.x - limit_x) * 0.65) + _target_pan.x = clampf(_target_pan.x, -slack_x, slack_x) + if not _is_panning: + _target_pan.x = lerpf(_target_pan.x, 0.0, 0.25) + if half_vp.y > limit_y: + var slack_y := maxf(90.0, (half_vp.y - limit_y) * 0.65) + _target_pan.y = clampf(_target_pan.y, -slack_y, slack_y) + if not _is_panning: + _target_pan.y = lerpf(_target_pan.y, 0.0, 0.25) var max_pan := MAX_PAN_RADIUS * maxf(1.0, _target_zoom / DEFAULT_ZOOM) if _target_pan.length() > max_pan: _target_pan = _target_pan.normalized() * max_pan diff --git a/src/world/pixel_art_gen.gd b/src/world/pixel_art_gen.gd index 20b5178..b58088c 100644 --- a/src/world/pixel_art_gen.gd +++ b/src/world/pixel_art_gen.gd @@ -1,8 +1,9 @@ class_name EvoPixelArtGen extends EvoPixelCanvas +const ASSET_SCHEMA_VERSION: String = "v4_readability" + static var _texture_cache: Dictionary = {} -static var _image_cache: Dictionary = {} static var _villager_colored_cache: Dictionary = {} static var _initialized: bool = false @@ -33,7 +34,15 @@ const TERRAIN_SPRITES: Array[String] = [ "coastal_reeds", "tree_stump", "soil_crack", - "wildflowers" + "wildflowers", + "mountain_peak", + "tree_canopy", + "palm_cluster", + "savanna_tree", + "acacia_tree", + "bush_cluster", + "marsh_reeds", + "pine_tree" ] const VILLAGE_SPRITES: Array[String] = [ @@ -48,6 +57,26 @@ const VILLAGE_SPRITES: Array[String] = [ "tier4_windmill", "tier4_tower", "tier5_metropolis", + "hut_thatch", + "house_clay", + "house_stone", + "campfire", + "totem_coop", + "totem_aggro", + "totem_innov", + "granary", + "palisade_wall", + "watchtower", + "temple_totem", + "citadel_keep", + "stone_wall", + "siege_totem", + "windmill", + "cathedral", + "crop_field", + "pasture_pen", + "covered_wagon", + "ruins_stone", "prop_unity_monument", "prop_defense_palisade", "prop_defense_wall", @@ -114,9 +143,58 @@ const UI_SPRITES: Array[String] = [ "icon_coop_link", "icon_conflict_swords", "icon_broken_link", - "thought_bubble" + "icon_epidemic", + "icon_famine", + "icon_immune", + "icon_vendetta", + "icon_casualties", + "icon_loot", + "icon_migrants" ] +const ALIAS_MAP: Dictionary = { + "tree_canopy": "tree_tropical", + "palm_cluster": "tree_tropical_2", + "savanna_tree": "tree_savanna", + "acacia_tree": "tree_savanna_2", + "pine_tree": "tree_tundra", + "bush_cluster": "bush_arid", + "marsh_reeds": "coastal_reeds", + "mountain_peak": "rock_mountain", + "tier1_tent": "hut_thatch", + "tier1_campfire": "campfire", + "tier2_cottage": "house_clay", + "tier2_barn": "granary", + "tier3_townhall": "temple_totem", + "tier3_house": "house_stone", + "tier4_citadel": "citadel_keep", + "tier4_windmill": "windmill", + "tier4_tower": "watchtower", + "tier5_metropolis": "cathedral", + "prop_defense_palisade": "palisade_wall", + "prop_defense_wall": "stone_wall", + "prop_defense_fortress": "watchtower", + "prop_siege_engine": "siege_totem", + "settler_wagon": "covered_wagon", + "caravan_cart": "covered_wagon", + "ruins": "ruins_stone" +} + + +static func _resolve_alias(sprite_name: String) -> String: + var cur := sprite_name + while ALIAS_MAP.has(cur): + cur = str(ALIAS_MAP[cur]) + return cur + + +static func _canonical_sprite_path(sprite_name: String) -> String: + if sprite_name.begins_with("hex_tile_") or sprite_name in TERRAIN_SPRITES: + return "res://assets/sprites/terrain/%s.png" % sprite_name + if sprite_name in UI_SPRITES: + return "res://assets/sprites/ui/%s.png" % sprite_name + return "res://assets/sprites/villages/%s.png" % sprite_name + static func ensure_all_assets(force_rebuild: bool = false) -> void: if _initialized and not force_rebuild: @@ -126,14 +204,23 @@ static func ensure_all_assets(force_rebuild: bool = false) -> void: DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://assets/sprites/villages")) DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://assets/sprites/ui")) + var schema_path := ProjectSettings.globalize_path("res://assets/sprites/schema_version.txt") + var current_schema := FileAccess.get_file_as_string(schema_path).strip_edges() if FileAccess.file_exists(schema_path) else "" + var should_rebuild := force_rebuild or current_schema != ASSET_SCHEMA_VERSION + for s_name in HEX_TILE_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/terrain/%s.png" % s_name, force_rebuild) + _generate_and_register(s_name, "res://assets/sprites/terrain/%s.png" % s_name, should_rebuild) for s_name in TERRAIN_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/terrain/%s.png" % s_name, force_rebuild) + _generate_and_register(s_name, "res://assets/sprites/terrain/%s.png" % s_name, should_rebuild) for s_name in VILLAGE_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/villages/%s.png" % s_name, force_rebuild) + _generate_and_register(s_name, "res://assets/sprites/villages/%s.png" % s_name, should_rebuild) for s_name in UI_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/ui/%s.png" % s_name, force_rebuild) + _generate_and_register(s_name, "res://assets/sprites/ui/%s.png" % s_name, should_rebuild) + + if should_rebuild: + var f := FileAccess.open(schema_path, FileAccess.WRITE) + if f != null: + f.store_string(ASSET_SCHEMA_VERSION) static func get_texture(sprite_name: String) -> ImageTexture: @@ -141,20 +228,15 @@ static func get_texture(sprite_name: String) -> ImageTexture: ensure_all_assets() if _texture_cache.has(sprite_name): return _texture_cache[sprite_name] - var img := _build_sprite_image(sprite_name) - var tex := ImageTexture.create_from_image(img) - _image_cache[sprite_name] = img - _texture_cache[sprite_name] = tex - return tex - - -static func get_image(sprite_name: String) -> Image: - if not _initialized: - ensure_all_assets() - if _image_cache.has(sprite_name): - return _image_cache[sprite_name] - get_texture(sprite_name) - return _image_cache[sprite_name] + var canonical := _resolve_alias(sprite_name) + if _texture_cache.has(canonical): + _texture_cache[sprite_name] = _texture_cache[canonical] + return _texture_cache[canonical] + var img := _build_sprite_image(canonical) + img.generate_mipmaps() + _texture_cache[canonical] = ImageTexture.create_from_image(img) + _texture_cache[sprite_name] = _texture_cache[canonical] + return _texture_cache[canonical] static func get_villager_texture(pose: String, tunic_color: Color) -> ImageTexture: @@ -169,23 +251,46 @@ static func get_villager_texture(pose: String, tunic_color: Color) -> ImageTextu elif pose == "villager_walk2": step = 2 var img := EvoVillageUiSprites.draw_villager(step, tunic_color) + img.generate_mipmaps() var tex := ImageTexture.create_from_image(img) _villager_colored_cache[key] = tex return tex +static func _min_expected_width(sprite_name: String) -> int: + if sprite_name.begins_with("hex_tile_"): + return 256 + if sprite_name.begins_with("villager_"): + return 48 + if sprite_name in UI_SPRITES or sprite_name.begins_with("icon_"): + return 64 + return 128 + + static func _generate_and_register(sprite_name: String, file_path: String, force_rebuild: bool = false) -> void: + var canonical := _resolve_alias(sprite_name) var abs_path := ProjectSettings.globalize_path(file_path) + if canonical != sprite_name: + var canon_rel := _canonical_sprite_path(canonical) + if force_rebuild or not _texture_cache.has(canonical): + _generate_and_register(canonical, canon_rel, force_rebuild) + if force_rebuild or not FileAccess.file_exists(abs_path): + DirAccess.copy_absolute(ProjectSettings.globalize_path(canon_rel), abs_path) + _texture_cache[sprite_name] = _texture_cache[canonical] + return + var img: Image + var min_w := _min_expected_width(sprite_name) if not force_rebuild and FileAccess.file_exists(abs_path): img = Image.load_from_file(abs_path) - if sprite_name.begins_with("hex_tile_") and img.get_width() < 224: + if img == null or img.get_width() < min_w: img = _build_sprite_image(sprite_name) img.save_png(abs_path) else: img = _build_sprite_image(sprite_name) img.save_png(abs_path) - _image_cache[sprite_name] = img + + img.generate_mipmaps() _texture_cache[sprite_name] = ImageTexture.create_from_image(img) diff --git a/src/world/render/event_fx_renderer.gd b/src/world/render/event_fx_renderer.gd new file mode 100644 index 0000000..8047183 --- /dev/null +++ b/src/world/render/event_fx_renderer.gd @@ -0,0 +1,289 @@ +class_name EvoEventFxRenderer +extends RefCounted + +const MAJOR_TYPES: Array[String] = [ + "war", "war_repelled", "siege", "betrayal", "raid", + "epidemic", "epidemic_spread", "famine", "extinction", + "pact", "walled_union", "absorption", "allopatric_speciation" +] + +const DIRECTIONAL_TYPES: Array[String] = [ + "war", "war_repelled", "raid", "tribute", "betrayal", + "colonize", "absorption", "epidemic_spread", "symbiosis", "pillage" +] + +const BILATERAL_TYPES: Array[String] = ["pact", "walled_union"] + + +static func enqueue_turn(r: Node2D, events: Array[Dictionary], replay_seconds: float) -> void: + r._fx_active.clear() + if events.is_empty(): + return + + var major: Array[Dictionary] = [] + var minor: Array[Dictionary] = [] + for ev in events: + if MAJOR_TYPES.has(str(ev["type"])): + major.append(ev) + else: + minor.append(ev) + + for ev in minor: + var item := ev.duplicate() + item["start"] = r._anim_time + item["life"] = 1.35 + item["minor"] = true + r._fx_active.append(item) + + if not major.is_empty(): + var slot := clampf(replay_seconds * 0.72 / maxf(1.0, float(major.size())), 0.12, 0.85) + var major_life := maxf(1.5, slot * 2.1) + for i in range(major.size()): + var item := major[i].duplicate() + item["start"] = r._anim_time + float(i) * slot + item["life"] = major_life + item["minor"] = false + r._fx_active.append(item) + + +static func _get_event_icon_name(ev_type: String) -> String: + match ev_type: + "war": + return "icon_war" + "war_repelled", "fortify", "defense": + return "icon_defense" + "siege": + return "icon_siege" + "raid", "pillage": + return "icon_raid" + "betrayal": + return "icon_betrayal" + "vendetta": + return "icon_vendetta" + "pact": + return "icon_pact" + "tribute": + return "icon_tribute" + "walled_union", "absorption", "merge": + return "icon_merge" + "colonize": + return "icon_migrate" + "epidemic", "epidemic_spread": + return "icon_epidemic" + "epidemic_recovered": + return "icon_immune" + "famine": + return "icon_famine" + "extinction": + return "icon_casualties" + "allopatric_speciation", "cultural_innovation", "baldwin_trait": + return "icon_innovate" + "symbiosis": + return "icon_exchange" + return "icon_custom" + + +static func _sample_bezier(p_a: Vector2, ctrl: Vector2, p_b: Vector2, t: float) -> Vector2: + var u := 1.0 - t + return (u * u) * p_a + (2.0 * u * t) * ctrl + (t * t) * p_b + + +static func _draw_icon_medallion(ci: CanvasItem, pos: Vector2, icon_name: String, border_col: Color, alpha: float) -> void: + var bg := Color(0.10, 0.06, 0.03, 0.92 * alpha) + var b_col := border_col.lightened(0.15) + b_col.a = alpha + ci.draw_circle(pos, 12.0, bg) + ci.draw_arc(pos, 12.0, 0.0, TAU, 20, b_col, 2.0) + var tex: ImageTexture = EvoPixelArtGen.get_texture(icon_name) + ci.draw_texture_rect( + tex, + Rect2(pos - Vector2(9.0, 9.0), Vector2(18.0, 18.0)), + false, + Color(1.0, 1.0, 1.0, alpha) + ) + + +static func draw_event_underlay(r: Node2D) -> void: + if r._fx_active.is_empty(): + return + var ci: Node2D = r._overlay_layer + var to_screen: Transform2D = r._world_layer.transform + var now: float = r._anim_time + + var idx: int = (r._fx_active as Array).size() - 1 + while idx >= 0: + var ev: Dictionary = r._fx_active[idx] + if now > float(ev["start"]) + float(ev["life"]): + r._fx_active.remove_at(idx) + idx -= 1 + + for ev in r._fx_active: + var start: float = float(ev["start"]) + if now < start: + continue + var life: float = float(ev["life"]) + var t := clampf((now - start) / maxf(0.01, life), 0.0, 1.0) + var base_alpha := 0.65 if bool(ev["minor"]) else 0.96 + var fade := minf(clampf(t / 0.12, 0.0, 1.0), clampf((1.0 - t) / 0.25, 0.0, 1.0)) + var alpha := base_alpha * fade + if alpha <= 0.02: + continue + + var ev_type := str(ev["type"]) + var from_hex := str(ev["from_hex"]) + var to_hex := str(ev["to_hex"]) + var col := EvoUiStyle.get_event_type_color(ev_type) + var icon_name := _get_event_icon_name(ev_type) + + if from_hex != to_hex and r._hex_centers.has(from_hex) and r._hex_centers.has(to_hex): + var p_a: Vector2 = to_screen * (r._hex_centers[from_hex] as Vector2) + var p_b: Vector2 = to_screen * (r._hex_centers[to_hex] as Vector2) + var diff := p_b - p_a + var dist := diff.length() + if dist > 4.0: + var dir := diff / dist + var perp := Vector2(-dir.y, dir.x) + var start_pt := p_a + dir * minf(24.0, dist * 0.18) + var end_pt := p_b - dir * minf(28.0, dist * 0.22) + + if BILATERAL_TYPES.has(ev_type): + var pulse := 0.75 + 0.25 * sin(now * 8.0) + var glow_col := col + glow_col.a = 0.36 * alpha * pulse + var line_col := col.lightened(0.20) + line_col.a = alpha + ci.draw_line(start_pt, end_pt, glow_col, 10.0) + ci.draw_line(start_pt + perp * 3.0, end_pt + perp * 3.0, line_col, 2.6) + ci.draw_line(start_pt - perp * 3.0, end_pt - perp * 3.0, line_col, 2.6) + _draw_icon_medallion(ci, (start_pt + end_pt) * 0.5, icon_name, col, alpha) + elif DIRECTIONAL_TYPES.has(ev_type) or from_hex != to_hex: + var ctrl := (start_pt + end_pt) * 0.5 + perp * minf(dist * 0.18, 36.0) + var head_t := clampf(t / 0.35, 0.08, 1.0) + var steps := 16 + var pts := PackedVector2Array() + for s_i in range(steps + 1): + var st := (float(s_i) / float(steps)) * head_t + pts.append(_sample_bezier(start_pt, ctrl, end_pt, st)) + + var shadow_col := Color(0.08, 0.04, 0.02, 0.72 * alpha) + var arc_col := col.lightened(0.12) + arc_col.a = alpha + ci.draw_polyline(pts, shadow_col, 6.5) + ci.draw_polyline(pts, arc_col, 3.8) + + var tip := pts[pts.size() - 1] + var prev := pts[maxi(0, pts.size() - 2)] + var tip_dir := (tip - prev).normalized() if tip.distance_to(prev) > 0.01 else dir + var tip_perp := Vector2(-tip_dir.y, tip_dir.x) + var tri := PackedVector2Array([ + tip + tip_dir * 7.0, + tip - tip_dir * 8.0 + tip_perp * 6.5, + tip - tip_dir * 8.0 - tip_perp * 6.5 + ]) + ci.draw_colored_polygon(tri, arc_col) + ci.draw_polyline(PackedVector2Array([tri[0], tri[1], tri[2], tri[0]]), shadow_col, 1.6) + + var mid_pt := _sample_bezier(start_pt, ctrl, end_pt, 0.5 * head_t) + _draw_icon_medallion(ci, mid_pt, icon_name, col, alpha) + + if (from_hex == to_hex or not BILATERAL_TYPES.has(ev_type)) and r._hex_polys.has(to_hex) and r._hex_centers.has(to_hex): + var center: Vector2 = r._hex_centers[to_hex] + var top_pts: PackedVector2Array = r._hex_polys[to_hex] + var wave_t := fposmod(t * 1.4, 1.0) + var scale_r := lerpf(0.32, 0.96, wave_t) + var wave_pts := PackedVector2Array() + wave_pts.resize(7) + for i in range(7): + wave_pts[i] = to_screen * center.lerp(top_pts[i % 6], scale_r) + var wave_col := col.lightened(0.15) + wave_col.a = alpha * (1.0 - wave_t * 0.75) + ci.draw_polyline(wave_pts, wave_col, 3.2) + if from_hex == to_hex: + var screen_center: Vector2 = to_screen * center + _draw_icon_medallion(ci, screen_center + Vector2(0.0, -34.0), icon_name, col, alpha) + + +static func draw_event_floating_texts(r: Node2D) -> void: + if r._fx_active.is_empty(): + return + var ci: Node2D = r._overlay_layer + var to_screen: Transform2D = r._world_layer.transform + var font := ThemeDB.fallback_font + var now: float = r._anim_time + var stack_by_hex: Dictionary = {} + + for ev in r._fx_active: + var start: float = float(ev["start"]) + var life: float = float(ev["life"]) + if now < start or now > start + life: + continue + var to_hex := str(ev["to_hex"]) + if not r._hex_centers.has(to_hex): + continue + var t := clampf((now - start) / maxf(0.01, life), 0.0, 1.0) + var alpha := minf(clampf(t / 0.10, 0.0, 1.0), clampf((1.0 - t) / 0.25, 0.0, 1.0)) + if alpha <= 0.02: + continue + + var ev_type := str(ev["type"]) + var casualties := int(ev["casualties"]) + var magnitude := float(ev["magnitude"]) + var pills: Array[Dictionary] = [] + + if casualties > 0: + pills.append({ + "icon": "icon_casualties", + "text": "-%d" % casualties, + "color": Color("#fca5a5"), + "border": Color("#ef4444") + }) + if magnitude >= 1.0 and ev_type in ["raid", "tribute", "betrayal", "pillage", "war"]: + pills.append({ + "icon": "icon_loot", + "text": "+%d" % int(round(magnitude)), + "color": Color("#fef08a"), + "border": Color("#eab308") + }) + elif magnitude >= 1.0 and ev_type in ["colonize", "absorption"]: + pills.append({ + "icon": "icon_migrants", + "text": "+%d" % int(round(magnitude)), + "color": Color("#bae6fd"), + "border": Color("#38bdf8") + }) + + if pills.is_empty(): + continue + + var screen_pos: Vector2 = to_screen * (r._hex_centers[to_hex] as Vector2) + var hex_slot := int(stack_by_hex.get(to_hex, 0)) + for pill in pills: + var y_off := -102.0 - t * 32.0 - float(hex_slot) * 20.0 + hex_slot += 1 + var pill_w := 56.0 + var pill_h := 18.0 + var rect := Rect2(screen_pos.x - pill_w * 0.5, screen_pos.y + y_off, pill_w, pill_h) + var bg := Color(0.09, 0.05, 0.03, 0.92 * alpha) + var border_col: Color = pill["border"] + border_col.a = alpha + ci.draw_rect(rect, bg, true) + ci.draw_rect(rect, border_col, false, 1.5) + var tex: ImageTexture = EvoPixelArtGen.get_texture(str(pill["icon"])) + ci.draw_texture_rect( + tex, + Rect2(rect.position + Vector2(3.0, 2.0), Vector2(14.0, 14.0)), + false, + Color(1.0, 1.0, 1.0, alpha) + ) + var txt_col: Color = pill["color"] + txt_col.a = alpha + ci.draw_string( + font, + Vector2(rect.position.x + 19.0, rect.position.y + 13.0), + str(pill["text"]), + HORIZONTAL_ALIGNMENT_LEFT, + pill_w - 21.0, + 11, + txt_col + ) + stack_by_hex[to_hex] = hex_slot diff --git a/src/world/render/event_fx_renderer.gd.uid b/src/world/render/event_fx_renderer.gd.uid new file mode 100644 index 0000000..02901d3 --- /dev/null +++ b/src/world/render/event_fx_renderer.gd.uid @@ -0,0 +1 @@ +uid://wlqrbd83h26r diff --git a/src/world/render/flora_prop_layout.gd b/src/world/render/flora_prop_layout.gd new file mode 100644 index 0000000..7ad4879 --- /dev/null +++ b/src/world/render/flora_prop_layout.gd @@ -0,0 +1,361 @@ +class_name EvoFloraPropLayout +extends RefCounted + +const Prop = EvoPropFloraRenderer.Prop + +const NON_SWAYING_SPRITES: Array[String] = [ + "mossy_rock", "fallen_log", "rock_mountain", "mountain_peak", + "tree_stump", "soil_crack", "ruins_stone", "crop_field", "pasture_pen" +] + +const BIOME_FLORA_PALETTES: Dictionary = { + "tropical_forest": [ + "tree_tropical", "tree_tropical_2", "bush_berry", "wildflowers", + "tree_tropical", "fallen_log", "tall_grass", "mossy_rock", + "tree_tropical_2", "bush_berry", "wildflowers", "tall_grass" + ], + "savanna": [ + "tree_savanna", "tall_grass", "tree_savanna_2", "wildflowers", + "mossy_rock", "tall_grass", "tree_savanna", "bush_berry", + "fallen_log", "tall_grass" + ], + "arid_steppe": [ + "bush_arid", "dry_scrub", "mossy_rock", "bush_arid", + "rock_mountain", "dry_scrub", "soil_crack", "bush_arid" + ], + "tundra": [ + "tree_tundra", "tree_tundra_2", "mossy_rock", "tree_tundra", + "rock_mountain", "tree_tundra_2", "fallen_log", "bush_berry" + ], + "high_mountain": [ + "mountain_peak", "rock_mountain", "mossy_rock", "tree_tundra", + "mountain_peak", "mossy_rock", "tree_tundra_2", "rock_mountain" + ], + "coastal_shallows": [ + "coastal_reeds", "wildflowers", "coastal_reeds", "tall_grass", + "mossy_rock", "coastal_reeds" + ] +} +const DEFAULT_FLORA_PALETTE: Array[String] = ["tree_savanna", "wildflowers", "tall_grass"] + + +static func compute_cell_flora_key(r: Node2D, cell: EvoTypes.HexCell, top_z: float) -> Array: + var state: EvoTypes.WorldState = r.world_state + var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + var base_count := 8 + if cell.biome == "tropical_forest": + base_count = 13 + elif cell.biome == "savanna": + base_count = 10 + elif cell.biome == "tundra": + base_count = 9 + elif cell.biome == "coastal_shallows": + base_count = 7 + var prop_count: int = clampi(int(round(float(base_count) * (0.55 + 0.45 * k_ratio))), 6, 13) + var v_count := cell.deme_ids.size() + if v_count > 0: + prop_count = clampi(prop_count - v_count, 5, 10) + var k_band: int = 0 if k_ratio < 0.60 else (1 if k_ratio < 0.85 else 2) + var link_mask: int = 0 + if v_count > 0: + for dir_idx in range(6): + var n_id: String = r._neighbor_id(cell, dir_idx) + if state.cells.has(n_id): + var ncell: EvoTypes.HexCell = state.cells[n_id] + if not ncell.deme_ids.is_empty() or ncell.land_use != "wild": + link_mask |= (1 << dir_idx) + var cell_hash: int = int(cell.altitude * 100.0) * 1000 + prop_count * 10 + cell.biome.hash() + cell.land_use.hash() * 31 + (17 if cell.has_ruins else 0) + return [cell_hash, cell.biome, cell.land_use, cell.has_ruins, top_z, prop_count, v_count, k_band, link_mask, r._built_yaw, r._built_pitch] + + +static func collect_static_props(r: Node2D) -> Array[Prop]: + var state: EvoTypes.WorldState = r.world_state + var hex_list: Array = r._sorted_hex_ids if not r._sorted_hex_ids.is_empty() else state.cells.keys() + var cam_vec := Vector2(r._built_yaw, r._built_pitch) + var full_flora_rebuild: bool = ( + not r._cell_flora_cache.has("__sorted_flora__") + or r._cell_flora_cache.get("__flora_cam__", Vector2.INF) != cam_vec + ) + var changed_hexes: Dictionary = {} + for hex_id in hex_list: + var cell: EvoTypes.HexCell = state.cells[hex_id] + if r._is_water(cell): + if r._cell_flora_cache.has(hex_id): + r._cell_flora_cache.erase(hex_id) + changed_hexes[hex_id] = true + continue + var top_z: float = r._get_cell_top_z(cell) + var flora_key := compute_cell_flora_key(r, cell, top_z) + if not r._cell_flora_cache.has(hex_id) or (r._cell_flora_cache[hex_id]["key"] as Array) != flora_key: + changed_hexes[hex_id] = true + var world_xy := HexMath.axial_to_world(cell.q, cell.r) + var cell_props: Array[Prop] = [] + collect_cell_flora_props(r, cell, world_xy, top_z, cell_props) + for cp in cell_props: + cp.hex_id = hex_id + cp.project(r._ground, r._lift) + for i in range(1, cell_props.size()): + var kp: Prop = cell_props[i] + var ky: float = kp.y + var j: int = i - 1 + while j >= 0 and cell_props[j].y > ky: + cell_props[j + 1] = cell_props[j] + j -= 1 + cell_props[j + 1] = kp + r._cell_flora_cache[hex_id] = {"key": flora_key, "props": cell_props} + + var flora_sorted: Array[Prop] = [] + if full_flora_rebuild or changed_hexes.size() > 95: + for hex_id in hex_list: + var cell: EvoTypes.HexCell = state.cells[hex_id] + if r._is_water(cell): + continue + if r._cell_flora_cache.has(hex_id): + flora_sorted.append_array(r._cell_flora_cache[hex_id]["props"] as Array[Prop]) + for i in range(1, flora_sorted.size()): + var key_prop: Prop = flora_sorted[i] + var key_y: float = key_prop.y + var j: int = i - 1 + while j >= 0 and flora_sorted[j].y > key_y: + flora_sorted[j + 1] = flora_sorted[j] + j -= 1 + flora_sorted[j + 1] = key_prop + r._cell_flora_cache["__sorted_flora__"] = flora_sorted + r._cell_flora_cache["__flora_cam__"] = cam_vec + elif not changed_hexes.is_empty(): + var prev_sorted: Array[Prop] = r._cell_flora_cache["__sorted_flora__"] as Array[Prop] + var added_props: Array[Prop] = [] + for ch_id in changed_hexes.keys(): + if r._cell_flora_cache.has(ch_id): + added_props.append_array(r._cell_flora_cache[ch_id]["props"] as Array[Prop]) + for i in range(1, added_props.size()): + var kp: Prop = added_props[i] + var ky: float = kp.y + var j: int = i - 1 + while j >= 0 and added_props[j].y > ky: + added_props[j + 1] = added_props[j] + j -= 1 + added_props[j + 1] = kp + var p_idx := 0 + var a_idx := 0 + var p_len := prev_sorted.size() + var a_len := added_props.size() + while p_idx < p_len: + var pp: Prop = prev_sorted[p_idx] + if changed_hexes.has(pp.hex_id): + p_idx += 1 + continue + while a_idx < a_len and added_props[a_idx].y <= pp.y: + flora_sorted.append(added_props[a_idx]) + a_idx += 1 + flora_sorted.append(pp) + p_idx += 1 + while a_idx < a_len: + flora_sorted.append(added_props[a_idx]) + a_idx += 1 + r._cell_flora_cache["__sorted_flora__"] = flora_sorted + else: + flora_sorted = r._cell_flora_cache["__sorted_flora__"] as Array[Prop] + + var dyn_props: Array[Prop] = [] + for hex_id in state.recent_extinction_hexes: + if state.cells.has(hex_id): + var cell: EvoTypes.HexCell = state.cells[hex_id] + if not r._is_water(cell) and not cell.has_ruins: + var world_xy := HexMath.axial_to_world(cell.q, cell.r) + var r_prop := EvoPropFloraRenderer.world_prop("ruins_stone", world_xy + Vector2(0, 12), r._get_cell_top_z(cell), Vector2.ZERO, 2.20, Color.WHITE) + r_prop.project(r._ground, r._lift) + dyn_props.append(r_prop) + + for village in r._villages: + EvoPropFloraRenderer.collect_village_building_props(village, dyn_props) + for i in range(1, dyn_props.size()): + var key_dp: Prop = dyn_props[i] + var key_dy: float = key_dp.y + var j: int = i - 1 + while j >= 0 and dyn_props[j].y > key_dy: + dyn_props[j + 1] = dyn_props[j] + j -= 1 + dyn_props[j + 1] = key_dp + + var props: Array[Prop] = [] + var f_idx := 0 + var d_idx := 0 + var f_len := flora_sorted.size() + var d_len := dyn_props.size() + while f_idx < f_len and d_idx < d_len: + if flora_sorted[f_idx].y <= dyn_props[d_idx].y: + props.append(flora_sorted[f_idx]) + f_idx += 1 + else: + props.append(dyn_props[d_idx]) + d_idx += 1 + while f_idx < f_len: + props.append(flora_sorted[f_idx]) + f_idx += 1 + while d_idx < d_len: + props.append(dyn_props[d_idx]) + d_idx += 1 + + return props + + +static func get_cell_road_corridors_local( + r: Node2D, + cell: EvoTypes.HexCell, + village_local_centers: Array[Vector2] +) -> Array[Dictionary]: + var segments: Array[Dictionary] = [] + if village_local_centers.is_empty(): + return segments + + var state: EvoTypes.WorldState = r.world_state + var cell_world := HexMath.axial_to_world(cell.q, cell.r) + var best_vc: Vector2 = village_local_centers[0] + for dir_idx in range(6): + var n_id: String = r._neighbor_id(cell, dir_idx) + if not state.cells.has(n_id): + continue + var ncell: EvoTypes.HexCell = state.cells[n_id] + var has_link := not ncell.deme_ids.is_empty() or ncell.land_use != "wild" + if has_link: + var n_local := HexMath.axial_to_world(ncell.q, ncell.r) - cell_world + segments.append({"a": best_vc, "b": n_local}) + return segments + + +static func collect_cell_flora_props( + r: Node2D, + cell: EvoTypes.HexCell, + world_xy: Vector2, + top_z: float, + props: Array[Prop] +) -> void: + var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + var base_seed: int = cell.q * 73856093 ^ cell.r * 19349663 + var palette_sprites: Array = BIOME_FLORA_PALETTES[cell.biome] if BIOME_FLORA_PALETTES.has(cell.biome) else DEFAULT_FLORA_PALETTE + + if cell.has_ruins: + var r1 := EvoPropFloraRenderer.world_prop("ruins_stone", world_xy + Vector2(-34.0, 10.0), top_z, Vector2.ZERO, 2.25, Color.WHITE) + props.append(r1) + if absi(base_seed) % 2 == 0: + var r2 := EvoPropFloraRenderer.world_prop("ruins_stone", world_xy + Vector2(38.0, -16.0), top_z, Vector2.ZERO, 2.05, Color.WHITE) + props.append(r2) + + var v_count := cell.deme_ids.size() + if v_count == 0 and cell.land_use in ["crops", "pasture"]: + if cell.land_use == "crops": + props.append(EvoPropFloraRenderer.world_prop("crop_field", world_xy + Vector2(-62.0, -28.0), top_z, Vector2.ZERO, 2.40, Color.WHITE)) + props.append(EvoPropFloraRenderer.world_prop("crop_field", world_xy + Vector2(58.0, -24.0), top_z, Vector2.ZERO, 2.40, Color.WHITE)) + props.append(EvoPropFloraRenderer.world_prop("windmill", world_xy + Vector2(0.0, -68.0), top_z, Vector2.ZERO, 2.52, Color.WHITE)) + else: + props.append(EvoPropFloraRenderer.world_prop("pasture_pen", world_xy + Vector2(-60.0, -24.0), top_z, Vector2.ZERO, 2.42, Color.WHITE)) + props.append(EvoPropFloraRenderer.world_prop("pasture_pen", world_xy + Vector2(56.0, -20.0), top_z, Vector2.ZERO, 2.42, Color.WHITE)) + for p_i in range(4): + var p_ang := (TAU * float(p_i) / 4.0) + 0.35 + var p_rad := HexMath.HEX_SIZE * (0.74 + float((absi(base_seed) + p_i * 7) % 10) * 0.01) + var p_off := Vector2(cos(p_ang), sin(p_ang)) * p_rad + var periph_sprite: String = palette_sprites[p_i % palette_sprites.size()] + var p_prop := EvoPropFloraRenderer.world_prop(periph_sprite, world_xy + p_off, top_z, Vector2.ZERO, 1.35, Color.WHITE) + p_prop.sways = not NON_SWAYING_SPRITES.has(periph_sprite) + p_prop.sway_phase = float(p_i) * 0.8 + float(cell.q) + props.append(p_prop) + return + + var base_count := 8 + if cell.biome == "tropical_forest": + base_count = 13 + elif cell.biome == "savanna": + base_count = 10 + elif cell.biome == "tundra": + base_count = 9 + elif cell.biome == "coastal_shallows": + base_count = 7 + + var prop_count: int = clampi(int(round(float(base_count) * (0.55 + 0.45 * k_ratio))), 6, 13) + + var village_local_centers: Array[Vector2] = [] + if v_count > 0: + village_local_centers.append(Vector2.ZERO) + prop_count = clampi(prop_count - v_count, 5, 10) + + var road_corridors := get_cell_road_corridors_local(r, cell, village_local_centers) + var placed_offsets: Array[Vector2] = [] + var village_clear_r := 138.0 + var village_clear_r_sq := village_clear_r * village_clear_r + var village_clear_r2_sq := (village_clear_r - 2.0) * (village_clear_r - 2.0) + var max_rad_sq := (HexMath.HEX_SIZE * 0.84) * (HexMath.HEX_SIZE * 0.84) + var inner_count: int = 0 if v_count > 0 else maxi(2, prop_count / 3) + var outer_count: int = prop_count - inner_count + + for idx in range(prop_count): + var s1: int = absi(base_seed ^ ((idx * 13 + 1) * 83492791)) + var s2: int = absi(base_seed ^ ((idx * 29 + 7) * 83492791)) + var angle := 0.0 + var rad_norm := 0.0 + if idx < inner_count: + angle = (TAU * float(idx) / float(inner_count)) + float(s1 % 31 - 15) * 0.045 + rad_norm = 0.22 + float(s2 % 22) * 0.01 + else: + var o_idx := idx - inner_count + angle = (TAU * float(o_idx) / float(maxi(1, outer_count))) + 0.32 + float(s1 % 31 - 15) * 0.032 + if v_count > 0: + rad_norm = 0.66 + float(s2 % 18) * 0.01 + else: + rad_norm = 0.50 + float(s2 % 28) * 0.01 + + var local_off := Vector2(cos(angle), sin(angle)) * (HexMath.HEX_SIZE * rad_norm) + + var valid_spot := true + for vc in village_local_centers: + var d_sq := local_off.distance_squared_to(vc) + if d_sq < village_clear_r_sq: + var push_dir := (local_off - vc).normalized() if d_sq > 0.0001 else Vector2(cos(angle), sin(angle)) + local_off = vc + push_dir * (village_clear_r + 8.0) + if local_off.length_squared() > max_rad_sq: + valid_spot = false + break + if not valid_spot: + continue + for vc in village_local_centers: + if local_off.distance_squared_to(vc) < village_clear_r2_sq: + valid_spot = false + break + if not valid_spot: + continue + + for seg in road_corridors: + var closest := Geometry2D.get_closest_point_to_segment(local_off, seg["a"], seg["b"]) + if local_off.distance_squared_to(closest) < 1764.0: + valid_spot = false + break + if not valid_spot: + continue + + for prev in placed_offsets: + if local_off.distance_squared_to(prev) < 2704.0: + valid_spot = false + break + if not valid_spot: + continue + + placed_offsets.append(local_off) + var sprite_key: String = palette_sprites[idx % palette_sprites.size()] + var can_sway := true + + if k_ratio < 0.60: + can_sway = false + if idx % 3 == 0: + sprite_key = "tree_stump" + elif idx % 3 == 1: + sprite_key = "soil_crack" + else: + sprite_key = "dry_scrub" + elif k_ratio < 0.85 and idx % 2 == 1: + can_sway = false + sprite_key = "tree_stump" if idx % 4 == 1 else ("soil_crack" if idx % 4 == 3 else "dry_scrub") + + var prop := EvoPropFloraRenderer.world_prop(sprite_key, world_xy + local_off, top_z, Vector2.ZERO, 1.45 + float(s1 % 20) * 0.015, Color.WHITE) + prop.sways = can_sway and not NON_SWAYING_SPRITES.has(sprite_key) + prop.sway_phase = float(idx) * 0.9 + float(cell.q) + props.append(prop) diff --git a/src/world/render/flora_prop_layout.gd.uid b/src/world/render/flora_prop_layout.gd.uid new file mode 100644 index 0000000..9923733 --- /dev/null +++ b/src/world/render/flora_prop_layout.gd.uid @@ -0,0 +1 @@ +uid://dlx36kooybxpi diff --git a/src/world/render/hex_column_renderer.gd b/src/world/render/hex_column_renderer.gd new file mode 100644 index 0000000..d6d9da2 --- /dev/null +++ b/src/world/render/hex_column_renderer.gd @@ -0,0 +1,295 @@ +class_name EvoHexColumnRenderer +extends RefCounted + +const BIOME_PALETTE: Dictionary = { + "tropical_forest": { + "edge": Color("#196132"), + "tuft": Color("#3eb864"), + "cliff_top": Color("#7c5333"), + "cliff_bot": Color("#4d301b"), + "overhang": Color("#258545") + }, + "savanna": { + "edge": Color("#946c1c"), + "tuft": Color("#e8be4e"), + "cliff_top": Color("#9c6234"), + "cliff_bot": Color("#633a1c"), + "overhang": Color("#cfa136") + }, + "arid_steppe": { + "edge": Color("#9e5822"), + "tuft": Color("#eda05c"), + "cliff_top": Color("#a85832"), + "cliff_bot": Color("#6e3419"), + "overhang": Color("#cc7735") + }, + "tundra": { + "edge": Color("#7592a3"), + "tuft": Color("#eaf4f8"), + "cliff_top": Color("#64748b"), + "cliff_bot": Color("#3f4c5c"), + "overhang": Color("#e2eff5") + }, + "high_mountain": { + "edge": Color("#4f4c49"), + "tuft": Color("#cbd5e1"), + "cliff_top": Color("#5c5855"), + "cliff_bot": Color("#363331"), + "overhang": Color("#99928b") + }, + "coastal_shallows": { + "edge": Color("#d9be7e"), + "tuft": Color("#ebd294"), + "cliff_top": Color("#a88e58"), + "cliff_bot": Color("#705c35"), + "overhang": Color("#d9be7e") + }, + "ocean": { + "edge": Color("#144566"), + "tuft": Color("#7dd3fc"), + "cliff_top": Color("#144566"), + "cliff_bot": Color("#0d2f47"), + "overhang": Color("#216899") + } +} + +const C_ROCK_CRACK := Color("#23150c") +const C_CLIFF_OUTLINE := Color("#23150c") +const C_SAND := Color("#ebd294") +const C_SURF := Color(0.95, 0.99, 1.0, 0.85) +const C_RIPPLE := Color(0.90, 0.98, 1.0, 0.60) + + +static func draw_hex_column(r: Node2D, column: Node2D, hex_id: String) -> void: + var cell: EvoTypes.HexCell = r.world_state.cells[hex_id] + var is_ocean: bool = r._is_water(cell) + var eff_biome: String = "ocean" if is_ocean else cell.biome + var pal: Dictionary = BIOME_PALETTE.get(eff_biome, BIOME_PALETTE["savanna"]) + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + + if not is_ocean and cell.altitude - r.world_state.sea_level > 0.015: + var bot_pts: PackedVector2Array = r._project_hex(HexMath.axial_to_world(cell.q, cell.r), 0.0) + _draw_cliff_walls(r, column, cell, pal, top_pts, bot_pts) + + var tint_col := get_top_tint(r, cell, is_ocean) + var v_colors := PackedColorArray([tint_col, tint_col, tint_col, tint_col, tint_col, tint_col]) + column.draw_polygon(top_pts, v_colors, r._hex_uvs, EvoPixelArtGen.get_texture("hex_tile_" + eff_biome)) + + if not is_ocean: + if cell.land_use == "crops" or cell.land_use == "pasture" or cell.has_ruins: + _draw_rural_and_ruins_top_overlay(r, column, cell, top_pts) + _draw_hex_borders(r, column, cell, pal, tint_col, top_pts) + if r.active_lens == "food": + var k_ratio: float = cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max) + if k_ratio < 0.60: + _draw_dashed_hex_border(column, top_pts, Color("#ef4444"), 2.6) + elif k_ratio < 0.85: + _draw_dashed_hex_border(column, top_pts, Color("#f59e0b"), 2.0) + + +static func _draw_rural_and_ruins_top_overlay(r: Node2D, column: Node2D, cell: EvoTypes.HexCell, top_pts: PackedVector2Array) -> void: + var center: Vector2 = r._hex_centers[cell.id] + var inner_pts := PackedVector2Array() + for pt in top_pts: + inner_pts.append(center.lerp(pt, 0.76)) + if cell.land_use == "crops": + column.draw_colored_polygon(inner_pts, Color(0.78, 0.60, 0.24, 0.28)) + var furrows := PackedVector2Array() + for row in range(-4, 5): + var t := float(row) / 5.5 + var y_off := t * 72.0 + var half_w := (1.0 - absf(t) * 0.55) * 96.0 + furrows.append(center + Vector2(-half_w, y_off - half_w * 0.14)) + furrows.append(center + Vector2(half_w, y_off + half_w * 0.14)) + column.draw_multiline(furrows, Color(0.38, 0.24, 0.10, 0.48), 2.2) + var border := inner_pts.duplicate() + border.append(inner_pts[0]) + column.draw_polyline(border, Color("#23150c") * Color(1, 1, 1, 0.45), 1.6) + elif cell.land_use == "pasture": + column.draw_colored_polygon(inner_pts, Color(0.32, 0.68, 0.34, 0.24)) + var fence := inner_pts.duplicate() + fence.append(inner_pts[0]) + column.draw_polyline(fence, Color(0.55, 0.36, 0.18, 0.72), 2.2) + for pt in inner_pts: + column.draw_circle(pt, 2.8, Color("#23150c")) + if cell.has_ruins: + column.draw_circle(center + Vector2(-18.0, 8.0), 34.0, Color(0.18, 0.14, 0.12, 0.32)) + column.draw_circle(center + Vector2(22.0, -10.0), 26.0, Color(0.24, 0.20, 0.18, 0.28)) + + +static func _draw_cliff_walls( + r: Node2D, + column: Node2D, + cell: EvoTypes.HexCell, + pal: Dictionary, + top_pts: PackedVector2Array, + bot_pts: PackedVector2Array +) -> void: + var tier: int = clampi(int(ceilf(cell.altitude * 10.0)), 1, 10) + var strata_count: int = mini(tier + 1, 6) + var cliff_top_col: Color = pal["cliff_top"] + var cliff_bot_col: Color = pal["cliff_bot"] + var center_y: float = r._hex_centers[cell.id].y + var strata := PackedVector2Array() + var cracks := PackedVector2Array() + var vines := PackedVector2Array() + var outlines := PackedVector2Array() + + for i in range(6): + var next_i: int = (i + 1) % 6 + var p0 := top_pts[i] + var p1 := top_pts[next_i] + if (p0.y + p1.y) * 0.5 - center_y <= 0.5: + continue + var b1 := bot_pts[next_i] + var b0 := bot_pts[i] + + var shade := 1.0 - float(i % 3) * 0.13 + var wall_col := cliff_top_col.lerp(cliff_bot_col, 0.45) * Color(shade, shade, shade, 1.0) + column.draw_colored_polygon(PackedVector2Array([p0, p1, b1, b0]), wall_col) + column.draw_colored_polygon(PackedVector2Array([p0, p1, p1.lerp(b1, 0.18), p0.lerp(b0, 0.18)]), cliff_top_col.darkened(0.18 * shade)) + + for s_idx in range(1, strata_count + 1): + var t: float = float(s_idx) / float(strata_count + 1) + strata.append(p0.lerp(b0, t)) + strata.append(p1.lerp(b1, t)) + + for c_idx in range(3): + var u := 0.22 + float(c_idx) * 0.28 + float(r._cell_seed(cell.q, cell.r, i * 10 + c_idx) % 9 - 4) * 0.015 + var c_top := p0.lerp(p1, u) + var c_bot := b0.lerp(b1, u) + var t_start := 0.15 + float((c_idx + i) % 2) * 0.18 + cracks.append(c_top.lerp(c_bot, t_start)) + cracks.append(c_top.lerp(c_bot, minf(0.92, t_start + 0.45))) + + for v_idx in range(1, 5): + var vu := float(v_idx) / 5.0 + var v_top := p0.lerp(p1, vu) + var hang_len := 0.16 + float(r._cell_seed(cell.q, cell.r, i * 31 + v_idx) % 22) * 0.012 + vines.append(v_top) + vines.append(v_top.lerp(b0.lerp(b1, vu), hang_len)) + + outlines.append_array(PackedVector2Array([p0, p1, p1, b1, b1, b0, b0, p0])) + + if outlines.is_empty(): + return + column.draw_multiline(strata, cliff_bot_col.darkened(0.22), 1.5) + column.draw_multiline(cracks, C_ROCK_CRACK, 1.3) + column.draw_multiline(vines, (pal["overhang"] as Color).darkened(0.08), 2.2) + column.draw_multiline(outlines, C_CLIFF_OUTLINE, 1.2) + + +static func get_top_tint(r: Node2D, cell: EvoTypes.HexCell, is_ocean: bool) -> Color: + var tint_col := Color.WHITE + if is_ocean: + return tint_col + if r.active_lens == "food": + var ratio_r: float = clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + if ratio_r < 0.35: + return Color("#8c583a").lerp(Color("#eab308"), ratio_r / 0.35) + return Color("#eab308").lerp(Color("#65f06c"), (ratio_r - 0.35) / 0.65) + if r.active_lens == "health": + if not cell.deme_ids.is_empty() and r.world_state.demes.has(cell.deme_ids[0]): + var d: EvoTypes.Deme = r.world_state.demes[cell.deme_ids[0]] + if d.infection_turns > 0: + return Color("#84cc16") + if d.salubrity < 0.40: + return Color("#f87171") + if d.salubrity < 0.75: + return Color("#fbbf24") + return Color("#34d399") + return Color("#94a3b8") + if r.active_lens == "diplomacy" or r.active_lens == "territory": + if not cell.deme_ids.is_empty() and r.world_state.demes.has(cell.deme_ids[0]): + var d: EvoTypes.Deme = r.world_state.demes[cell.deme_ids[0]] + var s_col := EvoPropFloraRenderer.resolve_deme_strategy_color(r.world_state, d) + return Color("#cbd5e1").lerp(s_col, 0.62) + if (cell.land_use == "crops" or cell.land_use == "pasture") and cell.owner_deme_id != "" and r.world_state.demes.has(cell.owner_deme_id): + var owner: EvoTypes.Deme = r.world_state.demes[cell.owner_deme_id] + var s_col := EvoPropFloraRenderer.resolve_deme_strategy_color(r.world_state, owner) + return Color("#cbd5e1").lerp(s_col, 0.38) + return Color("#94a3b8") + var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + if k_ratio < 0.60: + tint_col = tint_col.lerp(Color("#9c6b4e"), 0.42) + elif k_ratio < 0.85: + tint_col = tint_col.lerp(Color("#c29b61"), 0.22) + if cell.land_use == "crops": + tint_col = tint_col.lerp(Color("#e8c468"), 0.24) + elif cell.land_use == "pasture": + tint_col = tint_col.lerp(Color("#76c975"), 0.22) + if cell.has_ruins: + tint_col = tint_col.lerp(Color("#5c4a3e"), 0.28) + return tint_col + + +static func _draw_hex_borders(r: Node2D, column: Node2D, cell: EvoTypes.HexCell, pal: Dictionary, tint_col: Color, top_pts: PackedVector2Array) -> void: + var coast_dirs: PackedInt32Array = r._coast_dirs[cell.id] + var diff_dirs: PackedInt32Array = r._diff_biome_dirs[cell.id] + var sand_lines := PackedVector2Array() + var edge_lines := PackedVector2Array() + var tufts := PackedVector2Array() + for dir_idx in range(6): + var c0 := top_pts[dir_idx] + var c1 := top_pts[(dir_idx + 1) % 6] + if coast_dirs.has(dir_idx): + sand_lines.append(c0) + sand_lines.append(c1) + continue + edge_lines.append(c0) + edge_lines.append(c1) + var tuft_steps := 5 if diff_dirs.has(dir_idx) else 3 + for ts in range(1, tuft_steps + 1): + var jitter := Vector2( + float(r._cell_seed(cell.q, cell.r, dir_idx * 17 + ts) % 5 - 2), + float(r._cell_seed(cell.q, cell.r, dir_idx * 23 + ts) % 5 - 2) + ) * 0.6 + var pt := c0.lerp(c1, float(ts) / float(tuft_steps + 1)) + jitter + tufts.append(pt - Vector2(2.0, 0.0)) + tufts.append(pt + Vector2(2.0, 0.0)) + + if not sand_lines.is_empty(): + column.draw_multiline(sand_lines, C_SAND, 3.2) + if not edge_lines.is_empty(): + column.draw_multiline(edge_lines, (pal["edge"] as Color).darkened(0.18), 1.2) + if not tufts.is_empty(): + column.draw_multiline(tufts, pal["tuft"] * tint_col, 3.8) + + +static func _draw_dashed_hex_border(column: Node2D, pts: PackedVector2Array, col: Color, width: float) -> void: + var dashes := PackedVector2Array() + for i in range(6): + var a := pts[i] + var b := pts[(i + 1) % 6] + dashes.append_array(PackedVector2Array([a.lerp(b, 0.14), a.lerp(b, 0.44), a.lerp(b, 0.58), a.lerp(b, 0.88)])) + column.draw_multiline(dashes, col, width) + + +static func draw_water_fx(r: Node2D, fx: Node2D, hex_id: String) -> void: + var cell: EvoTypes.HexCell = r.world_state.cells[hex_id] + var center: Vector2 = r._hex_centers[hex_id] + if r._is_water(cell) or cell.biome == "coastal_shallows": + var phase: float = r._anim_time * 2.4 + float(cell.q) * 1.1 + float(cell.r) * 0.8 + var wave_offset := Vector2(sin(phase) * 7.5, cos(phase) * 3.8) + fx.draw_line(center + wave_offset + Vector2(-24, -10), center + wave_offset + Vector2(24, -10), C_RIPPLE, 2.0) + fx.draw_line(center - wave_offset + Vector2(-18, 12), center - wave_offset + Vector2(20, 12), C_RIPPLE, 1.6) + + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + for dir_idx in r._coast_dirs[hex_id]: + var surf_pulse := 0.5 + 0.5 * sin(r._anim_time * 3.2 + float(dir_idx + cell.q)) + var inset_inner := 0.05 + surf_pulse * 0.03 + var inset_outer := -0.05 - surf_pulse * 0.03 + var c0 := top_pts[dir_idx] + var c1 := top_pts[(dir_idx + 1) % 6] + fx.draw_line( + c0.lerp(center, inset_inner), + c1.lerp(center, inset_inner), + C_SURF, + 2.4 + surf_pulse + ) + fx.draw_line( + c0.lerp(center, inset_outer), + c1.lerp(center, inset_outer), + C_RIPPLE, + 1.6 + surf_pulse * 0.6 + ) diff --git a/src/world/render/hex_column_renderer.gd.uid b/src/world/render/hex_column_renderer.gd.uid new file mode 100644 index 0000000..3e1b1ce --- /dev/null +++ b/src/world/render/hex_column_renderer.gd.uid @@ -0,0 +1 @@ +uid://cqbtdqsmf54ss diff --git a/src/world/render/prop_flora_renderer.gd b/src/world/render/prop_flora_renderer.gd index ffd9628..cbbadb6 100644 --- a/src/world/render/prop_flora_renderer.gd +++ b/src/world/render/prop_flora_renderer.gd @@ -1,48 +1,19 @@ class_name EvoPropFloraRenderer extends RefCounted -const C_SELECTION := Color("#fde047") - const STATUS_COLORS: Dictionary = { "thriving": Color("#34d399"), "stable": Color("#38bdf8"), + "declining": Color("#f59e0b"), "struggling": Color("#fbbf24"), "starving": Color("#fb7185") } -const NON_SWAYING_SPRITES: Array[String] = ["mossy_rock", "fallen_log", "rock_mountain", "tree_stump", "soil_crack"] +const NON_SWAYING_SPRITES := EvoFloraPropLayout.NON_SWAYING_SPRITES +const BIOME_FLORA_PALETTES := EvoFloraPropLayout.BIOME_FLORA_PALETTES +const DEFAULT_FLORA_PALETTE := EvoFloraPropLayout.DEFAULT_FLORA_PALETTE const VILLAGER_POSES: Array[String] = ["villager_idle", "villager_walk1", "villager_walk2"] -const TRAVELER_ROAD_KINDS: Array[String] = ["collab", "bridge", "pact", "tribute", "merge"] - -const BIOME_FLORA_PALETTES: Dictionary = { - "tropical_forest": [ - "tree_tropical", "tree_tropical_2", "bush_berry", "wildflowers", - "tree_tropical", "fallen_log", "tall_grass", "mossy_rock", - "tree_tropical_2", "bush_berry", "wildflowers", "tall_grass" - ], - "savanna": [ - "tree_savanna", "tall_grass", "tree_savanna_2", "wildflowers", - "mossy_rock", "tall_grass", "tree_savanna", "bush_berry", - "fallen_log", "tall_grass" - ], - "arid_steppe": [ - "bush_arid", "dry_scrub", "mossy_rock", "bush_arid", - "rock_mountain", "dry_scrub", "soil_crack", "bush_arid" - ], - "tundra": [ - "tree_tundra", "tree_tundra_2", "mossy_rock", "tree_tundra", - "rock_mountain", "tree_tundra_2", "fallen_log", "bush_berry" - ], - "high_mountain": [ - "rock_mountain", "mossy_rock", "tree_tundra", "rock_mountain", - "mossy_rock", "tree_tundra_2", "rock_mountain" - ], - "coastal_shallows": [ - "coastal_reeds", "wildflowers", "coastal_reeds", "tall_grass", - "mossy_rock", "coastal_reeds" - ] -} -const DEFAULT_FLORA_PALETTE: Array[String] = ["tree_savanna", "wildflowers", "tall_grass"] +const TRAVELER_ROAD_KINDS: Array[String] = ["collab", "bridge", "pact", "tribute", "merge", "rural_farm"] class Prop: @@ -61,7 +32,9 @@ class Prop: func _init(p_texture: Texture2D, scale: float, anchor_y: float, p_modulate: Color) -> void: texture = p_texture - var size := p_texture.get_size() * scale + var tex_sz := p_texture.get_size() + var dpi_norm: float = 0.32 if tex_sz.x <= 64.0 else 0.24 + var size := tex_sz * (scale * dpi_norm) anchor_offset = Vector2(size.x * 0.5, size.y * anchor_y) rect = Rect2(Vector2.ZERO, size) modulate = p_modulate @@ -97,6 +70,8 @@ static func get_villager_textures_for_color(r: Node2D, tunic_col: Color) -> Arra static func collect_villages(r: Node2D) -> Array[Dictionary]: var villages: Array[Dictionary] = [] + r._village_by_deme_id.clear() + r._rural_cell_ids.clear() var state: EvoTypes.WorldState = r.world_state var geo_by_deme := build_geopolitics_by_deme(state) var hex_list: Array = r._sorted_hex_ids if not r._sorted_hex_ids.is_empty() else state.cells.keys() @@ -104,134 +79,152 @@ static func collect_villages(r: Node2D) -> Array[Dictionary]: var cell: EvoTypes.HexCell = state.cells[hex_id] if r._is_water(cell): continue - var count := cell.deme_ids.size() - var scale_factor := 1.0 - if count == 2: - scale_factor = 0.86 - elif count >= 3: - scale_factor = 0.78 + if cell.deme_ids.is_empty(): + if (cell.land_use in ["crops", "pasture"]) and cell.owner_deme_id != "": + r._rural_cell_ids.append(hex_id) + continue + var deme_id: String = cell.deme_ids[0] + if not state.demes.has(deme_id): + continue + var deme: EvoTypes.Deme = state.demes[deme_id] var world_xy := HexMath.axial_to_world(cell.q, cell.r) var top_z: float = r._get_cell_top_z(cell) - - for idx in range(count): - var deme_id: String = cell.deme_ids[idx] - if not state.demes.has(deme_id): - continue - var deme: EvoTypes.Deme = state.demes[deme_id] - var v_world := world_xy + HexMath.compute_village_sector_offset(idx, count, HexMath.HEX_SIZE) - var is_dimmed: bool = r.highlighted_lineage_id != "" and deme.lineage_id != r.highlighted_lineage_id - var tier := deme.get_village_tier() - var strat_color := resolve_deme_strategy_color(state, deme) - - var bb_offset := Vector2(0.0, -54.0) - var sub_text := "%d pop" % deme.population if r.lang == "en" else "%d hab" % deme.population - var box_size := Vector2(114.0, 30.0) - var v_pos: Vector2 = r._project(v_world, top_z) - var vital_status := deme.get_vital_status() - var status_col: Color = STATUS_COLORS[vital_status] if STATUS_COLORS.has(vital_status) else Color("#38bdf8") - var geo: Dictionary = geo_by_deme[deme.id] if geo_by_deme.has(deme.id) else resolve_deme_geopolitics(state, deme) - - villages.append({ - "deme": deme, - "deme_id": deme.id, - "hex_id": hex_id, - "world_xy": v_world, - "top_z": top_z, - "pos": v_pos, - "sf": scale_factor, - "modulate": Color(1, 1, 1, 0.42) if is_dimmed else Color.WHITE, - "is_dimmed": is_dimmed, - "tier": tier, - "archetype": get_deme_dominant_archetype(r, deme), - "has_pact": geo["has_pact"], - "in_war": geo["in_war"], - "in_raid": geo["in_raid"], - "in_betrayal": geo["in_betrayal"], - "in_tribute": geo["in_tribute"], - "in_siege": geo["in_siege"], - "in_merge": geo["in_merge"], - "geo_icons": geo["geo_icons"], - "status_col": status_col, - "strat_color": strat_color, - "villager_count": clampi(2 + tier + int(deme.population / 110), 3, 7), - "villager_textures": get_villager_textures_for_color(r, strat_color), - "bb_offset": bb_offset * scale_factor, - "sub_text": sub_text, - "label_text": sub_text, - "box_size": box_size, - "pill_w": box_size.x, - "hit_rect": Rect2(v_pos - Vector2(74, 76) * scale_factor, Vector2(148, 116) * scale_factor) - }) + var v_world := world_xy + var is_dimmed: bool = r.highlighted_lineage_id != "" and deme.lineage_id != r.highlighted_lineage_id + var tier := deme.get_village_tier() + var strat_vis := EvoWorldOverlayRenderer._resolve_deme_strategy_visuals(state, deme, r.lang) + var strat_color: Color = strat_vis["color"] + + var v_pos: Vector2 = r._project(v_world, top_z) + var vital_status := deme.get_vital_status() + var status_col: Color = STATUS_COLORS[vital_status] if STATUS_COLORS.has(vital_status) else Color("#38bdf8") + var geo: Dictionary = geo_by_deme.get(deme.id, {}) + + var v_entry := { + "deme": deme, + "deme_id": deme.id, + "hex_id": hex_id, + "world_xy": v_world, + "top_z": top_z, + "pos": v_pos, + "modulate": Color(1, 1, 1, 0.42) if is_dimmed else Color.WHITE, + "is_dimmed": is_dimmed, + "tier": tier, + "archetype": get_deme_dominant_archetype(r, deme), + "has_pact": geo["has_pact"], + "in_war": geo["in_war"], + "in_raid": geo["in_raid"], + "in_betrayal": geo["in_betrayal"], + "in_tribute": geo["in_tribute"], + "in_siege": geo["in_siege"], + "geo_icons": geo["geo_icons"], + "status_col": status_col, + "strat_color": strat_color, + "strat_visuals": strat_vis, + "villager_count": clampi(3 + tier + int(deme.population / 95), 3, 9), + "villager_textures": get_villager_textures_for_color(r, strat_color), + "hit_rect": Rect2(v_pos - Vector2(104, 92), Vector2(208, 168)) + } + villages.append(v_entry) + r._village_by_deme_id[deme.id] = v_entry return villages static func get_village_building_layout( tier: int, - sf: float, has_culture: bool, cultural_count: int, archetype: String ) -> Array[Dictionary]: var items: Array[Dictionary] = [] + var totem_sprite := "totem_innov" + if archetype in ["cooperate", "pact", "exchange"]: + totem_sprite = "totem_coop" + elif archetype in ["war", "raid", "dispute", "betrayal"]: + totem_sprite = "totem_aggro" + match tier: 1: - items.append({"sprite": "tier1_tent", "offset": Vector2(-48, -18) * sf, "scale": 2.50 * sf}) + items.append({"sprite": "campfire", "offset": Vector2(0, 6), "scale": 2.35}) + items.append({"sprite": "hut_thatch", "offset": Vector2(-68, -24), "scale": 2.48}) + items.append({"sprite": "hut_thatch", "offset": Vector2(66, -20), "scale": 2.44}) + items.append({"sprite": "hut_thatch", "offset": Vector2(-18, -54), "scale": 2.38}) 2: - items.append({"sprite": "tier2_cottage", "offset": Vector2(-52, -25) * sf, "scale": 2.54 * sf}) - items.append({"sprite": "tier2_barn", "offset": Vector2(52, -25) * sf, "scale": 2.38 * sf}) + items.append({"sprite": "temple_totem" if has_culture else "campfire", "offset": Vector2(0, -20), "scale": 2.52}) + items.append({"sprite": "house_clay", "offset": Vector2(-76, -28), "scale": 2.48}) + items.append({"sprite": "house_clay", "offset": Vector2(76, -26), "scale": 2.46}) + items.append({"sprite": "hut_thatch", "offset": Vector2(-92, 18), "scale": 2.36}) + items.append({"sprite": "hut_thatch", "offset": Vector2(90, 20), "scale": 2.36}) + items.append({"sprite": "hut_thatch", "offset": Vector2(0, -64), "scale": 2.38}) + items.append({"sprite": "granary", "offset": Vector2(-48, 44), "scale": 2.40}) + items.append({"sprite": "watchtower", "offset": Vector2(56, -56), "scale": 2.42}) 3: - items.append({"sprite": "tier3_townhall", "offset": Vector2(0, -44) * sf, "scale": 2.58 * sf}) - items.append({"sprite": "tier3_house", "offset": Vector2(-62, -10) * sf, "scale": 2.38 * sf}) - 4: - items.append({"sprite": "tier4_citadel", "offset": Vector2(0, -48) * sf, "scale": 2.65 * sf}) - items.append({"sprite": "tier4_tower", "offset": Vector2(-66, -14) * sf, "scale": 2.38 * sf}) - items.append({"sprite": "tier4_windmill", "offset": Vector2(66, -14) * sf, "scale": 2.38 * sf}) + items.append({"sprite": "temple_totem", "offset": Vector2(0, -26), "scale": 2.60}) + items.append({"sprite": "house_stone", "offset": Vector2(-64, -46), "scale": 2.48}) + items.append({"sprite": "house_stone", "offset": Vector2(64, -46), "scale": 2.48}) + items.append({"sprite": "house_stone", "offset": Vector2(0, -76), "scale": 2.44}) + items.append({"sprite": "house_clay", "offset": Vector2(-106, -8), "scale": 2.42}) + items.append({"sprite": "house_clay", "offset": Vector2(106, -8), "scale": 2.42}) + items.append({"sprite": "house_clay", "offset": Vector2(-86, 34), "scale": 2.38}) + items.append({"sprite": "house_clay", "offset": Vector2(86, 34), "scale": 2.38}) + items.append({"sprite": "granary", "offset": Vector2(-46, 52), "scale": 2.45}) + items.append({"sprite": "windmill", "offset": Vector2(110, -48), "scale": 2.52}) + items.append({"sprite": "watchtower", "offset": Vector2(-112, -48), "scale": 2.48}) _: - items.append({"sprite": "tier5_metropolis", "offset": Vector2(0, -50) * sf, "scale": 2.68 * sf}) - items.append({"sprite": "tier4_tower", "offset": Vector2(-68, -14) * sf, "scale": 2.40 * sf}) - items.append({"sprite": "tier3_townhall", "offset": Vector2(68, -14) * sf, "scale": 2.38 * sf}) + items.append({"sprite": "citadel_keep", "offset": Vector2(-24, -36), "scale": 2.72}) + items.append({"sprite": "cathedral", "offset": Vector2(34, -32), "scale": 2.64}) + items.append({"sprite": "house_stone", "offset": Vector2(0, -84), "scale": 2.48}) + items.append({"sprite": "house_stone", "offset": Vector2(-74, -62), "scale": 2.46}) + items.append({"sprite": "house_stone", "offset": Vector2(76, -62), "scale": 2.46}) + items.append({"sprite": "house_stone", "offset": Vector2(-116, -16), "scale": 2.44}) + items.append({"sprite": "house_stone", "offset": Vector2(116, -16), "scale": 2.44}) + items.append({"sprite": "house_stone", "offset": Vector2(-62, 16), "scale": 2.40}) + items.append({"sprite": "house_clay", "offset": Vector2(64, 16), "scale": 2.38}) + items.append({"sprite": "house_clay", "offset": Vector2(-96, 34), "scale": 2.38}) + items.append({"sprite": "house_clay", "offset": Vector2(96, 34), "scale": 2.38}) + items.append({"sprite": "granary", "offset": Vector2(-48, 56), "scale": 2.48}) + items.append({"sprite": "windmill", "offset": Vector2(122, -54), "scale": 2.55}) + items.append({"sprite": "watchtower", "offset": Vector2(-124, -54), "scale": 2.52}) + items.append({"sprite": "watchtower", "offset": Vector2(0, 68), "scale": 2.48}) if has_culture: - var totem_scale := (2.12 + mini(3, cultural_count) * 0.16) * sf - items.append({"sprite": "cultural_totem", "offset": Vector2(-52, 29) * sf, "scale": totem_scale}) + var totem_scale := 2.18 + mini(3, cultural_count) * 0.14 + var totem_off := Vector2(-58, 32) if tier == 1 else Vector2(-34, 14) + items.append({"sprite": totem_sprite, "offset": totem_off, "scale": totem_scale}) - var act_offset := Vector2(48, -12) * sf if tier == 1 else (Vector2(62, -10) * sf if tier == 3 else Vector2(52, 29) * sf) - items.append({"sprite": "act_" + archetype, "offset": act_offset, "scale": 2.32 * sf}) + var act_offset := Vector2(58, 28) if tier == 1 else Vector2(48, 50) + items.append({"sprite": "act_" + archetype, "offset": act_offset, "scale": 2.30}) return items static func draw_village_plaza(r: Node2D, village: Dictionary) -> void: - _draw_village(r, village) - - -static func _draw_village(r: Node2D, village: Dictionary) -> void: + var v_pos: Vector2 = village["pos"] + if r._visible_world_rect.size != Vector2.ZERO and not r._visible_world_rect.grow(HexMath.HEX_SIZE * 1.5).has_point(v_pos): + return var ci: Node2D = r._network_layer var deme: EvoTypes.Deme = village["deme"] - var sf: float = village["sf"] var modulate_col: Color = village["modulate"] var status_col: Color = village["status_col"] - var v_pos: Vector2 = village["pos"] - var plaza_center: Vector2 = v_pos + Vector2(0, 8.0 * sf) - var strat_color := resolve_deme_strategy_color(r.world_state, deme) + var plaza_center: Vector2 = v_pos + Vector2(0, 8.0) + var strat_color: Color = village["strat_color"] var strat_hi := strat_color.lightened(0.25) - EvoWorldOverlayRenderer.draw_ellipse_filled(ci, v_pos + Vector2(0, -4.0 * sf), 86.0 * sf, 50.0 * sf, Color(0.36, 0.25, 0.16, 0.34) * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, v_pos + Vector2(0, -4.0), 132.0, 76.0, Color(0.36, 0.25, 0.16, 0.34) * modulate_col) + var eff_tier: int = deme.get_village_tier() var layout := get_village_building_layout( - int(village["tier"]), - sf, + eff_tier, not deme.cultural_trait_ids.is_empty(), deme.cultural_trait_ids.size(), str(village["archetype"]) ) for b_item in layout: var b_foot: Vector2 = v_pos + (b_item["offset"] as Vector2) - EvoWorldOverlayRenderer.draw_ellipse_filled(ci, b_foot + Vector2(0, -3.0 * sf), 26.0 * sf, 14.5 * sf, Color("#7c5c3b") * modulate_col) - EvoWorldOverlayRenderer.draw_ellipse_filled(ci, b_foot + Vector2(0, -3.0 * sf), 21.5 * sf, 11.5 * sf, Color("#cbb082") * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, b_foot + Vector2(0, -3.0), 28.0, 15.5, Color("#7c5c3b") * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, b_foot + Vector2(0, -3.0), 23.0, 12.5, Color("#cbb082") * modulate_col) - var def_tier: int = deme.defense_tier - var is_merged: bool = deme.merged_count > 0 - var plinth_r := (54.5 if is_merged else 50.0) * sf - var rim_r := plinth_r + 5.5 * sf + var is_merged: bool = deme.merged_count > 0 or deme.walled_union_id != "" + var plinth_r := 58.0 if is_merged else 52.0 + var rim_r := plinth_r + 5.5 EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, rim_r, rim_r * 0.58, Color("#5c4028") * modulate_col) EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, plinth_r, plinth_r * 0.58, Color("#c2a378") * modulate_col) EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, plinth_r * 0.74, plinth_r * 0.74 * 0.58, Color("#d9be96") * modulate_col) @@ -242,10 +235,9 @@ static func _draw_village(r: Node2D, village: Dictionary) -> void: plinth_r * 0.64, plinth_r * 0.64 * 0.58, Color("#fbbf24") * modulate_col, - 2.4 * sf + 2.4 ) - # Inner Strategy Mosaic / Carpet Ring var mosaic_outer_r := plinth_r * 0.52 var mosaic_inner_r := plinth_r * 0.36 EvoWorldOverlayRenderer.draw_ellipse_filled( @@ -268,7 +260,7 @@ static func _draw_village(r: Node2D, village: Dictionary) -> void: mosaic_outer_r, mosaic_outer_r * 0.58, strat_hi * modulate_col, - 2.2 * sf + 2.2 ) EvoWorldOverlayRenderer.draw_ellipse_filled( ci, @@ -281,263 +273,72 @@ static func _draw_village(r: Node2D, village: Dictionary) -> void: for c_i in range(14): var c_ang := TAU * float(c_i) / 14.0 var c_pt := plaza_center + Vector2(cos(c_ang) * plinth_r * 0.84, sin(c_ang) * plinth_r * 0.48) - ci.draw_circle(c_pt, 4.4 * sf, (strat_color.darkened(0.15) if c_i % 2 == 0 else Color("#a38358")) * modulate_col) + ci.draw_circle(c_pt, 4.4, (strat_color.darkened(0.15) if c_i % 2 == 0 else Color("#a38358")) * modulate_col) - # Village Plaza Wooden Banner Pole / Strategy Totem with Pennant Flag - var pole_base := plaza_center + Vector2(-15.0 * sf, -6.0 * sf) - var pole_top := pole_base + Vector2(0.0, -26.0 * sf) - ci.draw_circle(pole_base + Vector2(0.0, 1.5 * sf), 3.6 * sf, Color(0.0, 0.0, 0.0, 0.32) * modulate_col) - ci.draw_line(pole_base, pole_top, Color("#5c3317") * modulate_col, 3.2 * sf) - ci.draw_line(pole_base + Vector2(-0.6 * sf, 0.0), pole_top + Vector2(-0.6 * sf, 0.0), Color("#8c5230") * modulate_col, 1.4 * sf) - ci.draw_circle(pole_top, 2.5 * sf, Color("#fbbf24") * modulate_col) + var pole_base := plaza_center + Vector2(-15.0, -6.0) + var pole_top := pole_base + Vector2(0.0, -26.0) + ci.draw_circle(pole_base + Vector2(0.0, 1.5), 3.6, Color(0.0, 0.0, 0.0, 0.32) * modulate_col) + ci.draw_line(pole_base, pole_top, Color("#5c3317") * modulate_col, 3.2) + ci.draw_line(pole_base + Vector2(-0.6, 0.0), pole_top + Vector2(-0.6, 0.0), Color("#8c5230") * modulate_col, 1.4) + ci.draw_circle(pole_top, 2.5, Color("#fbbf24") * modulate_col) - var wave := sin(r._anim_time * 4.2 + v_pos.x * 0.04) * 2.0 * sf + var wave := sin(r._anim_time * 4.2 + v_pos.x * 0.04) * 2.0 var flag_pts := PackedVector2Array([ - pole_top + Vector2(1.0 * sf, 1.0 * sf), - pole_top + Vector2(19.0 * sf, 4.5 * sf + wave), - pole_top + Vector2(1.0 * sf, 11.0 * sf) + pole_top + Vector2(1.0, 1.0), + pole_top + Vector2(19.0, 4.5 + wave), + pole_top + Vector2(1.0, 11.0) ]) ci.draw_colored_polygon(flag_pts, strat_color * modulate_col) var flag_hi_pts := PackedVector2Array([ - pole_top + Vector2(1.0 * sf, 1.0 * sf), - pole_top + Vector2(13.5 * sf, 3.5 * sf + wave * 0.65), - pole_top + Vector2(1.0 * sf, 5.5 * sf) + pole_top + Vector2(1.0, 1.0), + pole_top + Vector2(13.5, 3.5 + wave * 0.65), + pole_top + Vector2(1.0, 5.5) ]) ci.draw_colored_polygon(flag_hi_pts, strat_hi * modulate_col) if is_merged: var sec_col := Color(deme.secondary_color) if deme.secondary_color != "" else Color("#fbbf24") var sec_flag_pts := PackedVector2Array([ - pole_top + Vector2(1.0 * sf, 10.5 * sf), - pole_top + Vector2(16.0 * sf, 13.5 * sf - wave * 0.8), - pole_top + Vector2(1.0 * sf, 18.5 * sf) + pole_top + Vector2(1.0, 10.5), + pole_top + Vector2(16.0, 13.5 - wave * 0.8), + pole_top + Vector2(1.0, 18.5) ]) ci.draw_colored_polygon(sec_flag_pts, sec_col * modulate_col) - # Procedural Defense Perimeter Rings around plaza_center - var def_r := plinth_r + 3.2 * sf - if def_tier == 1: - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, def_r, def_r * 0.58, Color("#5c3317") * modulate_col, 3.5 * sf) - for s_i in range(22): - var ang := TAU * float(s_i) / 22.0 - if absf(ang - PI * 0.5) < 0.24: - continue - var st_base := plaza_center + Vector2(cos(ang) * def_r, sin(ang) * def_r * 0.58) - var st_top := st_base + Vector2(0.0, -5.5 * sf) - ci.draw_line(st_base, st_top, Color("#78350f") * modulate_col, 2.4 * sf) - ci.draw_circle(st_top, 1.3 * sf, Color("#fde68a") * modulate_col) - elif def_tier == 2: - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, def_r, def_r * 0.58, Color("#475569") * modulate_col, 5.5 * sf) - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, def_r, def_r * 0.58, Color("#94a3b8") * modulate_col, 3.2 * sf) - for m_i in range(20): - var ang := TAU * float(m_i) / 20.0 - if absf(ang - PI * 0.5) < 0.24: - continue - var m_pt := plaza_center + Vector2(cos(ang) * def_r, sin(ang) * def_r * 0.58) + Vector2(0.0, -2.2 * sf) - ci.draw_rect(Rect2(m_pt - Vector2(2.0 * sf, 2.0 * sf), Vector2(4.0 * sf, 3.5 * sf)), Color("#cbd5e1") * modulate_col, true) - elif def_tier >= 3: - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, def_r, def_r * 0.58, Color("#334155") * modulate_col, 7.0 * sf) - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, def_r, def_r * 0.58, Color("#94a3b8") * modulate_col, 4.2 * sf) - for m_i in range(20): - var ang := TAU * float(m_i) / 20.0 - if absf(ang - PI * 0.5) < 0.24: - continue - var m_pt := plaza_center + Vector2(cos(ang) * def_r, sin(ang) * def_r * 0.58) + Vector2(0.0, -2.5 * sf) - ci.draw_rect(Rect2(m_pt - Vector2(2.2 * sf, 2.2 * sf), Vector2(4.4 * sf, 4.0 * sf)), Color("#e2e8f0") * modulate_col, true) - for b_ang in [PI * 0.25, PI * 0.75, PI * 1.25, PI * 1.75]: - var b_pt := plaza_center + Vector2(cos(b_ang) * def_r, sin(b_ang) * def_r * 0.58) - ci.draw_circle(b_pt, 6.2 * sf, Color("#64748b") * modulate_col) - ci.draw_circle(b_pt + Vector2(0.0, -2.2 * sf), 4.6 * sf, strat_color * modulate_col) - ci.draw_circle(b_pt + Vector2(0.0, -3.5 * sf), 1.8 * sf, Color("#fbbf24") * modulate_col) - - var ring_r := plinth_r + (6.8 if def_tier > 0 else 4.5) * sf + var ring_r := plinth_r + 5.0 EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, ring_r, ring_r * 0.58, status_col * modulate_col, 3.4) - if bool(village.get("in_siege", false)): - var siege_pulse := 0.65 + 0.35 * sin(r._anim_time * 6.5) - var siege_r := plinth_r + 12.5 * sf - EvoWorldOverlayRenderer.draw_ellipse_outline( - ci, - plaza_center, - siege_r, - siege_r * 0.58, - Color(0.98, 0.32, 0.12, 0.85 * siege_pulse) * modulate_col, - 3.2 * sf - ) - - if deme.id == r.selected_deme_id: - var sel_r := plinth_r + 11.0 * sf - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, sel_r, sel_r * 0.58, C_SELECTION, 4.2) - elif deme.id == r.hovered_deme_id: - var hov_r := plinth_r + 9.5 * sf - EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, hov_r, hov_r * 0.58, Color(1.0, 0.92, 0.68, 0.88), 2.8) - static func compute_cell_flora_key(r: Node2D, cell: EvoTypes.HexCell, top_z: float) -> Array: - var state: EvoTypes.WorldState = r.world_state - var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) - var base_count := 8 - if cell.biome == "tropical_forest": - base_count = 13 - elif cell.biome == "savanna": - base_count = 10 - elif cell.biome == "tundra": - base_count = 9 - elif cell.biome == "coastal_shallows": - base_count = 7 - var prop_count: int = clampi(int(round(float(base_count) * (0.55 + 0.45 * k_ratio))), 6, 13) - var v_count := cell.deme_ids.size() - if v_count > 0: - prop_count = clampi(prop_count - v_count, 5, 10) - var k_band: int = 0 if k_ratio < 0.60 else (1 if k_ratio < 0.85 else 2) - var link_mask: int = 0 - if v_count > 0: - var n_ids := HexMath.get_neighbor_ids(cell.q, cell.r, state.cells) - for n_idx in range(n_ids.size()): - var n_id := n_ids[n_idx] - var ncell: EvoTypes.HexCell = state.cells[n_id] - if not ncell.deme_ids.is_empty() or (ncell.biome == "coastal_shallows" and ncell.altitude >= state.sea_level): - link_mask |= (1 << n_idx) - return [cell.biome, top_z, prop_count, v_count, k_band, link_mask, r._built_yaw, r._built_pitch] + return EvoFloraPropLayout.compute_cell_flora_key(r, cell, top_z) static func collect_static_props(r: Node2D) -> Array[Prop]: - var state: EvoTypes.WorldState = r.world_state - var hex_list: Array = r._sorted_hex_ids if not r._sorted_hex_ids.is_empty() else state.cells.keys() - var cam_vec := Vector2(r._built_yaw, r._built_pitch) - var full_flora_rebuild: bool = ( - not r._cell_flora_cache.has("__sorted_flora__") - or r._cell_flora_cache.get("__flora_cam__", Vector2.INF) != cam_vec - ) - var changed_hexes: Dictionary = {} - for hex_id in hex_list: - var cell: EvoTypes.HexCell = state.cells[hex_id] - if r._is_water(cell): - if r._cell_flora_cache.has(hex_id): - r._cell_flora_cache.erase(hex_id) - changed_hexes[hex_id] = true - continue - var top_z: float = r._get_cell_top_z(cell) - var flora_key := compute_cell_flora_key(r, cell, top_z) - if not r._cell_flora_cache.has(hex_id) or (r._cell_flora_cache[hex_id]["key"] as Array) != flora_key: - changed_hexes[hex_id] = true - var world_xy := HexMath.axial_to_world(cell.q, cell.r) - var cell_props: Array[Prop] = [] - collect_cell_flora_props(r, cell, world_xy, top_z, cell_props) - for cp in cell_props: - cp.hex_id = hex_id - cp.project(r._ground, r._lift) - for i in range(1, cell_props.size()): - var kp: Prop = cell_props[i] - var ky: float = kp.y - var j: int = i - 1 - while j >= 0 and cell_props[j].y > ky: - cell_props[j + 1] = cell_props[j] - j -= 1 - cell_props[j + 1] = kp - r._cell_flora_cache[hex_id] = {"key": flora_key, "props": cell_props} + return EvoFloraPropLayout.collect_static_props(r) - var flora_sorted: Array[Prop] = [] - if full_flora_rebuild or changed_hexes.size() > 45: - for hex_id in hex_list: - var cell: EvoTypes.HexCell = state.cells[hex_id] - if r._is_water(cell): - continue - if r._cell_flora_cache.has(hex_id): - flora_sorted.append_array(r._cell_flora_cache[hex_id]["props"] as Array[Prop]) - for i in range(1, flora_sorted.size()): - var key_prop: Prop = flora_sorted[i] - var key_y: float = key_prop.y - var j: int = i - 1 - while j >= 0 and flora_sorted[j].y > key_y: - flora_sorted[j + 1] = flora_sorted[j] - j -= 1 - flora_sorted[j + 1] = key_prop - r._cell_flora_cache["__sorted_flora__"] = flora_sorted - r._cell_flora_cache["__flora_cam__"] = cam_vec - elif not changed_hexes.is_empty(): - var prev_sorted: Array[Prop] = r._cell_flora_cache["__sorted_flora__"] as Array[Prop] - var added_props: Array[Prop] = [] - for ch_id in changed_hexes.keys(): - if r._cell_flora_cache.has(ch_id): - added_props.append_array(r._cell_flora_cache[ch_id]["props"] as Array[Prop]) - for i in range(1, added_props.size()): - var kp: Prop = added_props[i] - var ky: float = kp.y - var j: int = i - 1 - while j >= 0 and added_props[j].y > ky: - added_props[j + 1] = added_props[j] - j -= 1 - added_props[j + 1] = kp - var p_idx := 0 - var a_idx := 0 - var p_len := prev_sorted.size() - var a_len := added_props.size() - while p_idx < p_len: - var pp: Prop = prev_sorted[p_idx] - if changed_hexes.has(pp.hex_id): - p_idx += 1 - continue - while a_idx < a_len and added_props[a_idx].y <= pp.y: - flora_sorted.append(added_props[a_idx]) - a_idx += 1 - flora_sorted.append(pp) - p_idx += 1 - while a_idx < a_len: - flora_sorted.append(added_props[a_idx]) - a_idx += 1 - r._cell_flora_cache["__sorted_flora__"] = flora_sorted - else: - flora_sorted = r._cell_flora_cache["__sorted_flora__"] as Array[Prop] - - var dyn_props: Array[Prop] = [] - for hex_id in state.recent_extinction_hexes: - if state.cells.has(hex_id): - var cell: EvoTypes.HexCell = state.cells[hex_id] - if not r._is_water(cell): - var world_xy := HexMath.axial_to_world(cell.q, cell.r) - var r_prop := world_prop("ruins", world_xy + Vector2(0, 12), r._get_cell_top_z(cell), Vector2.ZERO, 2.20, Color.WHITE) - r_prop.project(r._ground, r._lift) - dyn_props.append(r_prop) - for village in r._villages: - collect_village_building_props(village, dyn_props) - for i in range(1, dyn_props.size()): - var key_dp: Prop = dyn_props[i] - var key_dy: float = key_dp.y - var j: int = i - 1 - while j >= 0 and dyn_props[j].y > key_dy: - dyn_props[j + 1] = dyn_props[j] - j -= 1 - dyn_props[j + 1] = key_dp +static func get_cell_road_corridors_local( + r: Node2D, + cell: EvoTypes.HexCell, + village_local_centers: Array[Vector2] +) -> Array[Dictionary]: + return EvoFloraPropLayout.get_cell_road_corridors_local(r, cell, village_local_centers) - var props: Array[Prop] = [] - var f_idx := 0 - var d_idx := 0 - var f_len := flora_sorted.size() - var d_len := dyn_props.size() - while f_idx < f_len and d_idx < d_len: - if flora_sorted[f_idx].y <= dyn_props[d_idx].y: - props.append(flora_sorted[f_idx]) - f_idx += 1 - else: - props.append(dyn_props[d_idx]) - d_idx += 1 - while f_idx < f_len: - props.append(flora_sorted[f_idx]) - f_idx += 1 - while d_idx < d_len: - props.append(dyn_props[d_idx]) - d_idx += 1 - return props +static func collect_cell_flora_props( + r: Node2D, + cell: EvoTypes.HexCell, + world_xy: Vector2, + top_z: float, + props: Array[Prop] +) -> void: + EvoFloraPropLayout.collect_cell_flora_props(r, cell, world_xy, top_z, props) static func collect_village_building_props(village: Dictionary, props: Array[Prop]) -> void: var deme: EvoTypes.Deme = village["deme"] var v_pos: Vector2 = village["pos"] - var sf: float = village["sf"] var m: Color = village["modulate"] - var tier := int(village["tier"]) + var eff_tier: int = deme.get_village_tier() var c_count := deme.cultural_trait_ids.size() var arch := str(village["archetype"]) var flags: int = ( @@ -549,13 +350,14 @@ static func collect_village_building_props(village: Dictionary, props: Array[Pro | (32 if bool(village.get("in_siege", false)) else 0) ) var vp_key: Array = [ - tier, - sf, + eff_tier, c_count, arch, flags, deme.defense_tier, deme.merged_count, + deme.walled_union_id, + snappedf(deme.salubrity, 0.05), m, v_pos ] @@ -566,7 +368,7 @@ static func collect_village_building_props(village: Dictionary, props: Array[Pro var v_props: Array[Prop] = [] var v_world: Vector2 = village["world_xy"] var top_z: float = village["top_z"] - var layout := get_village_building_layout(tier, sf, c_count > 0, c_count, arch) + var layout := get_village_building_layout(eff_tier, c_count > 0, c_count, arch) for b_item in layout: var off: Vector2 = b_item["offset"] var p := world_prop(str(b_item["sprite"]), v_world, top_z, off, float(b_item["scale"]), m) @@ -583,56 +385,40 @@ static func _draw_settlement_props(village: Dictionary, props: Array[Prop]) -> v var v_world: Vector2 = village["world_xy"] var v_pos: Vector2 = village["pos"] var top_z: float = village["top_z"] - var sf: float = village["sf"] var m: Color = village["modulate"] - if deme.defense_tier == 1: - var off := Vector2(0, 38) * sf - var p := world_prop("prop_defense_palisade", v_world, top_z, off, 2.20 * sf, m) - p.set_foot(v_pos + off) - props.append(p) - elif deme.defense_tier == 2: - var off := Vector2(0, 38) * sf - var p := world_prop("prop_defense_wall", v_world, top_z, off, 2.25 * sf, m) - p.set_foot(v_pos + off) - props.append(p) - elif deme.defense_tier >= 3: - var off := Vector2(0, 39) * sf - var p := world_prop("prop_defense_fortress", v_world, top_z, off, 2.30 * sf, m) - p.set_foot(v_pos + off) - props.append(p) if bool(village.get("has_pact", false)): - var off := (Vector2(-36, 30) if deme.defense_tier > 0 else Vector2(0, 34)) * sf - var p := world_prop("prop_pact_totem", v_world, top_z, off, 2.15 * sf, m) + var off := Vector2(-36, 30) + var p := world_prop("prop_pact_totem", v_world, top_z, off, 2.15, m) p.set_foot(v_pos + off) props.append(p) - if deme.merged_count > 0: - var off := Vector2(36, 30) * sf - var p := world_prop("prop_unity_monument", v_world, top_z, off, 2.15 * sf, m) + if deme.merged_count > 0 or deme.walled_union_id != "": + var off := Vector2(36, 30) + var p := world_prop("prop_unity_monument", v_world, top_z, off, 2.15, m) p.set_foot(v_pos + off) props.append(p) if bool(village.get("in_war", false)): - var off := Vector2(-58, 12) * sf - var p := world_prop("prop_war_banner", v_world, top_z, off, 2.25 * sf, m) + var off := Vector2(-58, 12) + var p := world_prop("prop_war_banner", v_world, top_z, off, 2.25, m) p.set_foot(v_pos + off) props.append(p) if bool(village.get("in_raid", false)): - var off := Vector2(58, 12) * sf - var p := world_prop("prop_raid_loot", v_world, top_z, off, 2.15 * sf, m) + var off := Vector2(58, 12) + var p := world_prop("prop_raid_loot", v_world, top_z, off, 2.15, m) p.set_foot(v_pos + off) props.append(p) if bool(village.get("in_tribute", false)): - var off := Vector2(56, 16) * sf - var p := world_prop("prop_tribute_chest", v_world, top_z, off, 2.10 * sf, m) + var off := Vector2(56, 16) + var p := world_prop("prop_tribute_chest", v_world, top_z, off, 2.10, m) p.set_foot(v_pos + off) props.append(p) if bool(village.get("in_siege", false)): - var off := Vector2(-66, 18) * sf - var p := world_prop("prop_siege_engine", v_world, top_z, off, 2.25 * sf, m) + var off := Vector2(-66, 18) + var p := world_prop("prop_siege_engine", v_world, top_z, off, 2.25, m) p.set_foot(v_pos + off) props.append(p) if bool(village.get("in_betrayal", false)): - var off := Vector2(0, -28) * sf - var p := world_prop("prop_betrayal_ruin", v_world, top_z, off, 2.20 * sf, m) + var off := Vector2(0, -28) + var p := world_prop("prop_betrayal_ruin", v_world, top_z, off, 2.20, m) p.set_foot(v_pos + off) props.append(p) @@ -644,22 +430,27 @@ static func build_geopolitics_by_deme(state: EvoTypes.WorldState) -> Dictionary: var empty_icons: Array[String] = [] geo_by_deme[did] = { "has_pact": false, + "has_vendetta": false, "in_war": false, "in_raid": false, "in_betrayal": false, "in_tribute": false, "in_siege": false, - "in_merge": d.merged_count > 0, + "in_merge": d.merged_count > 0 or d.walled_union_id != "", "geo_icons": empty_icons } - for pk_var in state.active_pacts.keys(): - if int(state.active_pacts[pk_var]) >= 2: + for pk_var in state.relations.keys(): + var rel: EvoTypes.Relation = state.relations[pk_var] + if rel.pact_turns > 0 or rel.feud_turns > 0: var pk := str(pk_var) - var sep := "__" if pk.contains("__") else "|" - var parts := pk.split(sep) + var parts := pk.split("__") if parts.size() == 2 and geo_by_deme.has(parts[0]) and geo_by_deme.has(parts[1]): - geo_by_deme[parts[0]]["has_pact"] = true - geo_by_deme[parts[1]]["has_pact"] = true + if rel.pact_turns > 0: + geo_by_deme[parts[0]]["has_pact"] = true + geo_by_deme[parts[1]]["has_pact"] = true + if rel.feud_turns > 0: + geo_by_deme[parts[0]]["has_vendetta"] = true + geo_by_deme[parts[1]]["has_vendetta"] = true for flow in state.active_flows: var ft := flow.flow_type if not ft in ["war", "raid", "betrayal", "pact", "tribute", "siege", "merge"]: @@ -683,268 +474,58 @@ static func build_geopolitics_by_deme(state: EvoTypes.WorldState) -> Dictionary: var d: EvoTypes.Deme = state.demes[did] var entry: Dictionary = geo_by_deme[did] var icons: Array[String] = entry["geo_icons"] - if d.defense_tier > 0: - icons.append("defense") - if d.merged_count > 0 or bool(entry["in_merge"]): - icons.append("merge") if bool(entry["in_siege"]): icons.append("siege") if bool(entry["in_war"]): icons.append("war") + if d.infection_turns > 0: + icons.append("epidemic") + if d.get_vital_status() == "starving": + icons.append("famine") if bool(entry["in_betrayal"]): icons.append("betrayal") if bool(entry["in_raid"]): icons.append("raid") + if bool(entry["has_vendetta"]): + icons.append("vendetta") if bool(entry["in_tribute"]): icons.append("tribute") if bool(entry["has_pact"]): icons.append("pact") + if d.merged_count > 0 or d.walled_union_id != "" or bool(entry["in_merge"]): + icons.append("merge") + if d.defense_tier > 0: + icons.append("defense") return geo_by_deme -static func resolve_deme_geopolitics(state: EvoTypes.WorldState, deme: EvoTypes.Deme) -> Dictionary: - var has_pact := false - var in_war := false - var in_raid := false - var in_betrayal := false - var in_tribute := false - var in_siege := false - var in_merge := deme.merged_count > 0 - if not state.active_pacts.is_empty(): - for other_id in state.demes.keys(): - if other_id != deme.id and EvoTypes.is_pact_active(state, deme.id, other_id): - has_pact = true - break - for flow in state.active_flows: - var touches_deme: bool = (flow.from_deme_id == deme.id or flow.to_deme_id == deme.id) - if not touches_deme and flow.from_deme_id == "" and flow.to_deme_id == "": - touches_deme = (flow.from_hex_id == deme.hex_id or flow.to_hex_id == deme.hex_id) - if not touches_deme: - continue - match flow.flow_type: - "war": - in_war = true - "raid": - in_raid = true - "betrayal": - in_betrayal = true - "pact": - has_pact = true - "tribute": - in_tribute = true - "siege": - in_siege = true - "merge": - in_merge = true - var geo_icons: Array[String] = [] - if deme.defense_tier > 0: - geo_icons.append("defense") - if deme.merged_count > 0 or in_merge: - geo_icons.append("merge") - if in_siege: - geo_icons.append("siege") - if in_war: - geo_icons.append("war") - if in_betrayal: - geo_icons.append("betrayal") - if in_raid: - geo_icons.append("raid") - if in_tribute: - geo_icons.append("tribute") - if has_pact: - geo_icons.append("pact") - return { - "has_pact": has_pact, - "in_war": in_war, - "in_raid": in_raid, - "in_betrayal": in_betrayal, - "in_tribute": in_tribute, - "in_siege": in_siege, - "in_merge": in_merge, - "geo_icons": geo_icons - } - - -static func get_cell_road_corridors_local( - r: Node2D, - cell: EvoTypes.HexCell, - village_local_centers: Array[Vector2] -) -> Array[Dictionary]: - var segments: Array[Dictionary] = [] - var v_count := village_local_centers.size() - if v_count == 0: - return segments - - for i in range(v_count): - for j in range(i + 1, v_count): - segments.append({"a": village_local_centers[i], "b": village_local_centers[j]}) - - var state: EvoTypes.WorldState = r.world_state - var cell_world := HexMath.axial_to_world(cell.q, cell.r) - for n_id in HexMath.get_neighbor_ids(cell.q, cell.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[n_id] - var has_link := not ncell.deme_ids.is_empty() or (ncell.biome == "coastal_shallows" and ncell.altitude >= state.sea_level) - if has_link: - var n_local := HexMath.axial_to_world(ncell.q, ncell.r) - cell_world - var best_vc: Vector2 = village_local_centers[0] - var best_d2: float = best_vc.distance_squared_to(n_local) - for v_i in range(1, v_count): - var d2 := village_local_centers[v_i].distance_squared_to(n_local) - if d2 < best_d2: - best_d2 = d2 - best_vc = village_local_centers[v_i] - segments.append({"a": best_vc, "b": n_local}) - return segments - - -static func collect_cell_flora_props( - r: Node2D, - cell: EvoTypes.HexCell, - world_xy: Vector2, - top_z: float, - props: Array[Prop] -) -> void: - var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) - - var base_count := 8 - if cell.biome == "tropical_forest": - base_count = 13 - elif cell.biome == "savanna": - base_count = 10 - elif cell.biome == "tundra": - base_count = 9 - elif cell.biome == "coastal_shallows": - base_count = 7 - - var prop_count: int = clampi(int(round(float(base_count) * (0.55 + 0.45 * k_ratio))), 6, 13) - - var village_local_centers: Array[Vector2] = [] - var v_count := cell.deme_ids.size() - for v_idx in range(v_count): - village_local_centers.append(HexMath.compute_village_sector_offset(v_idx, v_count, HexMath.HEX_SIZE)) - - if v_count > 0: - prop_count = clampi(prop_count - v_count, 5, 10) - - var road_corridors := get_cell_road_corridors_local(r, cell, village_local_centers) - var palette_sprites: Array = BIOME_FLORA_PALETTES[cell.biome] if BIOME_FLORA_PALETTES.has(cell.biome) else DEFAULT_FLORA_PALETTE - var placed_offsets: Array[Vector2] = [] - var village_clear_r := 118.0 if v_count == 1 else 88.0 - var village_clear_r_sq := village_clear_r * village_clear_r - var village_clear_r2_sq := (village_clear_r - 2.0) * (village_clear_r - 2.0) - var max_rad_sq := (HexMath.HEX_SIZE * 0.82) * (HexMath.HEX_SIZE * 0.82) - var inner_count: int = 0 if v_count > 0 else maxi(2, prop_count / 3) - var outer_count: int = prop_count - inner_count - var base_seed: int = cell.q * 73856093 ^ cell.r * 19349663 - - for idx in range(prop_count): - var s1: int = absi(base_seed ^ ((idx * 13 + 1) * 83492791)) - var s2: int = absi(base_seed ^ ((idx * 29 + 7) * 83492791)) - var angle := 0.0 - var rad_norm := 0.0 - if idx < inner_count: - angle = (TAU * float(idx) / float(inner_count)) + float(s1 % 31 - 15) * 0.045 - rad_norm = 0.22 + float(s2 % 22) * 0.01 - else: - var o_idx := idx - inner_count - angle = (TAU * float(o_idx) / float(maxi(1, outer_count))) + 0.32 + float(s1 % 31 - 15) * 0.032 - if v_count > 0: - rad_norm = 0.62 + float(s2 % 20) * 0.01 - else: - rad_norm = 0.50 + float(s2 % 28) * 0.01 - - var local_off := Vector2(cos(angle), sin(angle)) * (HexMath.HEX_SIZE * rad_norm) - - var valid_spot := true - for vc in village_local_centers: - var d_sq := local_off.distance_squared_to(vc) - if d_sq < village_clear_r_sq: - var push_dir := (local_off - vc).normalized() if d_sq > 0.0001 else Vector2(cos(angle), sin(angle)) - local_off = vc + push_dir * (village_clear_r + 8.0) - if local_off.length_squared() > max_rad_sq: - valid_spot = false - break - if not valid_spot: - continue - if v_count >= 2 and local_off.length_squared() < 5476.0: - continue - for vc in village_local_centers: - if local_off.distance_squared_to(vc) < village_clear_r2_sq: - valid_spot = false - break - if not valid_spot: - continue - - for seg in road_corridors: - var closest := Geometry2D.get_closest_point_to_segment(local_off, seg["a"], seg["b"]) - if local_off.distance_squared_to(closest) < 1764.0: - valid_spot = false - break - if not valid_spot: - continue - - for prev in placed_offsets: - if local_off.distance_squared_to(prev) < 2704.0: - valid_spot = false - break - if not valid_spot: - continue - - placed_offsets.append(local_off) - var sprite_key: String = palette_sprites[idx % palette_sprites.size()] - var can_sway := true - - if k_ratio < 0.60: - can_sway = false - if idx % 3 == 0: - sprite_key = "tree_stump" - elif idx % 3 == 1: - sprite_key = "soil_crack" - else: - sprite_key = "dry_scrub" - elif k_ratio < 0.85 and idx % 2 == 1: - can_sway = false - sprite_key = "tree_stump" if idx % 4 == 1 else ("soil_crack" if idx % 4 == 3 else "dry_scrub") - - var prop := world_prop(sprite_key, world_xy + local_off, top_z, Vector2.ZERO, 1.12 + float(s1 % 20) * 0.015, Color.WHITE) - prop.sways = can_sway and not NON_SWAYING_SPRITES.has(sprite_key) - prop.sway_phase = float(idx) * 0.9 + float(cell.q) - props.append(prop) - - static func get_deme_dominant_archetype(r: Node2D, deme: EvoTypes.Deme) -> String: + var action_type := str(deme.chosen_action["type"]) var catalog: Dictionary = r.world_state.decisions_catalog - if catalog.has(deme.last_dominant_decision_id): - var dec: EvoTypes.DecisionDef = catalog[deme.last_dominant_decision_id] + if catalog.has(action_type): + var dec: EvoTypes.DecisionDef = catalog[action_type] return dec.archetype - if deme.last_dominant_decision_id.contains("war"): - return "war" - if deme.last_dominant_decision_id.contains("raid"): - return "raid" - if deme.last_dominant_decision_id.contains("pact"): - return "pact" - if deme.last_dominant_decision_id.contains("betrayal"): - return "betrayal" - if deme.last_dominant_decision_id.contains("overexploit"): - return "overexploit" - if deme.last_dominant_decision_id.contains("migration"): - return "migrate" - if deme.last_dominant_decision_id.contains("cooperate"): - return "cooperate" - if deme.last_dominant_decision_id.contains("exchange"): - return "exchange" - if deme.last_dominant_decision_id.contains("dispute"): - return "dispute" - if deme.last_dominant_decision_id.contains("innovate"): - return "innovate" return "forage" static func draw_props(r: Node2D) -> void: + var zoom_val: float = r._get_camera_zoom() if r.has_method("_get_camera_zoom") else (r.camera.zoom_level if r.camera != null else 1.0) + var vis_rect: Rect2 = r._visible_world_rect + if zoom_val < 0.35: + for prop in r._static_props: + if not vis_rect.intersects(prop.rect): + continue + r._anim_layer.draw_texture_rect(prop.texture, prop.rect, false, prop.modulate) + if prop.trim_color.a > 0.0: + var trim_rect := Rect2( + prop.rect.position + Vector2(prop.rect.size.x * 0.30, prop.rect.size.y * 0.44), + Vector2(prop.rect.size.x * 0.40, maxf(2.0, prop.rect.size.y * 0.06)) + ) + r._anim_layer.draw_rect(trim_rect, prop.trim_color, true) + return var moving := collect_moving_props(r) moving.sort_custom(func(a: Prop, b: Prop) -> bool: return a.y < b.y) var m_idx := 0 - var vis_rect: Rect2 = r._visible_world_rect for prop in r._static_props: if not vis_rect.intersects(prop.rect): continue @@ -985,7 +566,9 @@ static func acquire_moving_prop( r._moving_prop_pool.append(prop) r._moving_prop_count += 1 prop.texture = tex - var sz: Vector2 = tex.get_size() * scale_val + var tex_sz := tex.get_size() + var dpi_norm: float = 0.32 if tex_sz.x <= 64.0 else 0.24 + var sz: Vector2 = tex_sz * (scale_val * dpi_norm) prop.anchor_offset = Vector2(sz.x * 0.5, sz.y * anchor_y_ratio) prop.rect = Rect2(foot - prop.anchor_offset, sz) prop.y = foot.y @@ -1005,23 +588,21 @@ static func collect_moving_props(r: Node2D) -> Array[Prop]: var v_pos: Vector2 = village["pos"] if not vis_rect.has_point(v_pos): continue - var deme: EvoTypes.Deme = village["deme"] - var sf: float = village["sf"] var m: Color = village["modulate"] - var strat_color := resolve_deme_strategy_color(r.world_state, deme) + var strat_color: Color = village["strat_color"] var cape_trim := strat_color.lightened(0.25) * m if int(village["tier"]) == 1: var fire_sprite := "tier1_campfire" if int(anim_time * 5.0) % 2 == 0 else "tier1_campfire_2" - props.append(acquire_moving_prop(r, EvoPixelArtGen.get_texture(fire_sprite), v_pos + Vector2(4, 8) * sf, 2.42 * sf, 0.78, m)) + props.append(acquire_moving_prop(r, EvoPixelArtGen.get_texture(fire_sprite), v_pos + Vector2(0, 6), 2.35, 0.78, m)) var villager_count: int = village["villager_count"] - var v_textures: Array[ImageTexture] = get_villager_textures_for_color(r, strat_color) + var v_textures: Array = village.get("villager_textures", []) for v_idx in range(villager_count): - var orbit_angle := anim_time * (0.95 + float(v_idx % 3) * 0.22) + float(v_idx) * (TAU / float(villager_count)) - var orbit_r := (28.0 + float(v_idx % 3) * 9.0) * sf - var foot := v_pos + Vector2(cos(orbit_angle) * orbit_r, sin(orbit_angle) * (orbit_r * 0.56) + 9.0 * sf) + var orbit_angle := anim_time * (0.90 + float(v_idx % 3) * 0.20) + float(v_idx) * (TAU / float(villager_count)) + var orbit_r := 34.0 + float(v_idx % 4) * 14.0 + var foot := v_pos + Vector2(cos(orbit_angle) * orbit_r, sin(orbit_angle) * (orbit_r * 0.56) + 9.0) var frame := int((anim_time * 6.0) + float(v_idx)) % 3 - var v_prop := acquire_moving_prop(r, v_textures[frame], foot, 2.60 * sf, 0.85, m) + var v_prop := acquire_moving_prop(r, v_textures[frame] as ImageTexture, foot, 2.55, 0.85, m) v_prop.trim_color = cape_trim props.append(v_prop) @@ -1035,6 +616,11 @@ static func collect_moving_props(r: Node2D) -> Array[Prop]: continue var curve_f: float = float(road["curve_factor"]) var r_textures: Array[ImageTexture] = road["villager_textures"] + if road["kind"] == "rural_farm": + var raw_wagon := fposmod(anim_time * 0.22 + float(r_idx) * 0.37, 1.0) + var t_wagon := lerpf(0.22, 0.82, raw_wagon) + var w_foot := EvoRoadNetworkRenderer.sample_road_curve(p_a, p_b, t_wagon, 1.0, curve_f) + props.append(acquire_moving_prop(r, EvoPixelArtGen.get_texture("covered_wagon"), w_foot, 1.85, 0.82, Color.WHITE)) for traveler_i in range(2): var raw_walk := fposmod(anim_time * 0.24 + float(r_idx) * 0.31 + float(traveler_i) * 0.48, 1.0) if traveler_i == 1: @@ -1042,7 +628,7 @@ static func collect_moving_props(r: Node2D) -> Array[Prop]: var t_walk := lerpf(0.24, 0.76, raw_walk) var foot := EvoRoadNetworkRenderer.sample_road_curve(p_a, p_b, t_walk, 1.0, curve_f) var frame := int((anim_time * 6.5) + float(traveler_i + r_idx)) % 3 - props.append(acquire_moving_prop(r, r_textures[frame], foot, 2.55, 0.85, Color.WHITE)) + props.append(acquire_moving_prop(r, r_textures[frame], foot, 2.50, 0.85, Color.WHITE)) return props diff --git a/src/world/render/road_network_renderer.gd b/src/world/render/road_network_renderer.gd index 3d63ace..53c25b9 100644 --- a/src/world/render/road_network_renderer.gd +++ b/src/world/render/road_network_renderer.gd @@ -1,52 +1,29 @@ class_name EvoRoadNetworkRenderer extends RefCounted +const DISPUTE_FRONT_MIN_PROB: float = 0.30 + static func collect_roads(r: Node2D) -> Array[Dictionary]: var roads: Array[Dictionary] = [] var handled_deme_pairs: Dictionary = {} var handled_hex_pairs: Dictionary = {} - var intra_hex_badge_drawn: Dictionary = {} var badge_positions: Array[Vector2] = [] var hex_traffic_counts: Dictionary = {} var state: EvoTypes.WorldState = r.world_state var v_positions := PackedVector2Array() var deme_village: Dictionary = {} var deme_road_stance: Dictionary = {} - var catalog: Dictionary = state.decisions_catalog for v in r._villages: v_positions.append(v["pos"] as Vector2) var did: String = v["deme_id"] deme_village[did] = v if state.demes.has(did): - var d: EvoTypes.Deme = state.demes[did] - var p_disp := 0.0 - var p_over := 0.0 - var p_coop := 0.0 - for dec_id in d.last_decision_probabilities.keys(): - var prob := float(d.last_decision_probabilities[dec_id]) - var arch := "" - if catalog.has(dec_id): - arch = (catalog[dec_id] as EvoTypes.DecisionDef).archetype - else: - var s := str(dec_id) - if s.contains("dispute"): - arch = "dispute" - elif s.contains("overexploit"): - arch = "overexploit" - elif s.contains("cooperate") or s.contains("exchange"): - arch = "cooperate" - if arch == "dispute": - p_disp += prob - elif arch == "overexploit": - p_over += prob - elif arch == "cooperate" or arch == "exchange": - p_coop += prob var dom_arch: String = v["archetype"] deme_road_stance[did] = { "arch": dom_arch, - "conflict": p_disp >= 0.10 or p_over >= 0.30 or dom_arch == "dispute" or dom_arch == "overexploit", - "coop": p_coop >= 0.14 or dom_arch == "cooperate" or dom_arch == "exchange" + "conflict": dom_arch in ["dispute", "raid", "war", "betrayal", "overexploit"], + "coop": dom_arch in ["cooperate", "exchange", "pact"] } r.set_meta("_v_pos_cache", v_positions) r.set_meta("_deme_v_cache", deme_village) @@ -55,13 +32,12 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: for flow in state.active_flows: if not state.cells.has(flow.from_hex_id) or not state.cells.has(flow.to_hex_id): continue + if flow.from_hex_id == flow.to_hex_id: + continue var cell_a: EvoTypes.HexCell = state.cells[flow.from_hex_id] var cell_b: EvoTypes.HexCell = state.cells[flow.to_hex_id] - var is_same_hex := cell_a.id == cell_b.id var h_key := make_pair_key(flow.from_hex_id, flow.to_hex_id) - if not is_same_hex and handled_hex_pairs.has(h_key): - continue - if is_same_hex and flow.from_deme_id != "" and flow.to_deme_id != "" and handled_deme_pairs.has(make_pair_key(flow.from_deme_id, flow.to_deme_id)): + if handled_hex_pairs.has(h_key): continue var endpoints := resolve_road_endpoints(r, cell_a, cell_b, flow.from_deme_id, flow.to_deme_id) @@ -75,7 +51,7 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: ) var p_a: Vector2 = trimmed["p_a"] var p_b: Vector2 = trimmed["p_b"] - var curve_f := 0.035 if is_same_hex else 0.045 + var curve_f := 0.045 var is_prio: bool = ( cell_a.id == r.selected_hex_id or cell_b.id == r.selected_hex_id @@ -83,8 +59,7 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: or cell_b.id == r.hovered_hex_id ) - if not is_same_hex: - handled_hex_pairs[h_key] = true + handled_hex_pairs[h_key] = true if flow.from_deme_id != "" and flow.to_deme_id != "": handled_deme_pairs[make_pair_key(flow.from_deme_id, flow.to_deme_id)] = true @@ -99,13 +74,21 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: flow_color = EvoPropFloraRenderer.resolve_deme_strategy_color(state, state.demes[cell_a.deme_ids[0]]) var effective_type := flow.flow_type + var d_from_id := flow.from_deme_id if flow.from_deme_id != "" else (cell_a.deme_ids[0] if not cell_a.deme_ids.is_empty() else "") + var d_to_id := flow.to_deme_id if flow.to_deme_id != "" else (cell_b.deme_ids[0] if not cell_b.deme_ids.is_empty() else "") + if d_from_id != "" and d_to_id != "" and state.demes.has(d_from_id) and state.demes.has(d_to_id): + var d1: EvoTypes.Deme = state.demes[d_from_id] + var d2: EvoTypes.Deme = state.demes[d_to_id] + if d1.walled_union_id != "" and d1.walled_union_id == d2.walled_union_id and effective_type in ["exchange", "cooperate", "trade", "pact", "merge"]: + effective_type = "merge" + curve_f = 0.0 if effective_type in ["exchange", "cooperate"] and flow.from_deme_id != "" and flow.to_deme_id != "": if EvoTypes.is_pact_active(state, flow.from_deme_id, flow.to_deme_id): effective_type = "pact" match effective_type: "war", "siege": - var b_pos: Vector2 = r._cell_top_center(cell_a) if is_same_hex else (p_a + p_b) * 0.5 + var b_pos: Vector2 = (p_a + p_b) * 0.5 var show_badge := register_road_badge(r, b_pos, badge_positions, true) roads.append({ "kind": effective_type, @@ -117,19 +100,15 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "world_b": endpoints["world_b"], "z_b": endpoints["z_b"], "trim_b": trim_b, - "badge_world": HexMath.axial_to_world(cell_a.q, cell_a.r), - "badge_z": r._get_cell_top_z(cell_a), "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, - "show_traffic": true, "badge_pos": b_pos, "color": flow_color }) "raid": - var b_pos: Vector2 = r._cell_top_center(cell_a) if is_same_hex else (p_a + p_b) * 0.5 + var b_pos: Vector2 = (p_a + p_b) * 0.5 var show_badge := register_road_badge(r, b_pos, badge_positions, true) roads.append({ "kind": "raid", @@ -141,15 +120,10 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "world_b": endpoints["world_b"], "z_b": endpoints["z_b"], "trim_b": trim_b, - "badge_world": HexMath.axial_to_world(cell_a.q, cell_a.r), - "badge_z": r._get_cell_top_z(cell_a), "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, - "show_traffic": true, - "badge_pos": b_pos, "color": flow_color }) "tribute": @@ -168,11 +142,9 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, "show_traffic": show_traffic, - "badge_pos": b_pos, "color": Color("#f59e0b") }) "merge": @@ -191,15 +163,13 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, "show_traffic": show_traffic, - "badge_pos": b_pos, "color": Color("#fbbf24") }) "betrayal": - var b_pos: Vector2 = r._cell_top_center(cell_a) if is_same_hex else (p_a + p_b) * 0.5 + var b_pos: Vector2 = (p_a + p_b) * 0.5 var show_badge := register_road_badge(r, b_pos, badge_positions, true) roads.append({ "kind": "betrayal", @@ -211,11 +181,8 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "world_b": endpoints["world_b"], "z_b": endpoints["z_b"], "trim_b": trim_b, - "badge_world": HexMath.axial_to_world(cell_a.q, cell_a.r), - "badge_z": r._get_cell_top_z(cell_a), "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, "badge_pos": b_pos @@ -236,21 +203,14 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, "show_traffic": show_traffic, - "badge_pos": b_pos, "color": Color("#10b981") }) "dispute": - var b_pos: Vector2 = r._cell_top_center(cell_a) if is_same_hex else (p_a + p_b) * 0.5 - var show_badge := true - if is_same_hex: - show_badge = not intra_hex_badge_drawn.has(cell_a.id) - intra_hex_badge_drawn[cell_a.id] = true - if show_badge: - show_badge = register_road_badge(r, b_pos, badge_positions, is_prio) + var b_pos: Vector2 = (p_a + p_b) * 0.5 + var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) roads.append({ "kind": "dispute", "hex_a": cell_a.id, @@ -261,16 +221,13 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "world_b": endpoints["world_b"], "z_b": endpoints["z_b"], "trim_b": trim_b, - "badge_world": HexMath.axial_to_world(cell_a.q, cell_a.r), - "badge_z": r._get_cell_top_z(cell_a), "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "show_badge": show_badge, "badge_pos": b_pos }) "exchange", "cooperate", "trade": - var kind := "bridge" if (not is_same_hex and should_use_bridge(r, cell_a, cell_b)) else "collab" + var kind := "bridge" if should_use_bridge(r, cell_a, cell_b) else "collab" var b_pos := bridge_arch_point(p_a, p_b, 0.5) if kind == "bridge" else sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) var show_traffic := claim_road_traffic(cell_a.id, cell_b.id, hex_traffic_counts, is_prio) @@ -286,7 +243,6 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": is_same_hex, "curve_factor": curve_f, "show_badge": show_badge, "show_traffic": show_traffic, @@ -314,138 +270,49 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: if d.population > 0 and state.cells.has(d.hex_id): all_demes.append(d) - var connected_deme_ids: Dictionary = {} for d in all_demes: var ca: EvoTypes.HexCell = state.cells[d.hex_id] - if ca.deme_ids.size() > 1: - connected_deme_ids[d.id] = true - continue - for n_id in HexMath.get_neighbor_ids(ca.q, ca.r, state.cells): - if not (state.cells[n_id] as EvoTypes.HexCell).deme_ids.is_empty(): - connected_deme_ids[d.id] = true - break - - for hex_id in state.cells: - var ca: EvoTypes.HexCell = state.cells[hex_id] - if ca.deme_ids.size() < 2: - continue - var z_ca: float = r._get_cell_top_z(ca) - var world_ca := HexMath.axial_to_world(ca.q, ca.r) - for i in range(ca.deme_ids.size()): - var id_a: String = ca.deme_ids[i] - if not state.demes.has(id_a): + var world_a := HexMath.axial_to_world(ca.q, ca.r) + var z_a: float = r._get_cell_top_z(ca) + var trim_a := get_cell_plaza_trim(ca) + var p_a_raw: Vector2 = r._project(world_a, z_a) + for dir_idx in range(6): + var n_id: String = r._neighbor_id(ca, dir_idx) + if not state.cells.has(n_id): continue - var da: EvoTypes.Deme = state.demes[id_a] - for j in range(i + 1, ca.deme_ids.size()): - var id_b: String = ca.deme_ids[j] - if not state.demes.has(id_b): - continue - var db: EvoTypes.Deme = state.demes[id_b] - var d_key := make_pair_key(da.id, db.id) - if handled_deme_pairs.has(d_key): - continue - handled_deme_pairs[d_key] = true - - var trim_r := get_cell_plaza_trim(ca) - var world_a := get_deme_world_pos(ca, da.id) - var world_b := get_deme_world_pos(ca, db.id) - var trimmed := trim_road_segment(r._project(world_a, z_ca), r._project(world_b, z_ca), trim_r, trim_r) - var p_a: Vector2 = trimmed["p_a"] - var p_b: Vector2 = trimmed["p_b"] - var is_prio: bool = (ca.id == r.selected_hex_id or ca.id == r.hovered_hex_id) - - match classify_village_pair_relation(r, da, db): - "pact": - var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, 0.035) - var show_badge := register_road_badge(r, b_pos, badge_positions, true) - var show_traffic := claim_road_traffic(ca.id, ca.id, hex_traffic_counts, true) - roads.append({ - "kind": "pact", - "hex_a": ca.id, - "hex_b": ca.id, - "world_a": world_a, - "z_a": z_ca, - "trim_a": trim_r, - "world_b": world_b, - "z_b": z_ca, - "trim_b": trim_r, - "p_a": p_a, - "p_b": p_b, - "is_same_hex": true, - "curve_factor": 0.035, - "show_badge": show_badge, - "show_traffic": show_traffic, - "badge_pos": b_pos, - "color": Color("#10b981") - }) - "dispute_front": - var b_pos: Vector2 = r._cell_top_center(ca) - var show_badge := not intra_hex_badge_drawn.has(ca.id) - intra_hex_badge_drawn[ca.id] = true - if show_badge: - show_badge = register_road_badge(r, b_pos, badge_positions, is_prio) - roads.append({ - "kind": "dispute", - "world_a": world_a, - "z_a": z_ca, - "trim_a": trim_r, - "world_b": world_b, - "z_b": z_ca, - "trim_b": trim_r, - "badge_world": world_ca, - "badge_z": z_ca, - "p_a": p_a, - "p_b": p_b, - "is_same_hex": true, - "show_badge": show_badge, - "badge_pos": b_pos - }) - "active_road": - var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, 0.035) - var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) - var show_traffic := claim_road_traffic(ca.id, ca.id, hex_traffic_counts, is_prio) - roads.append({ - "kind": "collab", - "hex_a": ca.id, - "hex_b": ca.id, - "world_a": world_a, - "z_a": z_ca, - "trim_a": trim_r, - "world_b": world_b, - "z_b": z_ca, - "trim_b": trim_r, - "p_a": p_a, - "p_b": p_b, - "is_same_hex": true, - "curve_factor": 0.035, - "show_badge": show_badge, - "show_traffic": show_traffic, - "color": Color(da.color) - }) - _: - var dis_age := get_disused_road_age(r, da.id, db.id, ca.id, ca.id) - if dis_age > 0 or (state.generation == 0 and state.active_flows.is_empty()): - var b_pos := (p_a + p_b) * 0.5 - var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) - roads.append({ - "kind": "broken", - "world_a": world_a, - "z_a": z_ca, - "trim_a": trim_r, - "world_b": world_b, - "z_b": z_ca, - "trim_b": trim_r, - "p_a": p_a, - "p_b": p_b, - "show_badge": show_badge, - "alpha": 0.52 if dis_age == 2 else 0.90 - }) + var cb: EvoTypes.HexCell = state.cells[n_id] + if cb.deme_ids.is_empty() and cb.owner_deme_id == d.id and (cb.land_use in ["crops", "pasture"]): + var h_key := make_pair_key(ca.id, cb.id) + if not handled_hex_pairs.has(h_key): + handled_hex_pairs[h_key] = true + var world_b := HexMath.axial_to_world(cb.q, cb.r) + var z_b: float = r._get_cell_top_z(cb) + var trim_b := 22.0 + var trimmed := trim_road_segment(p_a_raw, r._project(world_b, z_b), trim_a, trim_b) + roads.append({ + "kind": "rural_farm", + "hex_a": ca.id, + "hex_b": cb.id, + "world_a": world_a, + "z_a": z_a, + "trim_a": trim_a, + "world_b": world_b, + "z_b": z_b, + "trim_b": trim_b, + "p_a": trimmed["p_a"], + "p_b": trimmed["p_b"], + "curve_factor": 0.035, + "show_badge": false, + "show_traffic": true, + "color": EvoPropFloraRenderer.resolve_deme_strategy_color(state, d) + }) - for hex_id in state.cells: - var ca: EvoTypes.HexCell = state.cells[hex_id] - if ca.deme_ids.is_empty(): - continue - for n_id in HexMath.get_neighbor_ids(ca.q, ca.r, state.cells): + for d in all_demes: + var ca: EvoTypes.HexCell = state.cells[d.hex_id] + for dir_idx in range(6): + var n_id: String = r._neighbor_id(ca, dir_idx) + if not state.cells.has(n_id): + continue var cb: EvoTypes.HexCell = state.cells[n_id] if cb.deme_ids.is_empty(): continue @@ -481,6 +348,28 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: or cb.id == r.hovered_hex_id ) + if da.walled_union_id != "" and da.walled_union_id == db.walled_union_id: + var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, 0.0) + var show_badge := register_road_badge(r, b_pos, badge_positions, true) + roads.append({ + "kind": "merge", + "hex_a": ca.id, + "hex_b": cb.id, + "world_a": world_a, + "z_a": z_a, + "trim_a": trim_a, + "world_b": world_b, + "z_b": z_b, + "trim_b": trim_b, + "p_a": p_a, + "p_b": p_b, + "curve_factor": 0.0, + "show_badge": show_badge, + "show_traffic": true, + "color": Color("#fbbf24") + }) + continue + match classify_village_pair_relation(r, da, db): "pact": var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, 0.045) @@ -498,11 +387,9 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": false, "curve_factor": 0.045, "show_badge": show_badge, "show_traffic": show_traffic, - "badge_pos": b_pos, "color": Color("#10b981") }) "dispute_front": @@ -518,7 +405,6 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": false, "show_badge": show_badge, "badge_pos": b_pos }) @@ -539,7 +425,6 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": false, "curve_factor": 0.045, "show_badge": show_badge, "show_traffic": show_traffic, @@ -564,108 +449,27 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "alpha": 0.52 if dis_age == 2 else 0.90 }) - if state.generation <= 2 and all_demes.size() == 2 and state.cells.has("0,0") and (state.cells["0,0"] as EvoTypes.HexCell).deme_ids.is_empty(): - var da: EvoTypes.Deme = all_demes[0] - var db: EvoTypes.Deme = all_demes[1] - if not connected_deme_ids.has(da.id) and not connected_deme_ids.has(db.id): - var d_key := make_pair_key(da.id, db.id) - if not handled_deme_pairs.has(d_key): - handled_deme_pairs[d_key] = true - var ca: EvoTypes.HexCell = state.cells[da.hex_id] - var cb: EvoTypes.HexCell = state.cells[db.hex_id] - var c_mid: EvoTypes.HexCell = state.cells["0,0"] - var w_a := get_deme_world_pos(ca, da.id) - var z_a: float = r._get_cell_top_z(ca) - var trim_a := get_cell_plaza_trim(ca) - var w_mid := HexMath.axial_to_world(c_mid.q, c_mid.r) - var z_mid: float = r._get_cell_top_z(c_mid) - var w_b := get_deme_world_pos(cb, db.id) - var z_b: float = r._get_cell_top_z(cb) - var trim_b := get_cell_plaza_trim(cb) - var p_mid: Vector2 = r._project(w_mid, z_mid) - var seg1 := trim_road_segment(r._project(w_a, z_a), p_mid, trim_a, 18.0) - var seg2 := trim_road_segment(p_mid, r._project(w_b, z_b), 18.0, trim_b) - var alpha_val := 0.90 if state.generation <= 1 else 0.55 - roads.append({ - "kind": "broken", - "world_a": w_a, - "z_a": z_a, - "trim_a": trim_a, - "world_b": w_mid, - "z_b": z_mid, - "trim_b": 18.0, - "p_a": seg1["p_a"], - "p_b": seg1["p_b"], - "show_badge": true, - "alpha": alpha_val - }) - roads.append({ - "kind": "broken", - "world_a": w_mid, - "z_a": z_mid, - "trim_a": 18.0, - "world_b": w_b, - "z_b": z_b, - "trim_b": trim_b, - "p_a": seg2["p_a"], - "p_b": seg2["p_b"], - "show_badge": true, - "alpha": alpha_val - }) - - for da in all_demes: - if connected_deme_ids.has(da.id): - continue - var ca: EvoTypes.HexCell = state.cells[da.hex_id] - var w_a := get_deme_world_pos(ca, da.id) - var z_a: float = r._get_cell_top_z(ca) - var p_a_raw: Vector2 = r._project(w_a, z_a) - var trim_a := get_cell_plaza_trim(ca) - for n_id in HexMath.get_neighbor_ids(ca.q, ca.r, state.cells): - var ncell: EvoTypes.HexCell = state.cells[n_id] - if ncell.biome == "coastal_shallows" and ncell.altitude >= state.sea_level: - var h_key := make_pair_key(ca.id, ncell.id) - if not handled_hex_pairs.has(h_key): - handled_hex_pairs[h_key] = true - var w_b := HexMath.axial_to_world(ncell.q, ncell.r) - var z_b: float = r._get_cell_top_z(ncell) - var seg := trim_road_segment(p_a_raw, r._project(w_b, z_b), trim_a, 18.0) - roads.append({ - "kind": "expedition", - "world_a": w_a, - "z_a": z_a, - "trim_a": trim_a, - "world_b": w_b, - "z_b": z_b, - "trim_b": 18.0, - "p_a": seg["p_a"], - "p_b": seg["p_b"], - "curve_factor": 0.14 - }) - for disused in state.disused_connections: var age_val := int(disused.get("age", 1)) if age_val > 2: continue var from_id := str(disused.get("from_hex_id", "")) var to_id := str(disused.get("to_hex_id", "")) - if not state.cells.has(from_id) or not state.cells.has(to_id): + if not state.cells.has(from_id) or not state.cells.has(to_id) or from_id == to_id: continue var ca: EvoTypes.HexCell = state.cells[from_id] var cb: EvoTypes.HexCell = state.cells[to_id] if HexMath.hex_distance(ca.q, ca.r, cb.q, cb.r) > 1: continue - var is_same_hex := from_id == to_id var h_key := make_pair_key(from_id, to_id) var fa := str(disused.get("from_deme_id", "")) var fb := str(disused.get("to_deme_id", "")) var d_key := make_pair_key(fa, fb) if (fa != "" and fb != "") else "" - if not is_same_hex and handled_hex_pairs.has(h_key): + if handled_hex_pairs.has(h_key): continue if d_key != "" and handled_deme_pairs.has(d_key): continue - if not is_same_hex: - handled_hex_pairs[h_key] = true + handled_hex_pairs[h_key] = true if d_key != "": handled_deme_pairs[d_key] = true @@ -709,7 +513,7 @@ static func prune_redundant_triangle_chords(r: Node2D, roads: Array[Dictionary]) var coop_pairs: Dictionary = {} for rd in roads: var k := str(rd.get("kind", "")) - if k in ["collab", "bridge"] and not bool(rd.get("is_same_hex", false)): + if k in ["collab", "bridge"]: var ha := str(rd.get("hex_a", "")) var hb := str(rd.get("hex_b", "")) if ha != "" and hb != "": @@ -718,7 +522,7 @@ static func prune_redundant_triangle_chords(r: Node2D, roads: Array[Dictionary]) var result: Array[Dictionary] = [] for rd in roads: var k := str(rd.get("kind", "")) - if k in ["collab", "bridge"] and not bool(rd.get("is_same_hex", false)): + if k in ["collab", "bridge"]: var ha := str(rd.get("hex_a", "")) var hb := str(rd.get("hex_b", "")) if state.cells.has(ha) and state.cells.has(hb): @@ -757,19 +561,8 @@ static func project_road_entry(r: Node2D, road: Dictionary, force_reproject: boo else: p_a = road["p_a"] p_b = road["p_b"] - road["a_pos"] = p_a - road["b_pos"] = p_b - var curve_f := float(road.get("curve_factor", 0.045)) - var curve_sign := -1.0 if road.get("kind", "") == "expedition" else 1.0 - var diff := p_b - p_a - var dist := diff.length() - var perp := Vector2(-diff.y, diff.x).normalized() if dist > 0.01 else Vector2.UP - road["ctrl"] = (p_a + p_b) * 0.5 + perp * (minf(dist * curve_f, 12.0) * curve_sign) - if force_reproject and road.get("kind", "") in ["dispute", "war", "siege", "raid", "betrayal", "pact", "tribute", "merge"]: - if bool(road.get("is_same_hex", false)) and road.has("badge_world"): - road["badge_pos"] = r._project(road["badge_world"] as Vector2, float(road["badge_z"])) - else: - road["badge_pos"] = (p_a + p_b) * 0.5 + if force_reproject and road.get("kind", "") in ["dispute", "war", "siege", "betrayal"]: + road["badge_pos"] = (p_a + p_b) * 0.5 static func reproject_roads(r: Node2D) -> void: @@ -777,110 +570,30 @@ static func reproject_roads(r: Node2D) -> void: project_road_entry(r, road, true) -static func _draw_road_network(r: Node2D) -> void: - draw_network_roads(r) - - static func draw_network_roads(r: Node2D) -> void: - for road in r._roads: - match road["kind"]: - "pact": - draw_pact_highway(r, road) - "merge": - draw_merge_avenue(r, road) - "tribute": - draw_tribute_route(r, road) - "war", "siege": - draw_war_corridor(r, road) - "raid": - draw_raid_trail(r, road) - "betrayal": - draw_betrayal_road(r, road) - "collab": - draw_collaboration_route(r, road) - "bridge": - draw_wooden_bridge(r, road) - "dispute": - draw_dispute_barricade_front(r, road) - "broken": - draw_broken_cut_road(r, road) - "expedition": - draw_migration_expedition(r, road) + EvoRoadWallDrawer.draw_network_roads(r) static func draw_animated_roads(r: Node2D) -> void: - var anim_layer: Node2D = r._anim_layer - var vis_rect: Rect2 = r._visible_world_rect - var anim_time: float = r._anim_time - for road in r._roads: - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - if not vis_rect.has_point(p_a) and not vis_rect.has_point(p_b): - continue - var curve_f: float = float(road.get("curve_factor", 0.14)) - match road["kind"]: - "pact": - if bool(road.get("show_traffic", true)): - var diff := p_b - p_a - var perp := Vector2(-diff.y, diff.x).normalized() if diff.length() > 0.01 else Vector2.UP - var raw_t1 := fposmod(anim_time * 0.34 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) - var raw_t2 := fposmod(raw_t1 + 0.5, 1.0) - var pos_1 := sample_road_curve(p_a, p_b, lerpf(0.18, 0.82, raw_t1), 1.0, curve_f) + perp * 3.2 + Vector2(0, -6.0) - var pos_2 := sample_road_curve(p_a, p_b, lerpf(0.82, 0.18, raw_t2), 1.0, curve_f) - perp * 3.2 + Vector2(0, -6.0) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", pos_1, 1.55) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", pos_2, 1.55) - "merge": - if bool(road.get("show_traffic", true)): - var raw_m := fposmod(anim_time * 0.36 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) - var wagon_pos := sample_road_curve(p_a, p_b, lerpf(0.18, 0.82, raw_m), 1.0, curve_f) + Vector2(0, -6.0) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "settler_wagon", wagon_pos, 1.55) - "tribute": - if bool(road.get("show_traffic", true)): - var raw_tr := fposmod(anim_time * 0.36 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) - var cart_pos := sample_road_curve(p_a, p_b, lerpf(0.18, 0.82, raw_tr), 1.0, curve_f) + Vector2(0, -6.0) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", cart_pos, 1.55) - "war", "siege": - draw_war_animated_invasion(r, road) - "raid": - draw_raid_animated_plunder(r, road) - "betrayal": - draw_betrayal_animated_ruin(r, road) - "collab": - if bool(road.get("show_traffic", true)): - var raw_t := fposmod(anim_time * 0.32 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) - var t_cart := lerpf(0.22, 0.78, raw_t) - EvoWorldOverlayRenderer.draw_centered_sprite( - anim_layer, - "caravan_cart", - sample_road_curve(p_a, p_b, t_cart, 1.0, curve_f) + Vector2(0, -6.0), - 1.55 - ) - "bridge": - if bool(road.get("show_traffic", true)): - var raw_b := fposmod(anim_time * 0.32, 1.0) - var cart_pos := bridge_arch_point(p_a, p_b, lerpf(0.20, 0.80, raw_b)) + Vector2(0, -6.0) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", cart_pos, 1.55) - "dispute": - if bool(road.get("show_badge", true)): - draw_battle_sparks(r, road.get("badge_pos", (p_a + p_b) * 0.5)) - "expedition": - var raw_w := fposmod(anim_time * 0.38, 1.0) - var wagon_pos := sample_road_curve(p_a, p_b, lerpf(0.20, 0.82, raw_w), -1.0, curve_f) + Vector2(0, -6.0) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "settler_wagon", wagon_pos, 1.55) + EvoRoadWallDrawer.draw_animated_roads(r) + + +static func draw_settlement_perimeter_walls(r: Node2D) -> void: + EvoRoadWallDrawer.draw_settlement_perimeter_walls(r) -static func draw_battle_sparks(r: Node2D, mid: Vector2) -> void: - var anim_layer: Node2D = r._anim_layer - var anim_time: float = r._anim_time - var pulse := 1.0 + 0.22 * sin(anim_time * 8.5) - for s_i in range(4): - var ang := anim_time * 5.0 + float(s_i) * (TAU * 0.25) - var spark_pos := mid + Vector2(cos(ang) * 11.0, sin(ang) * 6.5 - 6.0) - anim_layer.draw_circle(spark_pos, 3.0, Color("#fde047") if s_i % 2 == 0 else Color("#f97316")) +static func sample_road_curve( + p_a: Vector2, + p_b: Vector2, + t: float, + curve_sign: float, + curve_factor: float = 0.045 +) -> Vector2: + return EvoRoadWallDrawer.sample_road_curve(p_a, p_b, t, curve_sign, curve_factor) + - var badge_pos := mid + Vector2(0, -18.0) - anim_layer.draw_circle(badge_pos, 14.0 * pulse, Color(0.94, 0.20, 0.20, 0.38)) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "icon_conflict_swords", badge_pos + Vector2(0, 8.0), 1.20) +static func bridge_arch_point(p_a: Vector2, p_b: Vector2, t: float) -> Vector2: + return EvoRoadWallDrawer.bridge_arch_point(p_a, p_b, t) static func make_pair_key(a: String, b: String) -> String: @@ -888,11 +601,9 @@ static func make_pair_key(a: String, b: String) -> String: static func get_cell_plaza_trim(cell: EvoTypes.HexCell) -> float: - var count := cell.deme_ids.size() - if count <= 0: + if cell.deme_ids.is_empty(): return 16.0 - var sf := 1.0 if count == 1 else (0.86 if count == 2 else 0.78) - return 44.0 * sf + return 48.0 static func trim_road_segment(p_a: Vector2, p_b: Vector2, trim_a: float, trim_b: float) -> Dictionary: @@ -913,15 +624,10 @@ static func register_road_badge( is_priority: bool ) -> bool: var min_v_dist_sq := (42.0 * 42.0) if is_priority else (54.0 * 54.0) - if r.has_meta("_v_pos_cache"): - var v_positions: PackedVector2Array = r.get_meta("_v_pos_cache") - for vp in v_positions: - if badge_pos.distance_squared_to(vp) < min_v_dist_sq: - return false - else: - for v in r._villages: - if badge_pos.distance_squared_to(v["pos"] as Vector2) < min_v_dist_sq: - return false + var v_positions: PackedVector2Array = r.get_meta("_v_pos_cache") + for vp in v_positions: + if badge_pos.distance_squared_to(vp) < min_v_dist_sq: + return false var min_b_dist_sq := (34.0 * 34.0) if is_priority else (48.0 * 48.0) for bp in badge_positions: if badge_pos.distance_squared_to(bp) < min_b_dist_sq: @@ -972,23 +678,11 @@ static func should_use_bridge(r: Node2D, cell_a: EvoTypes.HexCell, cell_b: EvoTy ) -static func get_deme_archetype_prob(r: Node2D, deme: EvoTypes.Deme, target_arch: String) -> float: - var total := 0.0 - var catalog: Dictionary = r.world_state.decisions_catalog - for dec_id in deme.last_decision_probabilities.keys(): - var prob := float(deme.last_decision_probabilities[dec_id]) - if catalog.has(dec_id): - var d_def: EvoTypes.DecisionDef = catalog[dec_id] - if d_def.archetype == target_arch: - total += prob - elif str(dec_id).contains(target_arch): - total += prob - return total - - static func classify_village_pair_relation(r: Node2D, da: EvoTypes.Deme, db: EvoTypes.Deme) -> String: if EvoTypes.is_pact_active(r.world_state, da.id, db.id): return "pact" + if EvoTypes.is_vendetta_active(r.world_state, da.id, db.id): + return "dispute_front" var stance_cache: Dictionary = r.get_meta("_deme_stance_cache") if r.has_meta("_deme_stance_cache") else {} if stance_cache.has(da.id) and stance_cache.has(db.id): @@ -1005,148 +699,38 @@ static func classify_village_pair_relation(r: Node2D, da: EvoTypes.Deme, db: Evo return "active_road" if da.lineage_id == db.lineage_id and da.isolation_turns < 3 and db.isolation_turns < 3 and not has_conflict_c: return "active_road" - return "broken_road" - - var arch_a: String = r._get_deme_dominant_archetype(da) - var arch_b: String = r._get_deme_dominant_archetype(db) - - var disp_a := get_deme_archetype_prob(r, da, "dispute") - var disp_b := get_deme_archetype_prob(r, db, "dispute") - var over_a := get_deme_archetype_prob(r, da, "overexploit") - var over_b := get_deme_archetype_prob(r, db, "overexploit") - - var has_conflict_stance: bool = ( - disp_a >= 0.10 or disp_b >= 0.10 - or over_a >= 0.30 or over_b >= 0.30 - or arch_a == "dispute" or arch_b == "dispute" - or arch_a == "overexploit" or arch_b == "overexploit" - ) - - if da.lineage_id != db.lineage_id and has_conflict_stance: - return "dispute_front" - if arch_a == "dispute" or arch_b == "dispute": - return "dispute_front" - - var coop_a := get_deme_archetype_prob(r, da, "cooperate") + get_deme_archetype_prob(r, da, "exchange") - var coop_b := get_deme_archetype_prob(r, db, "cooperate") + get_deme_archetype_prob(r, db, "exchange") - - if coop_a >= 0.14 or coop_b >= 0.14 or arch_a in ["cooperate", "exchange"] or arch_b in ["cooperate", "exchange"]: - return "active_road" - if da.lineage_id == db.lineage_id and da.isolation_turns < 3 and db.isolation_turns < 3 and not has_conflict_stance: - return "active_road" - return "broken_road" -static func get_deme_world_pos(cell: EvoTypes.HexCell, deme_id: String) -> Vector2: - var w_xy := HexMath.axial_to_world(cell.q, cell.r) - var idx := cell.deme_ids.find(deme_id) - if idx >= 0: - w_xy += HexMath.compute_village_sector_offset(idx, cell.deme_ids.size(), HexMath.HEX_SIZE) - return w_xy +static func get_deme_world_pos(cell: EvoTypes.HexCell, _deme_id: String = "") -> Vector2: + return HexMath.axial_to_world(cell.q, cell.r) static func find_closest_deme_pair(r: Node2D, cell_a: EvoTypes.HexCell, cell_b: EvoTypes.HexCell) -> Dictionary: + if cell_a.deme_ids.is_empty() or cell_b.deme_ids.is_empty(): + return {} var demes: Dictionary = r.world_state.demes - var best_da: EvoTypes.Deme = null - var best_db: EvoTypes.Deme = null - var best_wa := Vector2.ZERO - var best_wb := Vector2.ZERO - var best_dist_sq := INF - var pact_da: EvoTypes.Deme = null - var pact_db: EvoTypes.Deme = null - var pact_wa := Vector2.ZERO - var pact_wb := Vector2.ZERO - var pact_dist_sq := INF - var count_a := cell_a.deme_ids.size() - var count_b := cell_b.deme_ids.size() - var w_ca := HexMath.axial_to_world(cell_a.q, cell_a.r) - var w_cb := HexMath.axial_to_world(cell_b.q, cell_b.r) - var wb_list := PackedVector2Array() - wb_list.resize(count_b) - for j in range(count_b): - wb_list[j] = w_cb + HexMath.compute_village_sector_offset(j, count_b, HexMath.HEX_SIZE) - var has_pacts: bool = not (r.world_state as EvoTypes.WorldState).active_pacts.is_empty() - for i in range(count_a): - var id_a: String = cell_a.deme_ids[i] - if not demes.has(id_a): - continue - var da: EvoTypes.Deme = demes[id_a] - var wa := w_ca + HexMath.compute_village_sector_offset(i, count_a, HexMath.HEX_SIZE) - for j in range(count_b): - var id_b: String = cell_b.deme_ids[j] - if not demes.has(id_b): - continue - var db: EvoTypes.Deme = demes[id_b] - var wb := wb_list[j] - var d2 := wa.distance_squared_to(wb) - if has_pacts and EvoTypes.is_pact_active(r.world_state, id_a, id_b) and d2 < pact_dist_sq: - pact_dist_sq = d2 - pact_da = da - pact_db = db - pact_wa = wa - pact_wb = wb - if d2 < best_dist_sq: - best_dist_sq = d2 - best_da = da - best_db = db - best_wa = wa - best_wb = wb - if pact_da != null and pact_db != null: - return {"da": pact_da, "db": pact_db, "wa": pact_wa, "wb": pact_wb} - if best_da == null or best_db == null: + var id_a: String = cell_a.deme_ids[0] + var id_b: String = cell_b.deme_ids[0] + if not demes.has(id_a) or not demes.has(id_b): return {} - return {"da": best_da, "db": best_db, "wa": best_wa, "wb": best_wb} + return { + "da": demes[id_a], + "db": demes[id_b], + "wa": HexMath.axial_to_world(cell_a.q, cell_a.r), + "wb": HexMath.axial_to_world(cell_b.q, cell_b.r) + } static func resolve_road_endpoints( r: Node2D, cell_a: EvoTypes.HexCell, cell_b: EvoTypes.HexCell, - from_deme_id: String, - to_deme_id: String + _from_deme_id: String = "", + _to_deme_id: String = "" ) -> Dictionary: - if cell_a.id == cell_b.id and r.has_meta("_deme_v_cache"): - var dv: Dictionary = r.get_meta("_deme_v_cache") - if dv.has(from_deme_id) and dv.has(to_deme_id): - var va: Dictionary = dv[from_deme_id] - var vb: Dictionary = dv[to_deme_id] - return { - "world_a": va["world_xy"], - "z_a": va["top_z"], - "world_b": vb["world_xy"], - "z_b": vb["top_z"], - "p_a": va["pos"], - "p_b": vb["pos"] - } - var world_a: Vector2 - var world_b: Vector2 - if cell_a.id != cell_b.id and not cell_a.deme_ids.is_empty() and not cell_b.deme_ids.is_empty(): - var closest := find_closest_deme_pair(r, cell_a, cell_b) - if not closest.is_empty(): - world_a = closest["wa"] - world_b = closest["wb"] - if r.has_meta("_deme_v_cache"): - var dv: Dictionary = r.get_meta("_deme_v_cache") - var id_a := (closest["da"] as EvoTypes.Deme).id - var id_b := (closest["db"] as EvoTypes.Deme).id - if dv.has(id_a) and dv.has(id_b): - var va: Dictionary = dv[id_a] - var vb: Dictionary = dv[id_b] - return { - "world_a": world_a, - "z_a": va["top_z"], - "world_b": world_b, - "z_b": vb["top_z"], - "p_a": va["pos"], - "p_b": vb["pos"] - } - else: - world_a = get_flow_endpoint_world(cell_a, from_deme_id, 0) - world_b = get_flow_endpoint_world(cell_b, to_deme_id, 1) - else: - world_a = get_flow_endpoint_world(cell_a, from_deme_id, 0) - world_b = get_flow_endpoint_world(cell_b, to_deme_id, 1) + var world_a := HexMath.axial_to_world(cell_a.q, cell_a.r) + var world_b := HexMath.axial_to_world(cell_b.q, cell_b.r) var z_a: float = r._get_cell_top_z(cell_a) var z_b: float = r._get_cell_top_z(cell_b) return { @@ -1159,457 +743,5 @@ static func resolve_road_endpoints( } -static func get_flow_endpoint_world(cell: EvoTypes.HexCell, deme_id: String, fallback_idx: int) -> Vector2: - var w_xy := HexMath.axial_to_world(cell.q, cell.r) - var idx := cell.deme_ids.find(deme_id) - if idx == -1 and cell.deme_ids.size() > 0: - idx = fallback_idx % cell.deme_ids.size() - if idx >= 0: - w_xy += HexMath.compute_village_sector_offset(idx, cell.deme_ids.size(), HexMath.HEX_SIZE) - return w_xy - - -static func sample_road_curve( - p_a: Vector2, - p_b: Vector2, - t: float, - curve_sign: float, - curve_factor: float = 0.045 -) -> Vector2: - var diff := p_b - p_a - var dist := diff.length() - var perp := Vector2(-diff.y, diff.x).normalized() if dist > 0.01 else Vector2.UP - var ctrl := (p_a + p_b) * 0.5 + perp * (minf(dist * curve_factor, 12.0) * curve_sign) - var u := 1.0 - t - return (u * u) * p_a + (2.0 * u * t) * ctrl + (t * t) * p_b - - -static func bridge_arch_point(p_a: Vector2, p_b: Vector2, t: float) -> Vector2: - return p_a.lerp(p_b, t) + Vector2(0, sin(t * PI) * -10.0) - - -static func draw_collaboration_route(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var curve_f: float = float(road.get("curve_factor", 0.14)) - var show_badge: bool = bool(road.get("show_badge", true)) - - var segments := 26 - var pts := PackedVector2Array() - for i in range(segments + 1): - pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) - - ci.draw_polyline(pts, Color("#4a3219"), 16.0) - ci.draw_polyline(pts, Color("#8c6d46"), 12.8) - ci.draw_polyline(pts, Color("#d6b88a"), 9.0) - - for i in range(1, segments): - var pt := pts[i] - var dir := (pts[i + 1] - pts[i - 1]).normalized() - var perp := Vector2(-dir.y, dir.x) - ci.draw_circle(pt + perp * 2.8, 3.2, Color("#e6ccb2") if i % 2 == 0 else Color("#c4a474")) - ci.draw_circle(pt - perp * 2.8, 2.9, Color("#b89566")) - - var road_len := p_a.distance_to(p_b) - if road_len > 150.0: - var road_dir := (p_b - p_a) / road_len - var road_perp := Vector2(-road_dir.y, road_dir.x) - var lantern_pt := sample_road_curve(p_a, p_b, 0.30, 1.0, curve_f) + road_perp * 15.0 + Vector2(0, -5.0) - ci.draw_circle(lantern_pt + Vector2(2.0, -7.0), 13.0, Color(1.0, 0.88, 0.38, 0.25)) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "road_lantern", lantern_pt, 1.35) - - if show_badge: - var mid := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) + Vector2(0, -14.0) - ci.draw_circle(mid, 11.5, Color("#064e3b")) - ci.draw_circle(mid, 9.5, Color("#10b981")) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_coop_link", mid + Vector2(0, 6.8), 1.05) - - -static func draw_wooden_bridge(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var show_badge: bool = bool(road.get("show_badge", true)) - var diff := p_b - p_a - var dir := diff.normalized() if diff.length() > 0.01 else Vector2.RIGHT - var perp := Vector2(-dir.y, dir.x) - - for t_pile in [0.22, 0.50, 0.78]: - var base_pt := p_a.lerp(p_b, t_pile) - var pile_top := base_pt + Vector2(0, -4.0) - var pile_bot := base_pt + Vector2(0, 18.0) - ci.draw_line(pile_top - perp * 6.0, pile_bot - perp * 6.0, Color("#3d2314"), 4.2) - ci.draw_line(pile_top + perp * 6.0, pile_bot + perp * 6.0, Color("#4a2c18"), 4.2) - ci.draw_line(pile_bot - Vector2(9.0, 0), pile_bot + Vector2(9.0, 0), Color(0.88, 0.97, 1.0, 0.65), 2.2) - - var steps := 20 - var curve_pts := PackedVector2Array() - var rail_left := PackedVector2Array() - var rail_right := PackedVector2Array() - for i in range(steps + 1): - var pt := bridge_arch_point(p_a, p_b, float(i) / float(steps)) - curve_pts.append(pt) - rail_left.append(pt - perp * 7.6 + Vector2(0, -5.2)) - rail_right.append(pt + perp * 7.6 + Vector2(0, -5.2)) - - ci.draw_polyline(curve_pts, Color("#3d2314"), 15.8) - ci.draw_polyline(curve_pts, Color("#b07d4f"), 11.6) - - for i in range(1, steps): - var c := curve_pts[i] - ci.draw_line(c - perp * 7.0, c + perp * 7.0, Color("#d4a373") if i % 2 == 0 else Color("#8c5830"), 3.4) - - ci.draw_polyline(rail_left, Color("#6b3e26"), 2.8) - ci.draw_polyline(rail_right, Color("#6b3e26"), 2.8) - - if show_badge: - var mid := curve_pts[steps / 2] + Vector2(0, -15.0) - ci.draw_circle(mid, 11.5, Color("#064e3b")) - ci.draw_circle(mid, 9.5, Color("#10b981")) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_coop_link", mid + Vector2(0, 6.8), 1.05) - - -static func draw_dispute_barricade_front(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var diff := p_b - p_a - var dist := diff.length() - var dir := diff / dist if dist > 0.01 else Vector2.RIGHT - var perp := Vector2(-dir.y, dir.x) - var mid := (p_a + p_b) * 0.5 - var is_intra_hex := bool(road.get("is_same_hex", dist < 210.0)) - - var stop_a := p_a.lerp(p_b, 0.34 if is_intra_hex else 0.38) - var stop_b := p_b.lerp(p_a, 0.34 if is_intra_hex else 0.38) - ci.draw_line(p_a, stop_a, Color("#3b2313"), 9.2 if is_intra_hex else 10.8) - ci.draw_line(p_a, stop_a, Color("#785233"), 6.0 if is_intra_hex else 7.2) - ci.draw_line(p_b, stop_b, Color("#3b2313"), 9.2 if is_intra_hex else 10.8) - ci.draw_line(p_b, stop_b, Color("#785233"), 6.0 if is_intra_hex else 7.2) - - var scorch_rx := 15.0 if is_intra_hex else 22.0 - EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, scorch_rx, scorch_rx * 0.58, Color(0.16, 0.08, 0.05, 0.72)) - ci.draw_line(mid - perp * scorch_rx, mid + perp * scorch_rx, Color("#dc2626"), 2.8) - - if is_intra_hex: - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "barricade_spikes", mid + Vector2(0, 3.0), 1.35) - else: - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "barricade_spikes", p_a.lerp(p_b, 0.40) + Vector2(0, 3.0), 1.50) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "barricade_spikes", p_a.lerp(p_b, 0.60) + Vector2(0, 3.0), 1.50) - - -static func draw_broken_cut_road(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var alpha: float = float(road.get("alpha", 0.90)) - var show_badge: bool = bool(road.get("show_badge", true)) - var mod_col := Color(1.0, 1.0, 1.0, alpha) - var mid := (p_a + p_b) * 0.5 - - for rng in [Vector2(0.08, 0.36), Vector2(0.64, 0.92)]: - var steps := 7 - for i in range(steps + 1): - var pt := p_a.lerp(p_b, lerpf(rng.x, rng.y, float(i) / float(steps))) - ci.draw_circle(pt, 5.2, Color("#47423c") * mod_col) - ci.draw_circle(pt, 3.6, (Color("#8c8479") if i % 2 == 0 else Color("#6e675f")) * mod_col) - - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "soil_crack", mid + Vector2(0, 5.0), 1.30, mod_col) - ci.draw_circle(mid + Vector2(-8.0, 3.0), 4.6, Color("#4d7c0f") * mod_col) - ci.draw_circle(mid + Vector2(8.0, 1.5), 4.0, Color("#65a30d") * mod_col) - - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "road_block_sign", mid + Vector2(0, 4.0), 1.30, mod_col) - if show_badge: - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_broken_link", mid + Vector2(0, -11.0), 1.05, mod_col) - - -static func draw_migration_expedition(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var curve_f: float = float(road.get("curve_factor", 0.14)) - var steps := 18 - for i in range(1, steps): - var pt := sample_road_curve(p_a, p_b, float(i) / float(steps), -1.0, curve_f) - ci.draw_circle(pt, 4.8, Color("#0369a1")) - ci.draw_circle(pt, 3.2, Color("#bae6fd") if i % 2 == 0 else Color("#e0f2fe")) - - -static func draw_pact_highway(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var curve_f: float = float(road.get("curve_factor", 0.045)) - var show_badge: bool = bool(road.get("show_badge", true)) - - var segments := 28 - var pts := PackedVector2Array() - for i in range(segments + 1): - pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) - - # Subtle golden-emerald border glow & reinforced stone highway - ci.draw_polyline(pts, Color(0.06, 0.73, 0.51, 0.36), 22.0) - ci.draw_polyline(pts, Color("#f59e0b"), 17.2) - ci.draw_polyline(pts, Color("#475569"), 14.0) - ci.draw_polyline(pts, Color("#cbd5e1"), 10.2) - - for i in range(1, segments): - var pt := pts[i] - var dir := (pts[i + 1] - pts[i - 1]).normalized() - var perp := Vector2(-dir.y, dir.x) - ci.draw_circle(pt + perp * 3.0, 3.2, Color("#f8fafc") if i % 2 == 0 else Color("#94a3b8")) - ci.draw_circle(pt - perp * 3.0, 3.0, Color("#e2e8f0") if i % 2 == 1 else Color("#94a3b8")) - if i % 3 == 0: - ci.draw_circle(pt, 2.2, Color("#10b981") if i % 6 == 0 else Color("#fbbf24")) - - var mid_curve := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) - var road_len := p_a.distance_to(p_b) - if road_len > 90.0: - var road_dir := (p_b - p_a) / road_len - var road_perp := Vector2(-road_dir.y, road_dir.x) - var totem_pt := mid_curve + road_perp * 16.0 + Vector2(0, 2.0) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_pact_totem", totem_pt, 1.45) - - if show_badge: - var mid := mid_curve + Vector2(0, -15.0) - ci.draw_circle(mid, 12.5, Color("#f59e0b")) - ci.draw_circle(mid, 10.5, Color("#064e3b")) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_pact", mid + Vector2(0, 6.5), 1.35) - - -static func draw_merge_avenue(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var curve_f: float = float(road.get("curve_factor", 0.045)) - var show_badge: bool = bool(road.get("show_badge", true)) - - var segments := 26 - var pts := PackedVector2Array() - for i in range(segments + 1): - pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) - - ci.draw_polyline(pts, Color(0.98, 0.75, 0.14, 0.38), 22.0) - ci.draw_polyline(pts, Color("#10b981"), 16.5) - ci.draw_polyline(pts, Color("#fbbf24"), 12.5) - ci.draw_polyline(pts, Color("#fef3c7"), 8.5) - - var mid_curve := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) - if show_badge: - var mid := mid_curve + Vector2(0, -15.0) - ci.draw_circle(mid, 12.5, Color("#fbbf24")) - ci.draw_circle(mid, 10.5, Color("#064e3b")) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_merge", mid + Vector2(0, 6.5), 1.35) - - -static func draw_tribute_route(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var curve_f: float = float(road.get("curve_factor", 0.045)) - var show_badge: bool = bool(road.get("show_badge", true)) - - var segments := 24 - var pts := PackedVector2Array() - for i in range(segments + 1): - pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) - - ci.draw_polyline(pts, Color("#78350f"), 15.0) - ci.draw_polyline(pts, Color("#d97706"), 11.2) - ci.draw_polyline(pts, Color("#fbbf24"), 7.5) - - var mid_curve := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) - if show_badge: - var mid := mid_curve + Vector2(0, -15.0) - ci.draw_circle(mid, 12.0, Color("#78350f")) - ci.draw_circle(mid, 10.0, Color("#f59e0b")) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_tribute", mid + Vector2(0, 6.5), 1.35) - - -static func draw_war_corridor(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var diff := p_b - p_a - var dist := diff.length() - var dir := diff / dist if dist > 0.01 else Vector2.RIGHT - var perp := Vector2(-dir.y, dir.x) - var mid := (p_a + p_b) * 0.5 - - # Scorched dark-crimson dirt track - ci.draw_line(p_a, p_b, Color(0.50, 0.07, 0.07, 0.42), 20.0) - ci.draw_line(p_a, p_b, Color("#2a0a0a"), 14.8) - ci.draw_line(p_a, p_b, Color("#5c1919"), 10.4) - ci.draw_line(p_a, p_b, Color("#991b1b"), 5.6) - - var scorch_rx := 22.0 - EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, scorch_rx, scorch_rx * 0.58, Color(0.18, 0.04, 0.04, 0.78)) - ci.draw_line(mid - perp * scorch_rx, mid + perp * scorch_rx, Color("#ef4444"), 3.0) - - # War banners planted near the border clash front - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_war_banner", p_a.lerp(p_b, 0.40) + perp * 14.0 + Vector2(0, 3.0), 1.45) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_war_banner", p_a.lerp(p_b, 0.60) - perp * 14.0 + Vector2(0, 3.0), 1.45) - - -static func draw_war_animated_invasion(r: Node2D, road: Dictionary) -> void: - var anim_layer: Node2D = r._anim_layer - var anim_time: float = r._anim_time - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var diff := p_b - p_a - var dist := diff.length() - var dir := diff / dist if dist > 0.01 else Vector2.RIGHT - var perp := Vector2(-dir.y, dir.x) - var mid := road.get("badge_pos", (p_a + p_b) * 0.5) as Vector2 - - # Pulsing red tactical vector - var pulse := 0.55 + 0.45 * sin(anim_time * 7.5) - anim_layer.draw_line(p_a, p_b, Color(0.94, 0.27, 0.27, 0.52 * pulse), 4.2) - for c_i in range(3): - var t_chev := fposmod(anim_time * 0.55 + float(c_i) * 0.33, 1.0) - var c_pos := p_a.lerp(p_b, lerpf(0.15, 0.85, t_chev)) - var tip := c_pos + dir * 6.0 - var wing_l := c_pos - dir * 5.0 + perp * 6.0 - var wing_r := c_pos - dir * 5.0 - perp * 6.0 - anim_layer.draw_line(wing_l, tip, Color("#fca5a5"), 2.4) - anim_layer.draw_line(wing_r, tip, Color("#fca5a5"), 2.4) - - # Column of marching warriors advancing from origin_hex (p_a) toward target_hex (p_b) - var v_textures: Array = road.get("villager_textures", []) - var atk_col: Color = road.get("color", Color("#ef4444")) - for w_i in range(3): - var raw_w := fposmod(anim_time * 0.38 + float(w_i) * 0.28, 1.0) - var w_pos := p_a.lerp(p_b, lerpf(0.14, 0.72, raw_w)) + perp * (float(w_i - 1) * 4.5) - if v_textures.size() >= 3: - var frame := 1 + (int(anim_time * 7.0 + float(w_i)) % 2) - var tex: ImageTexture = v_textures[frame] - var sz := tex.get_size() * 2.45 - anim_layer.draw_texture_rect(tex, Rect2(w_pos - Vector2(sz.x * 0.5, sz.y * 0.85), sz), false) - # Spear & shield accents on each marching warrior - var spear_base := w_pos + Vector2(-4.0, -6.0) - var spear_tip := spear_base + dir * 14.0 + Vector2(0, -10.0) - anim_layer.draw_line(spear_base, spear_tip, Color("#cbd5e1"), 2.0) - anim_layer.draw_circle(spear_tip, 2.2, Color("#fde047")) - var shield_pos := w_pos + Vector2(5.0, -11.0) - anim_layer.draw_circle(shield_pos, 4.5, Color("#cbd5e1")) - anim_layer.draw_circle(shield_pos, 3.2, atk_col) - - # Clash front battle sparks & icon_war - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "effect_battle_sparks", mid + Vector2(0, 4.0), 1.15 + 0.20 * pulse) - for s_i in range(5): - var ang := anim_time * 6.0 + float(s_i) * (TAU * 0.20) - var spark_pos := mid + Vector2(cos(ang) * 13.0, sin(ang) * 7.5 - 6.0) - anim_layer.draw_circle(spark_pos, 3.2, Color("#fde047") if s_i % 2 == 0 else Color("#ef4444")) - - if bool(road.get("show_badge", true)): - var badge_pos := mid + Vector2(0, -18.0) - var is_siege_road := str(road.get("kind", "")) == "siege" - anim_layer.draw_circle(badge_pos, 14.5 * (0.92 + 0.16 * pulse), Color(0.94, 0.20, 0.20, 0.42)) - anim_layer.draw_circle(badge_pos, 10.5, Color("#7f1d1d")) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "icon_siege" if is_siege_road else "icon_war", badge_pos + Vector2(0, 6.5), 1.35) - - -static func draw_raid_trail(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var show_badge: bool = bool(road.get("show_badge", true)) - var diff := p_b - p_a - var dist := diff.length() - var dir := diff / dist if dist > 0.01 else Vector2.RIGHT - var perp := Vector2(-dir.y, dir.x) - - # Fast, jagged amber-scarlet raiding trail with dashed stealth/plunder tracks - var steps := 14 - var prev_pt := p_a - for i in range(1, steps + 1): - var t := float(i) / float(steps) - var jag := 0.0 if i == steps else (5.5 if i % 2 == 1 else -5.5) - var next_pt := p_a.lerp(p_b, t) + perp * jag - if i % 2 == 1: - ci.draw_line(prev_pt, next_pt, Color("#431407"), 8.5) - ci.draw_line(prev_pt, next_pt, Color("#ef4444"), 5.5) - ci.draw_line(prev_pt, next_pt, Color("#f97316"), 3.2) - else: - ci.draw_circle((prev_pt + next_pt) * 0.5, 3.2, Color("#fb923c")) - prev_pt = next_pt - - if show_badge: - var mid := (p_a + p_b) * 0.5 + Vector2(0, -14.0) - ci.draw_circle(mid, 11.5, Color("#7c2d12")) - ci.draw_circle(mid, 9.5, Color("#f97316")) - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_raid", mid + Vector2(0, 6.2), 1.35) - - -static func draw_raid_animated_plunder(r: Node2D, road: Dictionary) -> void: - var anim_layer: Node2D = r._anim_layer - var anim_time: float = r._anim_time - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var v_textures: Array = road.get("villager_textures", []) - - # Fast-moving raider figures carrying prop_raid_loot back from target_hex (p_b) toward origin_hex (p_a) - for r_i in range(2): - var raw_r := fposmod(anim_time * 0.62 + float(r_i) * 0.48, 1.0) - var r_pos := p_b.lerp(p_a, lerpf(0.14, 0.86, raw_r)) - if v_textures.size() >= 3: - var frame := 1 + (int(anim_time * 9.0 + float(r_i)) % 2) - var tex: ImageTexture = v_textures[frame] - var sz := tex.get_size() * 2.45 - anim_layer.draw_texture_rect(tex, Rect2(r_pos - Vector2(sz.x * 0.5, sz.y * 0.85), sz), false) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "prop_raid_loot", r_pos + Vector2(7.0, -5.0), 1.05) - - -static func draw_betrayal_road(r: Node2D, road: Dictionary) -> void: - var ci: Node2D = r._network_layer - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var diff := p_b - p_a - var dist := diff.length() - var dir := diff / dist if dist > 0.01 else Vector2.RIGHT - var perp := Vector2(-dir.y, dir.x) - var mid := (p_a + p_b) * 0.5 - - # Shattered alliance road stubs - var stop_a := p_a.lerp(p_b, 0.36) - var stop_b := p_b.lerp(p_a, 0.36) - ci.draw_line(p_a, stop_a, Color("#334155"), 14.0) - ci.draw_line(p_a, stop_a, Color("#94a3b8"), 9.2) - ci.draw_line(p_b, stop_b, Color("#334155"), 14.0) - ci.draw_line(p_b, stop_b, Color("#94a3b8"), 9.2) - - # Scorched dark violet/crimson crater & jagged fracture lines - EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, 22.0, 13.0, Color(0.18, 0.04, 0.22, 0.78)) - var f1 := mid - perp * 18.0 - dir * 6.0 - var f2 := mid - perp * 5.0 + dir * 7.0 - var f3 := mid + perp * 7.0 - dir * 6.0 - var f4 := mid + perp * 18.0 + dir * 5.0 - var fracture_pts := PackedVector2Array([f1, f2, f3, f4]) - ci.draw_polyline(fracture_pts, Color("#7e22ce"), 5.2) - ci.draw_polyline(fracture_pts, Color("#be123c"), 3.0) - ci.draw_polyline(fracture_pts, Color("#fda4af"), 1.4) - - EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_betrayal_ruin", mid + Vector2(0, 4.0), 1.50) - - -static func draw_betrayal_animated_ruin(r: Node2D, road: Dictionary) -> void: - var anim_layer: Node2D = r._anim_layer - var anim_time: float = r._anim_time - var p_a: Vector2 = road["p_a"] - var p_b: Vector2 = road["p_b"] - var mid := road.get("badge_pos", (p_a + p_b) * 0.5) as Vector2 - var pulse := 1.0 + 0.22 * sin(anim_time * 7.0) - - # Dark purple/charcoal smoke particles rising from shattered pact - for s_i in range(4): - var phase := fposmod(anim_time * 0.45 + float(s_i) * 0.25, 1.0) - var smoke_pos := mid + Vector2(sin(anim_time * 2.5 + float(s_i) * 1.7) * 10.0, -phase * 26.0 - 2.0) - var smoke_col := Color(0.29, 0.08, 0.42, (1.0 - phase) * 0.68) if s_i % 2 == 0 else Color(0.20, 0.20, 0.24, (1.0 - phase) * 0.62) - anim_layer.draw_circle(smoke_pos, lerpf(3.5, 6.5, phase), smoke_col) - - if bool(road.get("show_badge", true)): - var badge_pos := mid + Vector2(0, -19.0) - anim_layer.draw_circle(badge_pos, 14.0 * pulse, Color(0.75, 0.07, 0.24, 0.42)) - anim_layer.draw_circle(badge_pos, 10.0, Color("#4c0519")) - EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "icon_betrayal", badge_pos + Vector2(0, 6.5), 1.35 * (0.95 + 0.08 * sin(anim_time * 7.0))) +static func get_flow_endpoint_world(cell: EvoTypes.HexCell, _deme_id: String = "", _fallback_idx: int = 0) -> Vector2: + return HexMath.axial_to_world(cell.q, cell.r) diff --git a/src/world/render/road_wall_drawer.gd b/src/world/render/road_wall_drawer.gd new file mode 100644 index 0000000..193a16c --- /dev/null +++ b/src/world/render/road_wall_drawer.gd @@ -0,0 +1,668 @@ +class_name EvoRoadWallDrawer +extends RefCounted + + +static func sample_road_curve( + p_a: Vector2, + p_b: Vector2, + t: float, + curve_sign: float, + curve_factor: float = 0.045 +) -> Vector2: + var diff := p_b - p_a + var dist := diff.length() + var perp := Vector2(-diff.y, diff.x).normalized() if dist > 0.01 else Vector2.UP + var ctrl := (p_a + p_b) * 0.5 + perp * (minf(dist * curve_factor, 12.0) * curve_sign) + var u := 1.0 - t + return (u * u) * p_a + (2.0 * u * t) * ctrl + (t * t) * p_b + + +static func bridge_arch_point(p_a: Vector2, p_b: Vector2, t: float) -> Vector2: + return p_a.lerp(p_b, t) + Vector2(0, sin(t * PI) * -10.0) + + +static func draw_network_roads(r: Node2D) -> void: + draw_settlement_perimeter_walls(r) + var has_cull: bool = r._visible_world_rect.size != Vector2.ZERO + var cull_rect: Rect2 = r._visible_world_rect.grow(HexMath.HEX_SIZE * 1.5) if has_cull else Rect2() + for road in r._roads: + if has_cull: + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + if not cull_rect.has_point(p_a) and not cull_rect.has_point(p_b): + continue + match road["kind"]: + "pact": + draw_pact_highway(r, road) + "merge": + draw_merge_avenue(r, road) + "tribute": + draw_tribute_route(r, road) + "war", "siege": + draw_war_corridor(r, road) + "raid": + draw_raid_trail(r, road) + "betrayal": + draw_betrayal_road(r, road) + "collab": + draw_collaboration_route(r, road) + "bridge": + draw_wooden_bridge(r, road) + "dispute": + draw_dispute_barricade_front(r, road) + "broken": + draw_broken_cut_road(r, road) + "expedition": + draw_migration_expedition(r, road) + "rural_farm": + draw_rural_farm_track(r, road) + + +static func draw_animated_roads(r: Node2D) -> void: + var anim_layer: Node2D = r._anim_layer + var vis_rect: Rect2 = r._visible_world_rect + var anim_time: float = r._anim_time + for road in r._roads: + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + if not vis_rect.has_point(p_a) and not vis_rect.has_point(p_b): + continue + var curve_f: float = float(road.get("curve_factor", 0.14)) + match road["kind"]: + "pact": + if bool(road.get("show_traffic", true)): + var diff := p_b - p_a + var perp := Vector2(-diff.y, diff.x).normalized() if diff.length() > 0.01 else Vector2.UP + var raw_t1 := fposmod(anim_time * 0.34 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) + var raw_t2 := fposmod(raw_t1 + 0.5, 1.0) + var pos_1 := sample_road_curve(p_a, p_b, lerpf(0.18, 0.82, raw_t1), 1.0, curve_f) + perp * 3.2 + Vector2(0, -6.0) + var pos_2 := sample_road_curve(p_a, p_b, lerpf(0.82, 0.18, raw_t2), 1.0, curve_f) - perp * 3.2 + Vector2(0, -6.0) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", pos_1, 1.55) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", pos_2, 1.55) + "merge": + if bool(road.get("show_traffic", true)): + var raw_m := fposmod(anim_time * 0.36 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) + var wagon_pos := sample_road_curve(p_a, p_b, lerpf(0.18, 0.82, raw_m), 1.0, curve_f) + Vector2(0, -6.0) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "settler_wagon", wagon_pos, 1.55) + "tribute": + if bool(road.get("show_traffic", true)): + var raw_tr := fposmod(anim_time * 0.36 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) + var cart_pos := sample_road_curve(p_a, p_b, lerpf(0.18, 0.82, raw_tr), 1.0, curve_f) + Vector2(0, -6.0) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", cart_pos, 1.55) + "war", "siege": + draw_war_animated_invasion(r, road) + "raid": + draw_raid_animated_plunder(r, road) + "betrayal": + draw_betrayal_animated_ruin(r, road) + "collab": + if bool(road.get("show_traffic", true)): + var raw_t := fposmod(anim_time * 0.32 + float(int(p_a.x + p_b.y) % 7) * 0.13, 1.0) + var t_cart := lerpf(0.22, 0.78, raw_t) + EvoWorldOverlayRenderer.draw_centered_sprite( + anim_layer, + "caravan_cart", + sample_road_curve(p_a, p_b, t_cart, 1.0, curve_f) + Vector2(0, -6.0), + 1.55 + ) + "bridge": + if bool(road.get("show_traffic", true)): + var raw_b := fposmod(anim_time * 0.32, 1.0) + var cart_pos := bridge_arch_point(p_a, p_b, lerpf(0.20, 0.80, raw_b)) + Vector2(0, -6.0) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "caravan_cart", cart_pos, 1.55) + "dispute": + if bool(road.get("show_badge", true)): + draw_battle_sparks(r, road.get("badge_pos", (p_a + p_b) * 0.5)) + "expedition": + var raw_w := fposmod(anim_time * 0.38, 1.0) + var wagon_pos := sample_road_curve(p_a, p_b, lerpf(0.20, 0.82, raw_w), -1.0, curve_f) + Vector2(0, -6.0) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "settler_wagon", wagon_pos, 1.55) + + +static func draw_battle_sparks(r: Node2D, mid: Vector2) -> void: + var anim_layer: Node2D = r._anim_layer + var anim_time: float = r._anim_time + var pulse := 1.0 + 0.22 * sin(anim_time * 8.5) + for s_i in range(4): + var ang := anim_time * 5.0 + float(s_i) * (TAU * 0.25) + var spark_pos := mid + Vector2(cos(ang) * 11.0, sin(ang) * 6.5 - 6.0) + anim_layer.draw_circle(spark_pos, 3.0, Color("#fde047") if s_i % 2 == 0 else Color("#f97316")) + + var badge_pos := mid + Vector2(0, -18.0) + anim_layer.draw_circle(badge_pos, 14.0 * pulse, Color(0.94, 0.20, 0.20, 0.38)) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "icon_conflict_swords", badge_pos + Vector2(0, 8.0), 1.20) + + +static func draw_collaboration_route(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var curve_f: float = float(road.get("curve_factor", 0.14)) + var show_badge: bool = bool(road.get("show_badge", true)) + + var segments := 26 + var pts := PackedVector2Array() + for i in range(segments + 1): + pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) + + ci.draw_polyline(pts, Color("#4a3219"), 16.0) + ci.draw_polyline(pts, Color("#8c6d46"), 12.8) + ci.draw_polyline(pts, Color("#d6b88a"), 9.0) + + for i in range(1, segments): + var pt := pts[i] + var dir := (pts[i + 1] - pts[i - 1]).normalized() + var perp := Vector2(-dir.y, dir.x) + ci.draw_circle(pt + perp * 2.8, 3.2, Color("#e6ccb2") if i % 2 == 0 else Color("#c4a474")) + ci.draw_circle(pt - perp * 2.8, 2.9, Color("#b89566")) + + var road_len := p_a.distance_to(p_b) + if road_len > 150.0: + var road_dir := (p_b - p_a) / road_len + var road_perp := Vector2(-road_dir.y, road_dir.x) + var lantern_pt := sample_road_curve(p_a, p_b, 0.30, 1.0, curve_f) + road_perp * 15.0 + Vector2(0, -5.0) + ci.draw_circle(lantern_pt + Vector2(2.0, -7.0), 13.0, Color(1.0, 0.88, 0.38, 0.25)) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "road_lantern", lantern_pt, 1.35) + + if show_badge: + var mid := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) + Vector2(0, -14.0) + ci.draw_circle(mid, 11.5, Color("#064e3b")) + ci.draw_circle(mid, 9.5, Color("#10b981")) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_coop_link", mid + Vector2(0, 6.8), 1.05) + + +static func draw_wooden_bridge(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var show_badge: bool = bool(road.get("show_badge", true)) + var diff := p_b - p_a + var dir := diff.normalized() if diff.length() > 0.01 else Vector2.RIGHT + var perp := Vector2(-dir.y, dir.x) + + for t_pile in [0.22, 0.50, 0.78]: + var base_pt := p_a.lerp(p_b, t_pile) + var pile_top := base_pt + Vector2(0, -4.0) + var pile_bot := base_pt + Vector2(0, 18.0) + ci.draw_line(pile_top - perp * 6.0, pile_bot - perp * 6.0, Color("#3d2314"), 4.2) + ci.draw_line(pile_top + perp * 6.0, pile_bot + perp * 6.0, Color("#4a2c18"), 4.2) + ci.draw_line(pile_bot - Vector2(9.0, 0), pile_bot + Vector2(9.0, 0), Color(0.88, 0.97, 1.0, 0.65), 2.2) + + var steps := 20 + var curve_pts := PackedVector2Array() + var rail_left := PackedVector2Array() + var rail_right := PackedVector2Array() + for i in range(steps + 1): + var pt := bridge_arch_point(p_a, p_b, float(i) / float(steps)) + curve_pts.append(pt) + rail_left.append(pt - perp * 7.6 + Vector2(0, -5.2)) + rail_right.append(pt + perp * 7.6 + Vector2(0, -5.2)) + + ci.draw_polyline(curve_pts, Color("#3d2314"), 15.8) + ci.draw_polyline(curve_pts, Color("#b07d4f"), 11.6) + + for i in range(1, steps): + var c := curve_pts[i] + ci.draw_line(c - perp * 7.0, c + perp * 7.0, Color("#d4a373") if i % 2 == 0 else Color("#8c5830"), 3.4) + + ci.draw_polyline(rail_left, Color("#6b3e26"), 2.8) + ci.draw_polyline(rail_right, Color("#6b3e26"), 2.8) + + if show_badge: + var mid := curve_pts[steps / 2] + Vector2(0, -15.0) + ci.draw_circle(mid, 11.5, Color("#064e3b")) + ci.draw_circle(mid, 9.5, Color("#10b981")) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_coop_link", mid + Vector2(0, 6.8), 1.05) + + +static func draw_dispute_barricade_front(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var diff := p_b - p_a + var dist := diff.length() + var dir := diff / dist if dist > 0.01 else Vector2.RIGHT + var perp := Vector2(-dir.y, dir.x) + var mid := (p_a + p_b) * 0.5 + + var stop_a := p_a.lerp(p_b, 0.38) + var stop_b := p_b.lerp(p_a, 0.38) + ci.draw_line(p_a, stop_a, Color("#3b2313"), 10.8) + ci.draw_line(p_a, stop_a, Color("#785233"), 7.2) + ci.draw_line(p_b, stop_b, Color("#3b2313"), 10.8) + ci.draw_line(p_b, stop_b, Color("#785233"), 7.2) + + var scorch_rx := 22.0 + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, scorch_rx, scorch_rx * 0.58, Color(0.16, 0.08, 0.05, 0.72)) + ci.draw_line(mid - perp * scorch_rx, mid + perp * scorch_rx, Color("#dc2626"), 2.8) + + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "barricade_spikes", p_a.lerp(p_b, 0.40) + Vector2(0, 3.0), 1.50) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "barricade_spikes", p_a.lerp(p_b, 0.60) + Vector2(0, 3.0), 1.50) + + +static func draw_broken_cut_road(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var alpha: float = float(road.get("alpha", 0.90)) + var show_badge: bool = bool(road.get("show_badge", true)) + var mod_col := Color(1.0, 1.0, 1.0, alpha) + var mid := (p_a + p_b) * 0.5 + + for rng in [Vector2(0.08, 0.36), Vector2(0.64, 0.92)]: + var steps := 7 + for i in range(steps + 1): + var pt := p_a.lerp(p_b, lerpf(rng.x, rng.y, float(i) / float(steps))) + ci.draw_circle(pt, 5.2, Color("#47423c") * mod_col) + ci.draw_circle(pt, 3.6, (Color("#8c8479") if i % 2 == 0 else Color("#6e675f")) * mod_col) + + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "soil_crack", mid + Vector2(0, 5.0), 1.30, mod_col) + ci.draw_circle(mid + Vector2(-8.0, 3.0), 4.6, Color("#4d7c0f") * mod_col) + ci.draw_circle(mid + Vector2(8.0, 1.5), 4.0, Color("#65a30d") * mod_col) + + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "road_block_sign", mid + Vector2(0, 4.0), 1.30, mod_col) + if show_badge: + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_broken_link", mid + Vector2(0, -11.0), 1.05, mod_col) + + +static func draw_migration_expedition(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var curve_f: float = float(road.get("curve_factor", 0.14)) + var steps := 18 + for i in range(1, steps): + var pt := sample_road_curve(p_a, p_b, float(i) / float(steps), -1.0, curve_f) + ci.draw_circle(pt, 4.8, Color("#0369a1")) + ci.draw_circle(pt, 3.2, Color("#bae6fd") if i % 2 == 0 else Color("#e0f2fe")) + + +static func draw_pact_highway(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var curve_f: float = float(road.get("curve_factor", 0.045)) + var show_badge: bool = bool(road.get("show_badge", true)) + + var segments := 28 + var pts := PackedVector2Array() + for i in range(segments + 1): + pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) + + ci.draw_polyline(pts, Color(0.06, 0.73, 0.51, 0.36), 22.0) + ci.draw_polyline(pts, Color("#f59e0b"), 17.2) + ci.draw_polyline(pts, Color("#475569"), 14.0) + ci.draw_polyline(pts, Color("#cbd5e1"), 10.2) + + for i in range(1, segments): + var pt := pts[i] + var dir := (pts[i + 1] - pts[i - 1]).normalized() + var perp := Vector2(-dir.y, dir.x) + ci.draw_circle(pt + perp * 3.0, 3.2, Color("#f8fafc") if i % 2 == 0 else Color("#94a3b8")) + ci.draw_circle(pt - perp * 3.0, 3.0, Color("#e2e8f0") if i % 2 == 1 else Color("#94a3b8")) + if i % 3 == 0: + ci.draw_circle(pt, 2.2, Color("#10b981") if i % 6 == 0 else Color("#fbbf24")) + + var mid_curve := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) + var road_len := p_a.distance_to(p_b) + if road_len > 90.0: + var road_dir := (p_b - p_a) / road_len + var road_perp := Vector2(-road_dir.y, road_dir.x) + var totem_pt := mid_curve + road_perp * 16.0 + Vector2(0, 2.0) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_pact_totem", totem_pt, 1.45) + + if show_badge: + var mid := mid_curve + Vector2(0, -15.0) + ci.draw_circle(mid, 12.5, Color("#f59e0b")) + ci.draw_circle(mid, 10.5, Color("#064e3b")) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_pact", mid + Vector2(0, 6.5), 1.35) + + +static func draw_merge_avenue(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var curve_f: float = float(road.get("curve_factor", 0.045)) + var show_badge: bool = bool(road.get("show_badge", true)) + + var segments := 26 + var pts := PackedVector2Array() + for i in range(segments + 1): + pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) + + ci.draw_polyline(pts, Color(0.98, 0.75, 0.14, 0.38), 22.0) + ci.draw_polyline(pts, Color("#10b981"), 16.5) + ci.draw_polyline(pts, Color("#fbbf24"), 12.5) + ci.draw_polyline(pts, Color("#fef3c7"), 8.5) + + var mid_curve := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) + if show_badge: + var mid := mid_curve + Vector2(0, -15.0) + ci.draw_circle(mid, 12.5, Color("#fbbf24")) + ci.draw_circle(mid, 10.5, Color("#064e3b")) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_merge", mid + Vector2(0, 6.5), 1.35) + + +static func draw_road_direction_arrow( + ci: CanvasItem, + from_pt: Vector2, + to_pt: Vector2, + fill_col: Color, + outline_col: Color = Color("#23150c") +) -> void: + var diff := to_pt - from_pt + var dist := diff.length() + if dist < 12.0: + return + var dir := diff / dist + var perp := Vector2(-dir.y, dir.x) + var tip := from_pt.lerp(to_pt, 0.78) + var tri := PackedVector2Array([ + tip + dir * 7.5, + tip - dir * 8.0 + perp * 7.0, + tip - dir * 8.0 - perp * 7.0 + ]) + ci.draw_colored_polygon(tri, fill_col) + ci.draw_polyline(PackedVector2Array([tri[0], tri[1], tri[2], tri[0]]), outline_col, 2.0) + + +static func draw_tribute_route(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var curve_f: float = float(road.get("curve_factor", 0.045)) + var show_badge: bool = bool(road.get("show_badge", true)) + + var segments := 24 + var pts := PackedVector2Array() + for i in range(segments + 1): + pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) + + ci.draw_polyline(pts, Color("#78350f"), 15.0) + ci.draw_polyline(pts, Color("#d97706"), 11.2) + ci.draw_polyline(pts, Color("#fbbf24"), 7.5) + draw_road_direction_arrow(ci, p_a, p_b, Color("#fde047"), Color("#78350f")) + + var mid_curve := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) + if show_badge: + var mid := mid_curve + Vector2(0, -15.0) + ci.draw_circle(mid, 12.0, Color("#78350f")) + ci.draw_circle(mid, 10.0, Color("#f59e0b")) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_tribute", mid + Vector2(0, 6.5), 1.35) + + +static func draw_war_corridor(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var diff := p_b - p_a + var dist := diff.length() + var dir := diff / dist if dist > 0.01 else Vector2.RIGHT + var perp := Vector2(-dir.y, dir.x) + var mid := (p_a + p_b) * 0.5 + + ci.draw_line(p_a, p_b, Color(0.50, 0.07, 0.07, 0.42), 20.0) + ci.draw_line(p_a, p_b, Color("#2a0a0a"), 14.8) + ci.draw_line(p_a, p_b, Color("#5c1919"), 10.4) + ci.draw_line(p_a, p_b, Color("#991b1b"), 5.6) + draw_road_direction_arrow(ci, p_a, p_b, Color("#ef4444"), Color("#2a0a0a")) + + var scorch_rx := 22.0 + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, scorch_rx, scorch_rx * 0.58, Color(0.18, 0.04, 0.04, 0.78)) + ci.draw_line(mid - perp * scorch_rx, mid + perp * scorch_rx, Color("#ef4444"), 3.0) + + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_war_banner", p_a.lerp(p_b, 0.40) + perp * 14.0 + Vector2(0, 3.0), 1.45) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_war_banner", p_a.lerp(p_b, 0.60) - perp * 14.0 + Vector2(0, 3.0), 1.45) + + +static func draw_war_animated_invasion(r: Node2D, road: Dictionary) -> void: + var anim_layer: Node2D = r._anim_layer + var anim_time: float = r._anim_time + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var diff := p_b - p_a + var dist := diff.length() + var dir := diff / dist if dist > 0.01 else Vector2.RIGHT + var perp := Vector2(-dir.y, dir.x) + var mid := road.get("badge_pos", (p_a + p_b) * 0.5) as Vector2 + + var pulse := 0.55 + 0.45 * sin(anim_time * 7.5) + anim_layer.draw_line(p_a, p_b, Color(0.94, 0.27, 0.27, 0.52 * pulse), 4.2) + for c_i in range(3): + var t_chev := fposmod(anim_time * 0.55 + float(c_i) * 0.33, 1.0) + var c_pos := p_a.lerp(p_b, lerpf(0.15, 0.85, t_chev)) + var tip := c_pos + dir * 6.0 + var wing_l := c_pos - dir * 5.0 + perp * 6.0 + var wing_r := c_pos - dir * 5.0 - perp * 6.0 + anim_layer.draw_line(wing_l, tip, Color("#fca5a5"), 2.4) + anim_layer.draw_line(wing_r, tip, Color("#fca5a5"), 2.4) + + var v_textures: Array = road.get("villager_textures", []) + var atk_col: Color = road.get("color", Color("#ef4444")) + for w_i in range(3): + var raw_w := fposmod(anim_time * 0.38 + float(w_i) * 0.28, 1.0) + var w_pos := p_a.lerp(p_b, lerpf(0.14, 0.72, raw_w)) + perp * (float(w_i - 1) * 4.5) + if v_textures.size() >= 3: + var frame := 1 + (int(anim_time * 7.0 + float(w_i)) % 2) + var tex: ImageTexture = v_textures[frame] + var sz := tex.get_size() * (2.45 * 0.32) + anim_layer.draw_texture_rect(tex, Rect2(w_pos - Vector2(sz.x * 0.5, sz.y * 0.85), sz), false) + var spear_base := w_pos + Vector2(-4.0, -6.0) + var spear_tip := spear_base + dir * 14.0 + Vector2(0, -10.0) + anim_layer.draw_line(spear_base, spear_tip, Color("#cbd5e1"), 2.0) + anim_layer.draw_circle(spear_tip, 2.2, Color("#fde047")) + var shield_pos := w_pos + Vector2(5.0, -11.0) + anim_layer.draw_circle(shield_pos, 4.5, Color("#cbd5e1")) + anim_layer.draw_circle(shield_pos, 3.2, atk_col) + + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "effect_battle_sparks", mid + Vector2(0, 4.0), 1.15 + 0.20 * pulse) + for s_i in range(5): + var ang := anim_time * 6.0 + float(s_i) * (TAU * 0.20) + var spark_pos := mid + Vector2(cos(ang) * 13.0, sin(ang) * 7.5 - 6.0) + anim_layer.draw_circle(spark_pos, 3.2, Color("#fde047") if s_i % 2 == 0 else Color("#ef4444")) + + if bool(road.get("show_badge", true)): + var badge_pos := mid + Vector2(0, -18.0) + var is_siege_road := str(road.get("kind", "")) == "siege" + anim_layer.draw_circle(badge_pos, 14.5 * (0.92 + 0.16 * pulse), Color(0.94, 0.20, 0.20, 0.42)) + anim_layer.draw_circle(badge_pos, 10.5, Color("#7f1d1d")) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "icon_siege" if is_siege_road else "icon_war", badge_pos + Vector2(0, 6.5), 1.35) + + +static func draw_raid_trail(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var show_badge: bool = bool(road.get("show_badge", true)) + var diff := p_b - p_a + var dist := diff.length() + var dir := diff / dist if dist > 0.01 else Vector2.RIGHT + var perp := Vector2(-dir.y, dir.x) + + var steps := 14 + var prev_pt := p_a + for i in range(1, steps + 1): + var t := float(i) / float(steps) + var jag := 0.0 if i == steps else (5.5 if i % 2 == 1 else -5.5) + var next_pt := p_a.lerp(p_b, t) + perp * jag + if i % 2 == 1: + ci.draw_line(prev_pt, next_pt, Color("#431407"), 8.5) + ci.draw_line(prev_pt, next_pt, Color("#ef4444"), 5.5) + ci.draw_line(prev_pt, next_pt, Color("#f97316"), 3.2) + else: + ci.draw_circle((prev_pt + next_pt) * 0.5, 3.2, Color("#fb923c")) + prev_pt = next_pt + draw_road_direction_arrow(ci, p_a, p_b, Color("#f97316"), Color("#431407")) + + if show_badge: + var mid := (p_a + p_b) * 0.5 + Vector2(0, -14.0) + ci.draw_circle(mid, 11.5, Color("#7c2d12")) + ci.draw_circle(mid, 9.5, Color("#f97316")) + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "icon_raid", mid + Vector2(0, 6.2), 1.35) + + +static func draw_raid_animated_plunder(r: Node2D, road: Dictionary) -> void: + var anim_layer: Node2D = r._anim_layer + var anim_time: float = r._anim_time + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var v_textures: Array = road.get("villager_textures", []) + + for r_i in range(2): + var raw_r := fposmod(anim_time * 0.62 + float(r_i) * 0.48, 1.0) + var r_pos := p_b.lerp(p_a, lerpf(0.14, 0.86, raw_r)) + if v_textures.size() >= 3: + var frame := 1 + (int(anim_time * 9.0 + float(r_i)) % 2) + var tex: ImageTexture = v_textures[frame] + var sz := tex.get_size() * (2.45 * 0.32) + anim_layer.draw_texture_rect(tex, Rect2(r_pos - Vector2(sz.x * 0.5, sz.y * 0.85), sz), false) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "prop_raid_loot", r_pos + Vector2(7.0, -5.0), 1.05) + + +static func draw_betrayal_road(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var diff := p_b - p_a + var dist := diff.length() + var dir := diff / dist if dist > 0.01 else Vector2.RIGHT + var perp := Vector2(-dir.y, dir.x) + var mid := (p_a + p_b) * 0.5 + + var stop_a := p_a.lerp(p_b, 0.36) + var stop_b := p_b.lerp(p_a, 0.36) + ci.draw_line(p_a, stop_a, Color("#334155"), 14.0) + ci.draw_line(p_a, stop_a, Color("#94a3b8"), 9.2) + ci.draw_line(p_b, stop_b, Color("#334155"), 14.0) + ci.draw_line(p_b, stop_b, Color("#94a3b8"), 9.2) + + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, 22.0, 13.0, Color(0.18, 0.04, 0.22, 0.78)) + var f1 := mid - perp * 18.0 - dir * 6.0 + var f2 := mid - perp * 5.0 + dir * 7.0 + var f3 := mid + perp * 7.0 - dir * 6.0 + var f4 := mid + perp * 18.0 + dir * 5.0 + var fracture_pts := PackedVector2Array([f1, f2, f3, f4]) + ci.draw_polyline(fracture_pts, Color("#7e22ce"), 5.2) + ci.draw_polyline(fracture_pts, Color("#be123c"), 3.0) + ci.draw_polyline(fracture_pts, Color("#fda4af"), 1.4) + + EvoWorldOverlayRenderer.draw_centered_sprite(ci, "prop_betrayal_ruin", mid + Vector2(0, 4.0), 1.50) + + +static func draw_betrayal_animated_ruin(r: Node2D, road: Dictionary) -> void: + var anim_layer: Node2D = r._anim_layer + var anim_time: float = r._anim_time + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var mid := road.get("badge_pos", (p_a + p_b) * 0.5) as Vector2 + var pulse := 1.0 + 0.22 * sin(anim_time * 7.0) + + for s_i in range(4): + var phase := fposmod(anim_time * 0.45 + float(s_i) * 0.25, 1.0) + var smoke_pos := mid + Vector2(sin(anim_time * 2.5 + float(s_i) * 1.7) * 10.0, -phase * 26.0 - 2.0) + var smoke_col := Color(0.29, 0.08, 0.42, (1.0 - phase) * 0.68) if s_i % 2 == 0 else Color(0.20, 0.20, 0.24, (1.0 - phase) * 0.62) + anim_layer.draw_circle(smoke_pos, lerpf(3.5, 6.5, phase), smoke_col) + + if bool(road.get("show_badge", true)): + var badge_pos := mid + Vector2(0, -19.0) + anim_layer.draw_circle(badge_pos, 14.0 * pulse, Color(0.75, 0.07, 0.24, 0.42)) + anim_layer.draw_circle(badge_pos, 10.0, Color("#4c0519")) + EvoWorldOverlayRenderer.draw_centered_sprite(anim_layer, "icon_betrayal", badge_pos + Vector2(0, 6.5), 1.35 * (0.95 + 0.08 * sin(anim_time * 7.0))) + + +static func draw_rural_farm_track(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var curve_f: float = float(road.get("curve_factor", 0.035)) + var segments := 20 + var pts := PackedVector2Array() + for i in range(segments + 1): + pts.append(sample_road_curve(p_a, p_b, float(i) / float(segments), 1.0, curve_f)) + + ci.draw_polyline(pts, Color("#23150c"), 11.5) + ci.draw_polyline(pts, Color("#6b4423"), 8.5) + ci.draw_polyline(pts, Color("#c29b61"), 5.2) + for i in range(2, segments, 2): + ci.draw_circle(pts[i], 2.0, Color("#8c5e35")) + + +static func draw_settlement_perimeter_walls(r: Node2D) -> void: + var ci: Node2D = r._network_layer + var state: EvoTypes.WorldState = r.world_state + const EDGE_DIR_MAP: Array[int] = [0, 5, 4, 3, 2, 1] + const INK := Color("#23150c") + var has_cull: bool = r._visible_world_rect.size != Vector2.ZERO + var cull_rect: Rect2 = r._visible_world_rect.grow(HexMath.HEX_SIZE * 1.5) if has_cull else Rect2() + + for village in r._villages: + var center: Vector2 = village["pos"] + if has_cull and not cull_rect.has_point(center): + continue + var deme: EvoTypes.Deme = village["deme"] + var has_union: bool = deme.walled_union_id != "" + if deme.defense_tier < 1 and not has_union: + continue + var hex_id: String = village["hex_id"] + if not state.cells.has(hex_id) or not r._hex_polys.has(hex_id): + continue + var cell: EvoTypes.HexCell = state.cells[hex_id] + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + var mod_col: Color = village["modulate"] + var strat_col: Color = village["strat_color"] + var is_stone: bool = deme.defense_tier >= 2 or has_union + var wall_r := 0.78 + + var is_shared_edge: Array[bool] = [false, false, false, false, false, false] + if has_union: + for e in range(6): + var dir_idx: int = EDGE_DIR_MAP[e] + var nb_id: String = r._neighbor_id(cell, dir_idx) + if state.cells.has(nb_id): + var nb_cell: EvoTypes.HexCell = state.cells[nb_id] + if not nb_cell.deme_ids.is_empty() and state.demes.has(nb_cell.deme_ids[0]): + var nb_deme: EvoTypes.Deme = state.demes[nb_cell.deme_ids[0]] + if nb_deme.walled_union_id == deme.walled_union_id: + is_shared_edge[e] = true + + for e in range(6): + if is_shared_edge[e]: + continue + var next_e: int = (e + 1) % 6 + var c0 := center.lerp(top_pts[e], wall_r) + var c1 := center.lerp(top_pts[next_e], wall_r) + + if is_stone: + var wall_h := 11.0 + var t0 := c0 + Vector2(0.0, -wall_h) + var t1 := c1 + Vector2(0.0, -wall_h) + var wall_quad := PackedVector2Array([t0, t1, c1, c0]) + ci.draw_colored_polygon(wall_quad, Color("#64748b") * mod_col) + ci.draw_polyline(PackedVector2Array([t0, t1, c1, c0, t0]), INK * mod_col, 1.8) + ci.draw_line(t0, t1, Color("#cbd5e1") * mod_col, 4.4) + for m_i in range(5): + var mt := (float(m_i) + 0.5) / 5.0 + var m_pt := t0.lerp(t1, mt) + Vector2(0.0, -2.5) + ci.draw_rect(Rect2(m_pt - Vector2(3.2, 2.5), Vector2(6.4, 4.5)), Color("#e2e8f0") * mod_col, true) + ci.draw_rect(Rect2(m_pt - Vector2(3.2, 2.5), Vector2(6.4, 4.5)), INK * mod_col, false, 1.2) + else: + ci.draw_line(c0, c1, INK * mod_col, 6.2) + ci.draw_line(c0, c1, Color("#5c3a21") * mod_col, 4.2) + for s_i in range(9): + var st := float(s_i) / 8.0 + var sb := c0.lerp(c1, st) + var st_top := sb + Vector2(0.0, -8.5) + ci.draw_line(sb, st_top, INK * mod_col, 3.6) + ci.draw_line(sb, st_top, Color("#8c5230") * mod_col, 2.2) + ci.draw_circle(st_top, 1.4, Color("#fde68a") * mod_col) + + if is_stone: + var wall_h := 11.0 + for v in range(6): + var prev_e: int = (v + 5) % 6 + if is_shared_edge[v] and is_shared_edge[prev_e]: + continue + var cv := center.lerp(top_pts[v], wall_r) + ci.draw_circle(cv + Vector2(0.0, -wall_h * 0.5), 7.5, INK * mod_col) + ci.draw_circle(cv + Vector2(0.0, -wall_h * 0.5), 5.8, Color("#94a3b8") * mod_col) + ci.draw_circle(cv + Vector2(0.0, -wall_h - 2.0), 4.8, strat_col * mod_col) diff --git a/src/world/render/road_wall_drawer.gd.uid b/src/world/render/road_wall_drawer.gd.uid new file mode 100644 index 0000000..7b3365b --- /dev/null +++ b/src/world/render/road_wall_drawer.gd.uid @@ -0,0 +1 @@ +uid://cabretuib1o3d diff --git a/src/world/render/world_overlay_renderer.gd b/src/world/render/world_overlay_renderer.gd index 578ec0f..8c0336c 100644 --- a/src/world/render/world_overlay_renderer.gd +++ b/src/world/render/world_overlay_renderer.gd @@ -2,9 +2,23 @@ class_name EvoWorldOverlayRenderer extends RefCounted const C_SELECTION := Color("#fde047") -const BADGE_W := 114.0 -const BADGE_H := 30.0 -const SHIELD_W := 26.0 +const EDGE_DIR_MAP: Array[int] = [0, 5, 4, 3, 2, 1] + +static var _unit_circle_16: PackedVector2Array = PackedVector2Array() +static var _unit_circle_19: PackedVector2Array = PackedVector2Array() + + +static func _ensure_unit_circles() -> void: + if not _unit_circle_16.is_empty(): + return + _unit_circle_16.resize(16) + for i in range(16): + var a := TAU * float(i) / 16.0 + _unit_circle_16[i] = Vector2(cos(a), sin(a)) + _unit_circle_19.resize(19) + for i in range(19): + var a := TAU * float(i) / 18.0 + _unit_circle_19[i] = Vector2(cos(a), sin(a)) static func _resolve_deme_strategy_visuals( @@ -13,7 +27,7 @@ static func _resolve_deme_strategy_visuals( lang: String = "es" ) -> Dictionary: var fallback_color := Color.from_string(deme.color, Color(0.88, 0.48, 0.37)) - var slot_tag := "A" if deme.strategy_id == world_state.strategy_a_id else ("B" if deme.strategy_id == world_state.strategy_b_id else "") + var slot_tag := "B" if EvoTypes._is_side_b_deme(world_state, deme) else "A" if world_state.strategies_catalog.has(deme.strategy_id): var strategy: EvoTypes.StrategyDef = world_state.strategies_catalog[deme.strategy_id] return { @@ -38,7 +52,7 @@ static func draw_centered_sprite( modulate_col: Color = Color.WHITE ) -> void: var tex: ImageTexture = EvoPixelArtGen.get_texture(sprite_name) - var sz: Vector2 = tex.get_size() * scale_val + var sz: Vector2 = tex.get_size() * (scale_val * 0.25) ci.draw_texture_rect( tex, Rect2(Vector2(center_pos.x - sz.x * 0.5, center_pos.y - sz.y * 0.78), sz), @@ -48,10 +62,12 @@ static func draw_centered_sprite( static func draw_ellipse_filled(ci: CanvasItem, center: Vector2, rx: float, ry: float, col: Color) -> void: + _ensure_unit_circles() var pts := PackedVector2Array() + pts.resize(16) for i in range(16): - var a := TAU * float(i) / 16.0 - pts.append(center + Vector2(cos(a) * rx, sin(a) * ry)) + var u := _unit_circle_16[i] + pts[i] = Vector2(center.x + u.x * rx, center.y + u.y * ry) ci.draw_colored_polygon(pts, col) @@ -63,10 +79,12 @@ static func draw_ellipse_outline( col: Color, width: float ) -> void: + _ensure_unit_circles() var pts := PackedVector2Array() + pts.resize(19) for i in range(19): - var a := TAU * float(i) / 18.0 - pts.append(center + Vector2(cos(a) * rx, sin(a) * ry)) + var u := _unit_circle_19[i] + pts[i] = Vector2(center.x + u.x * rx, center.y + u.y * ry) ci.draw_polyline(pts, col, width) @@ -102,21 +120,272 @@ static func update_particles(r: Node2D, delta: float) -> void: static func draw_overlay(r: Node2D) -> void: if r.world_state == null: return + draw_brush_radius_overlay(r) + _draw_active_lens_overlay(r) + EvoEventFxRenderer.draw_event_underlay(r) _draw_deme_billboards(r) + EvoEventFxRenderer.draw_event_floating_texts(r) draw_seasonal_particles(r) -static func _draw_billboards(r: Node2D) -> void: - _draw_deme_billboards(r) +static func draw_brush_radius_overlay(r: Node2D) -> void: + var ci: Node2D = r._overlay_layer + var to_screen: Transform2D = r._world_layer.transform + var zoom_val: float = r.camera.zoom_level if r.camera != null else 1.0 + var inv_zoom: float = clampf(1.0 / maxf(0.05, zoom_val), 0.8, 3.2) + var brush_col := Color("#38bdf8") if r.active_tool == "inspect" else Color("#fb923c") + for hex_id in r._brush_hexes: + if hex_id == r.selected_hex_id or not r._hex_polys.has(hex_id): + continue + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + var pts := PackedVector2Array() + pts.resize(7) + for i in range(7): + pts[i] = to_screen * top_pts[i % 6] + ci.draw_polyline(pts, brush_col, 2.2 * sqrt(inv_zoom)) + + if r.selected_hex_id != "" and r._hex_polys.has(r.selected_hex_id): + var sel_pts: PackedVector2Array = r._hex_polys[r.selected_hex_id] + var pts := PackedVector2Array() + pts.resize(7) + for i in range(7): + pts[i] = to_screen * sel_pts[i % 6] + ci.draw_polyline(pts, C_SELECTION, 3.0 * sqrt(inv_zoom)) + + +static func _draw_dashed_screen_line(ci: CanvasItem, p_a: Vector2, p_b: Vector2, col: Color, width: float) -> void: + var dist := p_a.distance_to(p_b) + var steps := maxi(4, int(dist / 16.0)) + var dashes := PackedVector2Array() + for i in range(steps): + var t0 := float(i) / float(steps) + var t1 := minf(1.0, (float(i) + 0.58) / float(steps)) + dashes.append(p_a.lerp(p_b, t0)) + dashes.append(p_a.lerp(p_b, t1)) + if not dashes.is_empty(): + ci.draw_multiline(dashes, col, width) + + +static func _draw_lens_icon_pill( + ci: CanvasItem, + font: Font, + pos: Vector2, + icon_name: String, + text_str: String, + border_col: Color +) -> void: + var pill_w := 44.0 if text_str != "" else 22.0 + var pill_h := 18.0 + var rect := Rect2(pos.x - pill_w * 0.5, pos.y - pill_h * 0.5, pill_w, pill_h) + ci.draw_rect(rect, Color(0.10, 0.06, 0.03, 0.92), true) + ci.draw_rect(rect, border_col, false, 1.5) + var tex: ImageTexture = EvoPixelArtGen.get_texture(icon_name) + ci.draw_texture_rect( + tex, + Rect2(rect.position + Vector2(3.0, 2.0), Vector2(14.0, 14.0)), + false + ) + if text_str != "": + ci.draw_string( + font, + Vector2(rect.position.x + 19.0, rect.position.y + 13.0), + text_str, + HORIZONTAL_ALIGNMENT_LEFT, + pill_w - 21.0, + 10, + Color("#fef3c7") + ) + + +static func _resolve_cell_owner_deme(state: EvoTypes.WorldState, cell: EvoTypes.HexCell) -> EvoTypes.Deme: + if not cell.deme_ids.is_empty() and state.demes.has(cell.deme_ids[0]): + return state.demes[cell.deme_ids[0]] + if (cell.land_use == "crops" or cell.land_use == "pasture") and cell.owner_deme_id != "" and state.demes.has(cell.owner_deme_id): + return state.demes[cell.owner_deme_id] + return null + + +static func _draw_active_lens_overlay(r: Node2D) -> void: + var lens: String = r.active_lens + if lens == "biome": + return + var ci: Node2D = r._overlay_layer + var to_screen: Transform2D = r._world_layer.transform + var state: EvoTypes.WorldState = r.world_state + var font := ThemeDB.fallback_font + var zoom_val: float = r.camera.zoom_level if r.camera != null else 1.0 + var inv_zoom: float = clampf(1.0 / maxf(0.05, zoom_val), 0.8, 3.2) + + if lens == "diplomacy": + for d_a: EvoTypes.Deme in state.demes.values(): + if d_a.walled_union_id == "" or not r._hex_centers.has(d_a.hex_id): + continue + for d_b: EvoTypes.Deme in state.demes.values(): + if d_b.id <= d_a.id or d_b.walled_union_id != d_a.walled_union_id or not r._hex_centers.has(d_b.hex_id): + continue + var p_a: Vector2 = to_screen * (r._hex_centers[d_a.hex_id] as Vector2) + var p_b: Vector2 = to_screen * (r._hex_centers[d_b.hex_id] as Vector2) + var diff := p_b - p_a + var perp := Vector2(-diff.y, diff.x).normalized() if diff.length() > 1.0 else Vector2.UP + ci.draw_line(p_a, p_b, Color(0.08, 0.04, 0.02, 0.75), 9.0) + ci.draw_line(p_a + perp * 2.5, p_b + perp * 2.5, Color("#fbbf24"), 2.6) + ci.draw_line(p_a - perp * 2.5, p_b - perp * 2.5, Color("#fbbf24"), 2.6) + _draw_lens_icon_pill(ci, font, (p_a + p_b) * 0.5, "icon_merge", "", Color("#fbbf24")) + + for pk_var in state.relations.keys(): + var pk := str(pk_var) + var parts := pk.split("__") + if parts.size() != 2 or not state.demes.has(parts[0]) or not state.demes.has(parts[1]): + continue + var d_a: EvoTypes.Deme = state.demes[parts[0]] + var d_b: EvoTypes.Deme = state.demes[parts[1]] + if not r._hex_centers.has(d_a.hex_id) or not r._hex_centers.has(d_b.hex_id): + continue + var p_a: Vector2 = to_screen * (r._hex_centers[d_a.hex_id] as Vector2) + var p_b: Vector2 = to_screen * (r._hex_centers[d_b.hex_id] as Vector2) + var rel: EvoTypes.Relation = state.relations[pk_var] + if rel.pact_turns > 0: + var w := clampf(2.5 + float(rel.pact_turns) * 0.7, 3.0, 6.5) + ci.draw_line(p_a, p_b, Color(0.04, 0.18, 0.12, 0.80), w + 2.4) + ci.draw_line(p_a, p_b, Color("#10b981"), w) + _draw_lens_icon_pill(ci, font, (p_a + p_b) * 0.5, "icon_pact", str(rel.pact_turns), Color("#10b981")) + elif rel.feud_turns > 0: + _draw_dashed_screen_line(ci, p_a, p_b, Color("#ef4444"), 3.0) + _draw_lens_icon_pill(ci, font, (p_a + p_b) * 0.5, "icon_vendetta", str(rel.feud_turns), Color("#ef4444")) + elif rel.truce_turns > 0: + _draw_dashed_screen_line(ci, p_a, p_b, Color("#6ee7b7"), 2.2) + + for flow in state.active_flows: + if not r._hex_centers.has(flow.from_hex_id) or not r._hex_centers.has(flow.to_hex_id): + continue + if flow.from_hex_id == flow.to_hex_id: + continue + var p_a: Vector2 = to_screen * (r._hex_centers[flow.from_hex_id] as Vector2) + var p_b: Vector2 = to_screen * (r._hex_centers[flow.to_hex_id] as Vector2) + var diff := p_b - p_a + var dist := diff.length() + if dist < 8.0: + continue + var dir := diff / dist + var perp := Vector2(-dir.y, dir.x) + if flow.flow_type == "merge": + ci.draw_line(p_a + perp * 2.5, p_b + perp * 2.5, Color("#fbbf24"), 2.4) + ci.draw_line(p_a - perp * 2.5, p_b - perp * 2.5, Color("#fbbf24"), 2.4) + elif flow.flow_type in ["war", "siege", "raid", "tribute", "betrayal"]: + var col := EvoUiStyle.get_event_type_color(flow.flow_type) + var s_pt := p_a + dir * minf(24.0, dist * 0.20) + var e_pt := p_b - dir * minf(30.0, dist * 0.24) + ci.draw_line(s_pt, e_pt, Color(0.08, 0.04, 0.02, 0.82), 6.0) + ci.draw_line(s_pt, e_pt, col.lightened(0.12), 3.6) + var tri := PackedVector2Array([ + e_pt + dir * 7.0, + e_pt - dir * 8.0 + perp * 6.5, + e_pt - dir * 8.0 - perp * 6.5 + ]) + ci.draw_colored_polygon(tri, col.lightened(0.15)) + + elif lens == "health": + var pulse := 0.65 + 0.35 * sin(r._anim_time * 6.0) + for village in r._villages: + var deme: EvoTypes.Deme = village["deme"] + var hex_id: String = village["hex_id"] + if not r._hex_polys.has(hex_id) or not r._hex_centers.has(hex_id): + continue + var center: Vector2 = r._hex_centers[hex_id] + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + var ring_pts := PackedVector2Array() + ring_pts.resize(7) + for i in range(7): + ring_pts[i] = to_screen * center.lerp(top_pts[i % 6], 0.82) + var screen_center: Vector2 = to_screen * center + if deme.infection_turns > 0: + var inf_col := Color(0.52, 0.80, 0.09, 0.88 * pulse) + ci.draw_polyline(ring_pts, inf_col, 4.5 * sqrt(inv_zoom)) + _draw_lens_icon_pill( + ci, + font, + screen_center + Vector2(0.0, 32.0), + "icon_epidemic", + "%dt" % deme.infection_turns, + Color("#84cc16") + ) + elif deme.immunity_turns > 0: + ci.draw_polyline(ring_pts, Color("#06b6d4"), 3.2 * sqrt(inv_zoom)) + _draw_lens_icon_pill( + ci, + font, + screen_center + Vector2(0.0, 32.0), + "icon_immune", + "%dt" % deme.immunity_turns, + Color("#06b6d4") + ) + elif deme.salubrity < 0.45: + ci.draw_polyline(ring_pts, Color("#f59e0b"), 3.0 * sqrt(inv_zoom)) + + elif lens == "territory": + for hex_id in state.cells.keys(): + if not r._hex_polys.has(hex_id) or not r._hex_centers.has(hex_id): + continue + var cell: EvoTypes.HexCell = state.cells[hex_id] + if r._is_water(cell): + continue + var owner := _resolve_cell_owner_deme(state, cell) + if owner == null: + continue + var owner_side := "B" if EvoTypes._is_side_b_deme(state, owner) else "A" + var strat_col := EvoPropFloraRenderer.resolve_deme_strategy_color(state, owner) + var center: Vector2 = r._hex_centers[hex_id] + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + for e in range(6): + var nb_id: String = r._neighbor_id(cell, EDGE_DIR_MAP[e]) + var same_domain := false + if state.cells.has(nb_id): + var nb_cell: EvoTypes.HexCell = state.cells[nb_id] + if not r._is_water(nb_cell): + var nb_owner := _resolve_cell_owner_deme(state, nb_cell) + if nb_owner != null: + var nb_side := "B" if EvoTypes._is_side_b_deme(state, nb_owner) else "A" + same_domain = (nb_side == owner_side and nb_owner.strategy_id == owner.strategy_id) + if not same_domain: + var c0: Vector2 = to_screen * center.lerp(top_pts[e], 0.94) + var c1: Vector2 = to_screen * center.lerp(top_pts[(e + 1) % 6], 0.94) + ci.draw_line(c0, c1, Color("#23150c"), 6.4 * sqrt(inv_zoom)) + ci.draw_line(c0, c1, strat_col.lightened(0.18), 3.8 * sqrt(inv_zoom)) + + elif lens == "food": + for village in r._villages: + var deme: EvoTypes.Deme = village["deme"] + var hex_id: String = village["hex_id"] + if not r._hex_centers.has(hex_id): + continue + var screen_center: Vector2 = to_screen * (r._hex_centers[hex_id] as Vector2) + var ratio := (deme.last_food_intake / maxf(1.0, deme.last_food_need)) if deme.last_food_need > 0.0 else 1.0 + var pct := int(round(clampf(ratio, 0.0, 2.5) * 100.0)) + var col := Color("#10b981") if pct >= 100 else (Color("#f59e0b") if pct >= 75 else Color("#ef4444")) + _draw_lens_icon_pill(ci, font, screen_center + Vector2(0.0, 32.0), "icon_forage", "%d%%" % pct, col) + + for hex_id in r._rural_cell_ids: + if not state.cells.has(hex_id) or not r._hex_centers.has(hex_id): + continue + var cell: EvoTypes.HexCell = state.cells[hex_id] + var screen_center: Vector2 = to_screen * (r._hex_centers[hex_id] as Vector2) + var icon_name := "icon_forage" if cell.land_use == "crops" else "icon_cooperate" + var col := Color("#eab308") if cell.land_use == "crops" else Color("#10b981") + _draw_lens_icon_pill(ci, font, screen_center, icon_name, "", col) static func _draw_deme_billboards(r: Node2D) -> void: + var ci: Node2D = r._overlay_layer var to_screen: Transform2D = r._world_layer.transform + var zoom_val: float = r.camera.zoom_level if r.camera != null else 1.0 + var is_macro: bool = zoom_val < 0.30 + var inv_zoom: float = clampf(1.0 / maxf(0.05, zoom_val), 0.8, 3.2) var vp_size := r.get_viewport_rect().size if r.is_inside_tree() else Vector2(1600.0, 960.0) var screen_cull_rect := Rect2(Vector2(-240.0, -200.0), vp_size + Vector2(480.0, 400.0)) r._deme_hit_rects.clear() - var drawn_hex_rings: Dictionary = {} + _draw_rural_territory_overlays(r, to_screen, screen_cull_rect) + for village in r._villages: var v_pos: Vector2 = village["pos"] var screen_pos: Vector2 = to_screen * v_pos @@ -124,30 +393,120 @@ static func _draw_deme_billboards(r: Node2D) -> void: continue var deme: EvoTypes.Deme = village["deme"] var hex_id: String = village["hex_id"] - var sf: float = village["sf"] var is_dimmed: bool = village["is_dimmed"] var alpha := 0.48 if is_dimmed else 0.96 - var strat_vis := _resolve_deme_strategy_visuals(r.world_state, deme, r.lang) + var strat_vis: Dictionary = village["strat_visuals"] var strat_color: Color = strat_vis["color"] - if not drawn_hex_rings.has(hex_id): - drawn_hex_rings[hex_id] = true - _draw_hex_strategy_ring(r, hex_id, screen_pos, sf, strat_color, alpha) + _draw_hex_strategy_ring(r, hex_id, screen_pos, strat_color, alpha) + + var is_merged: bool = deme.merged_count > 0 or deme.walled_union_id != "" + var plinth_r: float = (58.0 if is_merged else 52.0) * zoom_val + var plaza_screen: Vector2 = to_screen * (v_pos + Vector2(0.0, 8.0)) + var modulate_col: Color = village["modulate"] + if bool(village.get("in_siege", false)): + var siege_pulse := 0.65 + 0.35 * sin(r._anim_time * 6.5) + var siege_r := plinth_r + 12.5 * zoom_val + draw_ellipse_outline( + ci, + plaza_screen, + siege_r, + siege_r * 0.58, + Color(0.98, 0.32, 0.12, 0.85 * siege_pulse) * modulate_col, + 3.2 * sqrt(inv_zoom) + ) + if deme.id == r.selected_deme_id: + var sel_r := plinth_r + 11.0 * zoom_val + draw_ellipse_outline(ci, plaza_screen, sel_r, sel_r * 0.58, C_SELECTION, 4.2 * sqrt(inv_zoom)) + elif deme.id == r.hovered_deme_id: + var hov_r := plinth_r + 9.5 * zoom_val + draw_ellipse_outline(ci, plaza_screen, hov_r, hov_r * 0.58, Color(1.0, 0.92, 0.68, 0.88), 2.8 * sqrt(inv_zoom)) r._deme_hit_rects.append({ "rect": to_screen * (village["hit_rect"] as Rect2), "deme_id": deme.id, "hex_id": hex_id }) - var crest_pos: Vector2 = (to_screen * (v_pos + Vector2(0.0, -54.0 * sf))) + Vector2(0.0, -16.0) + var crest_offset_y := -52.0 if is_macro else -68.0 + var crest_pos: Vector2 = (to_screen * (v_pos + Vector2(0.0, crest_offset_y))) + Vector2(0.0, -12.0 if is_macro else -16.0) draw_village_billboard(r, village, crest_pos) +static func _draw_rural_territory_overlays(r: Node2D, to_screen: Transform2D, screen_cull_rect: Rect2) -> void: + var ci: Node2D = r._overlay_layer + var state: EvoTypes.WorldState = r.world_state + var font := ThemeDB.fallback_font + for hex_id in r._rural_cell_ids: + if not state.cells.has(hex_id) or not r._hex_centers.has(hex_id) or not r._hex_polys.has(hex_id): + continue + var cell: EvoTypes.HexCell = state.cells[hex_id] + if not cell.deme_ids.is_empty() or not (cell.land_use in ["crops", "pasture"]) or cell.owner_deme_id == "": + continue + var center: Vector2 = r._hex_centers[hex_id] + var screen_pos: Vector2 = to_screen * center + if not screen_cull_rect.has_point(screen_pos): + continue + var owner_deme: EvoTypes.Deme + var strat_color: Color + if r._village_by_deme_id.has(cell.owner_deme_id): + var owner_village: Dictionary = r._village_by_deme_id[cell.owner_deme_id] + owner_deme = owner_village["deme"] + strat_color = owner_village["strat_color"] + elif state.demes.has(cell.owner_deme_id): + owner_deme = state.demes[cell.owner_deme_id] + strat_color = _resolve_deme_strategy_visuals(state, owner_deme, r.lang)["color"] + else: + continue + var top_pts: PackedVector2Array = r._hex_polys[hex_id] + var border_pts := PackedVector2Array() + border_pts.resize(7) + for i in range(7): + border_pts[i] = to_screen * center.lerp(top_pts[i % 6], 0.86) + var ring_col := strat_color + ring_col.a = 0.42 + ci.draw_polyline(border_pts, ring_col, 1.8) + + var show_label: bool = ( + hex_id == r.selected_hex_id + or hex_id == r.hovered_hex_id + or owner_deme.id == r.selected_deme_id + or owner_deme.id == r.hovered_deme_id + ) + if show_label: + var strat_vis := _resolve_deme_strategy_visuals(state, owner_deme, r.lang) + var short_name := str(strat_vis["name"]).split(" ")[0] + var slot_tag := str(strat_vis["slot_tag"]) + var tag_str := ("%s·%s" % [slot_tag, short_name]) if slot_tag != "" else short_name + var label_str := "%s (%s)" % [tag_str, owner_deme.hex_id] + if label_str.length() > 20: + label_str = label_str.substr(0, 19) + "…" + var pill_w := 134.0 + var pill_h := 20.0 + var pill_rect := Rect2(screen_pos.x - pill_w * 0.5, screen_pos.y - 34.0, pill_w, pill_h) + ci.draw_rect(pill_rect, Color(0.11, 0.07, 0.04, 0.88), true) + ci.draw_rect(pill_rect, strat_color, false, 1.5) + var icon_name := "icon_forage" if cell.land_use == "crops" else "icon_cooperate" + var icon_tex: ImageTexture = EvoPixelArtGen.get_texture(icon_name) + ci.draw_texture_rect( + icon_tex, + Rect2(pill_rect.position + Vector2(4.0, 3.0), Vector2(14.0, 14.0)), + false + ) + ci.draw_string( + font, + Vector2(pill_rect.position.x + 21.0, pill_rect.position.y + 14.0), + label_str, + HORIZONTAL_ALIGNMENT_LEFT, + pill_w - 25.0, + 10, + Color("#fef3c7") + ) + + static func _draw_hex_strategy_ring( r: Node2D, hex_id: String, screen_pos: Vector2, - sf: float, strat_color: Color, alpha: float ) -> void: @@ -174,7 +533,7 @@ static func _draw_hex_strategy_ring( else: var fallback_col := strat_color fallback_col.a = 0.65 * alpha - draw_ellipse_outline(ci, screen_pos, 64.0 * sf, 37.0 * sf, fallback_col, 2.2) + draw_ellipse_outline(ci, screen_pos, 64.0, 37.0, fallback_col, 2.2) static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) -> void: @@ -182,15 +541,20 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) var deme: EvoTypes.Deme = village["deme"] var is_dimmed: bool = village["is_dimmed"] var font := ThemeDB.fallback_font + var zoom_val: float = r.camera.zoom_level if r.camera != null else 1.0 + var is_macro: bool = zoom_val < 0.30 + var lod: int = 0 if zoom_val < 0.48 else (1 if zoom_val < 0.90 else 2) - var strat_vis := _resolve_deme_strategy_visuals(r.world_state, deme, r.lang) + var strat_vis: Dictionary = village["strat_visuals"] var strat_color: Color = strat_vis["color"] - var strat_icon: String = strat_vis["icon"] var strat_name: String = strat_vis["name"] - var slot_tag: String = strat_vis["slot_tag"] + var strat_letter: String = str(strat_vis["slot_tag"]) + if strat_letter == "": + strat_letter = "B" if EvoTypes._is_side_b_deme(r.world_state, deme) else "A" - var badge_w := BADGE_W - var badge_h := BADGE_H + var badge_w := 96.0 if is_macro else (88.0 if lod == 0 else (134.0 if lod == 1 else 148.0)) + var badge_h := 24.0 if is_macro else (24.0 if lod == 0 else (36.0 if lod == 1 else 40.0)) + var shield_w := 6.0 if is_macro else (20.0 if lod == 0 else (24.0 if lod == 1 else 26.0)) var rect := Rect2(pos.x - badge_w * 0.5, pos.y - badge_h * 0.5, badge_w, badge_h) var alpha := 0.48 if is_dimmed else 0.96 @@ -211,8 +575,8 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) ci.draw_rect(Rect2(rect.position + Vector2(0.0, 2.0), rect.size), Color(0.0, 0.0, 0.0, 0.38 * alpha), true) ci.draw_rect(rect, Color(0.11, 0.07, 0.04, 0.92 * alpha), true) - var shield_rect := Rect2(rect.position, Vector2(SHIELD_W, badge_h)) - var shield_fill := strat_color.darkened(0.25) + var shield_rect := Rect2(rect.position, Vector2(shield_w, badge_h)) + var shield_fill := strat_color if is_macro else strat_color.darkened(0.25) shield_fill.a = 0.95 * alpha ci.draw_rect(shield_rect, shield_fill, true) var shield_border := strat_color.lightened(0.12) @@ -227,60 +591,168 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) shield_border = Color("#fbbf24") shield_border_w = 2.5 shield_border.a = alpha - ci.draw_rect(shield_rect, shield_border, false, shield_border_w) - ci.draw_string( - font, - Vector2(shield_rect.position.x, shield_rect.position.y + 20.0), - strat_icon, - HORIZONTAL_ALIGNMENT_CENTER, - SHIELD_W, - 14, - Color(1.0, 1.0, 1.0, alpha) - ) + if not is_macro: + ci.draw_rect(shield_rect, shield_border, false, shield_border_w) + var tag_y := shield_rect.position.y + (16.0 if lod == 0 else (22.0 if lod == 1 else 24.0)) + ci.draw_string( + font, + Vector2(shield_rect.position.x, tag_y), + strat_letter, + HORIZONTAL_ALIGNMENT_CENTER, + shield_w, + 11 if lod == 0 else 13, + Color(1.0, 1.0, 1.0, alpha) + ) - var short_name := strat_name.split(" ")[0] - var top_text := ("%s · %s" % [slot_tag, short_name]) if slot_tag != "" else short_name - if top_text.length() > 13: - top_text = top_text.substr(0, 12) + "…" - var top_col := strat_color.lightened(0.35) - top_col.a = alpha - ci.draw_string( - font, - Vector2(rect.position.x + SHIELD_W + 5.0, rect.position.y + 12.0), - top_text, - HORIZONTAL_ALIGNMENT_LEFT, - badge_w - SHIELD_W - 8.0, - 10, - top_col - ) + var tier_roman := deme.get_village_roman() + var is_starving := deme.get_vital_status() == "starving" + + if is_macro: + var macro_text := "%s %s·%d" % [strat_letter, tier_roman, deme.population] + var pop_col := Color("#fde047") if is_selected else Color("#fef3c7") + pop_col.a = alpha + ci.draw_string( + font, + Vector2(rect.position.x + shield_w + 5.0, rect.position.y + 16.0), + macro_text, + HORIZONTAL_ALIGNMENT_LEFT, + badge_w - shield_w - 16.0, + 11, + pop_col + ) + var dot_col := Color("#10b981") + if deme.infection_turns > 0: + var pulse := 0.55 + 0.45 * sin(r._anim_time * 7.0) + dot_col = Color("#84cc16") * pulse + Color(0.2, 0.3, 0.0, 1.0 - pulse) + elif is_starving: + dot_col = Color("#ef4444") + elif deme.salubrity < 0.50: + dot_col = Color("#f59e0b") + dot_col.a = alpha + var dot_pos := Vector2(rect.end.x - 8.0, rect.position.y + 12.0) + ci.draw_circle(dot_pos, 4.0, Color(0.14, 0.08, 0.05, alpha)) + ci.draw_circle(dot_pos, 2.8, dot_col) + elif lod == 0: + var compact_text := "%s·%d" % [tier_roman, deme.population] + var pop_col := Color("#fde047") if is_selected else Color("#fef3c7") + pop_col.a = alpha + ci.draw_string( + font, + Vector2(rect.position.x + shield_w + 4.0, rect.position.y + 16.0), + compact_text, + HORIZONTAL_ALIGNMENT_LEFT, + badge_w - shield_w - 16.0, + 10, + pop_col + ) + var dot_col := Color("#10b981") + if deme.infection_turns > 0: + var pulse := 0.55 + 0.45 * sin(r._anim_time * 7.0) + dot_col = Color("#84cc16") * pulse + Color(0.2, 0.3, 0.0, 1.0 - pulse) + elif is_starving: + dot_col = Color("#ef4444") + elif deme.salubrity < 0.50: + dot_col = Color("#f59e0b") + dot_col.a = alpha + var dot_pos := Vector2(rect.end.x - 8.0, rect.position.y + 12.0) + ci.draw_circle(dot_pos, 4.2, Color(0.14, 0.08, 0.05, alpha)) + ci.draw_circle(dot_pos, 3.0, dot_col) + else: + var short_name := strat_name.split(" ")[0] + var top_text := "%s · %s" % [strat_letter, short_name] + if top_text.length() > 14: + top_text = top_text.substr(0, 13) + "…" + var top_col := strat_color.lightened(0.35) + top_col.a = alpha + ci.draw_string( + font, + Vector2(rect.position.x + shield_w + 5.0, rect.position.y + 11.0), + top_text, + HORIZONTAL_ALIGNMENT_LEFT, + badge_w - shield_w - 22.0, + 10, + top_col + ) - var pop_text := ("%d pop" if r.lang == "en" else "%d hab") % deme.population - var pop_col := Color("#fde047") if is_selected else Color("#fef3c7") - pop_col.a = alpha - ci.draw_string( - font, - Vector2(rect.position.x + SHIELD_W + 5.0, rect.position.y + 25.0), - pop_text, - HORIZONTAL_ALIGNMENT_LEFT, - badge_w - SHIELD_W - 24.0, - 10, - pop_col - ) + var archetype: String = village["archetype"] + var dec_tex: ImageTexture = EvoPixelArtGen.get_texture("icon_" + archetype) + ci.draw_texture_rect( + dec_tex, + Rect2(Vector2(rect.end.x - 17.0, rect.position.y + 3.0), Vector2(14.0, 14.0)), + false, + Color(1.0, 1.0, 1.0, alpha) + ) - var archetype: String = village["archetype"] - var dec_tex: ImageTexture = EvoPixelArtGen.get_texture("icon_" + archetype) - ci.draw_texture_rect( - dec_tex, - Rect2(Vector2(rect.end.x - 18.0, rect.position.y + 14.0), Vector2(14.0, 14.0)), - false, - Color(1.0, 1.0, 1.0, alpha) - ) + var pop_base := "%s · %d" % [tier_roman, deme.population] + var pop_col := Color("#fde047") if is_selected else Color("#fef3c7") + pop_col.a = alpha + var mid_x := rect.position.x + shield_w + 5.0 + ci.draw_string( + font, + Vector2(mid_x, rect.position.y + 23.0), + pop_base, + HORIZONTAL_ALIGNMENT_LEFT, + 64.0, + 10, + pop_col + ) + if deme.last_population_delta != 0: + var delta_str := "(%+d)" % deme.last_population_delta + var delta_col := Color("#4ade80") if deme.last_population_delta > 0 else Color("#f87171") + delta_col.a = alpha + ci.draw_string( + font, + Vector2(mid_x + 48.0, rect.position.y + 23.0), + delta_str, + HORIZONTAL_ALIGNMENT_LEFT, + 38.0, + 9, + delta_col + ) + + if lod == 2: + var cal_str := "%d cal" % int(round(deme.energy_reserves)) + var cal_col := Color("#fca5a5") if is_starving else Color("#fcd34d") + cal_col.a = alpha * 0.90 + ci.draw_string( + font, + Vector2(rect.end.x - 42.0, rect.position.y + 23.0), + cal_str, + HORIZONTAL_ALIGNMENT_RIGHT, + 38.0, + 9, + cal_col + ) + + var bar_area_x := rect.position.x + shield_w + 5.0 + var bar_area_w := badge_w - shield_w - 10.0 + var bar_y := rect.end.y - 5.5 + var single_w := (bar_area_w - 4.0) * 0.5 + + var food_ratio := clampf(deme.last_food_intake / maxf(1.0, deme.last_food_need), 0.05, 1.0) if deme.last_food_need > 0.0 else 1.0 + var food_col := Color("#fbbf24") if (food_ratio >= 0.80 and not is_starving) else Color("#ef4444") + food_col.a = alpha + var food_bg := Rect2(bar_area_x, bar_y, single_w, 3.0) + ci.draw_rect(food_bg, Color(0.0, 0.0, 0.0, 0.50 * alpha), true) + ci.draw_rect(Rect2(food_bg.position, Vector2(single_w * food_ratio, 3.0)), food_col, true) + + var sal_ratio := clampf(deme.salubrity, 0.05, 1.0) + var sal_col := Color("#10b981") if deme.salubrity >= 0.75 else (Color("#f59e0b") if deme.salubrity >= 0.40 else Color("#ef4444")) + if deme.infection_turns > 0: + sal_col = Color("#84cc16") + sal_col.a = alpha + var sal_bg := Rect2(bar_area_x + single_w + 4.0, bar_y, single_w, 3.0) + ci.draw_rect(sal_bg, Color(0.0, 0.0, 0.0, 0.50 * alpha), true) + ci.draw_rect(Rect2(sal_bg.position, Vector2(single_w * sal_ratio, 3.0)), sal_col, true) var geo_icons: Array = village.get("geo_icons", []) - for g_idx in range(geo_icons.size()): + var max_icons := 0 if is_macro else (1 if lod == 0 else (3 if lod == 1 else 5)) + var shown_count := mini(geo_icons.size(), max_icons) + var pill_y := rect.position.y + (badge_h - 18.0) * 0.5 + for g_idx in range(shown_count): var g_type := str(geo_icons[g_idx]) var pill_rect := Rect2( - Vector2(rect.end.x + 3.0 + float(g_idx) * 20.0, rect.position.y + 6.0), + Vector2(rect.end.x + 3.0 + float(g_idx) * 20.0, pill_y), Vector2(18.0, 18.0) ) var g_col := EvoUiStyle.get_event_type_color(g_type) @@ -297,6 +769,24 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) pill_border.a = alpha ci.draw_rect(pill_rect, pill_border, false, 1.5) + if geo_icons.size() > max_icons and lod >= 1: + var extra_count := geo_icons.size() - max_icons + var extra_rect := Rect2( + Vector2(rect.end.x + 3.0 + float(shown_count) * 20.0, pill_y), + Vector2(22.0, 18.0) + ) + ci.draw_rect(extra_rect, Color(0.11, 0.07, 0.04, 0.92 * alpha), true) + ci.draw_rect(extra_rect, Color(0.85, 0.65, 0.25, alpha), false, 1.2) + ci.draw_string( + font, + Vector2(extra_rect.position.x, extra_rect.position.y + 13.0), + "+%d" % extra_count, + HORIZONTAL_ALIGNMENT_CENTER, + 22.0, + 9, + Color(1.0, 0.95, 0.80, alpha) + ) + var border_col := ( C_SELECTION if is_selected diff --git a/src/world/shaders/ocean_waves.gdshader b/src/world/shaders/ocean_waves.gdshader new file mode 100644 index 0000000..4577f11 --- /dev/null +++ b/src/world/shaders/ocean_waves.gdshader @@ -0,0 +1,59 @@ +shader_type canvas_item; + +uniform vec4 deep_color : source_color = vec4(0.11, 0.34, 0.52, 1.0); +uniform vec4 mid_color : source_color = vec4(0.16, 0.48, 0.68, 1.0); +uniform vec4 shallow_color : source_color = vec4(0.24, 0.68, 0.76, 1.0); +uniform vec4 foam_color : source_color = vec4(0.92, 0.97, 1.0, 0.92); +uniform float wave_speed : hint_range(0.1, 4.0) = 1.15; +uniform float wave_scale : hint_range(0.5, 20.0) = 6.0; +uniform float foam_intensity : hint_range(0.0, 1.5) = 0.65; + +varying vec2 v_pos; + +float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float value_noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +void vertex() { + v_pos = VERTEX; +} + +void fragment() { + vec2 coord = UV * wave_scale + v_pos * 0.012; + float t = TIME * wave_speed; + + float n1 = value_noise(coord * 1.4 + vec2(t * 0.35, -t * 0.22)); + float n2 = value_noise(coord * 2.8 - vec2(t * 0.28, t * 0.40)); + float wave_band = sin(coord.x * 2.6 + coord.y * 3.4 - t * 1.6 + n1 * 2.8); + float secondary_band = cos(coord.x * 4.1 - coord.y * 2.7 + t * 1.2 + n2 * 2.2); + + float depth_mix = clamp(0.5 + 0.35 * n1 + 0.15 * n2, 0.0, 1.0); + vec3 water_rgb = mix(deep_color.rgb, mid_color.rgb, smoothstep(0.15, 0.72, depth_mix)); + water_rgb = mix(water_rgb, shallow_color.rgb, smoothstep(0.55, 0.95, n1 * 0.6 + n2 * 0.4)); + + // Illustrated stylized wave crest bands matching land.jpeg / land2.jpeg + float crest = smoothstep(0.62, 0.86, wave_band * 0.65 + secondary_band * 0.35); + float shimmer = smoothstep(0.74, 0.92, n1 * 0.55 + n2 * 0.45); + water_rgb = mix(water_rgb, shallow_color.rgb * 1.15, shimmer * 0.42); + + // Rhythmic white shoreline/wave foam contours + float edge_dist = length(UV - vec2(0.5)) * 2.0; + float shore_ring = smoothstep(0.68, 0.94, edge_dist + 0.08 * sin(t * 1.8 + n1 * 5.0)); + float foam_mask = clamp((crest * 0.78 + shore_ring * 0.55 * step(0.01, UV.x + UV.y)) * foam_intensity, 0.0, 1.0); + vec3 final_rgb = mix(water_rgb, foam_color.rgb, foam_mask * foam_color.a); + + COLOR = vec4(final_rgb, 1.0); +} diff --git a/src/world/shaders/ocean_waves.gdshader.uid b/src/world/shaders/ocean_waves.gdshader.uid new file mode 100644 index 0000000..84faf48 --- /dev/null +++ b/src/world/shaders/ocean_waves.gdshader.uid @@ -0,0 +1 @@ +uid://bmwad3pt6n6pv diff --git a/src/world/sprites/README.md b/src/world/sprites/README.md new file mode 100644 index 0000000..441579a --- /dev/null +++ b/src/world/sprites/README.md @@ -0,0 +1,30 @@ +# High-DPI Illustrated & Procedural Sprites (`src/world/sprites`) + +## Overview +Implements the high-definition anti-aliased illustrated drawing primitives (`EvoPixelCanvas`) and procedural isometric sprite generators for biome hex tiles and terrain landmarks (`EvoTerrainSprites`), 4-tier settlement buildings, rural agricultural/pastoral fields, fortifications, ruins, and animated villagers (`EvoVillageUiSprites`), and `64x64` 2D UI and map lens icons (`EvoUiIconSprites`). + +## Interface +- `[pixel_canvas.gd:EvoPixelCanvas]` — Base procedural vector/raster illustrated canvas helper extending `RefCounted`. + - Constants: `[pixel_canvas.gd:C_OUTLINE]` (`#23150c`), `[pixel_canvas.gd:C_SHADOW]` + - Raster & Outline Methods: `[pixel_canvas.gd:_new_canvas]`, `[pixel_canvas.gd:_px]`, `[pixel_canvas.gd:_rect]`, `[pixel_canvas.gd:_rect_outlined]` + - High-DPI Anti-Aliased & Isometric Methods: `[pixel_canvas.gd:_blend_px]`, `[pixel_canvas.gd:_drop_shadow]`, `[pixel_canvas.gd:_aa_circle]`, `[pixel_canvas.gd:_aa_circle_outlined]`, `[pixel_canvas.gd:_aa_ellipse]`, `[pixel_canvas.gd:_aa_ellipse_outlined]`, `[pixel_canvas.gd:_aa_line]`, `[pixel_canvas.gd:_poly_filled]`, `[pixel_canvas.gd:_poly_outlined]`, `[pixel_canvas.gd:_draw_isometric_box]`, `[pixel_canvas.gd:_draw_pitched_roof]` +- `[terrain_sprites.gd:EvoTerrainSprites]` — High-DPI illustrated generator for biome hex tiles (`256x224`) and terrain flora/geology sprites (`128x128` to `160x160`) extending `EvoPixelCanvas`. + - Static Methods: `[terrain_sprites.gd:draw_terrain_sprite]`, `[terrain_sprites.gd:draw_hex_tile]` +- `[village_ui_sprites.gd:EvoVillageUiSprites]` — High-DPI illustrated generator for isometric settlement architecture (`hut_thatch`, `campfire`, `house_clay`, `granary`, `house_stone`, `temple_totem`, `windmill`, `watchtower`, `citadel_keep`, `cathedral`, `palisade_wall`, `stone_wall`, `siege_totem`), rural & ruin props (`crop_field`, `pasture_pen`, `covered_wagon`, `ruins_stone`, `prop_betrayal_ruin`), and villagers (`48x48`), delegating `64x64` UI icons to `EvoUiIconSprites`. + - Static Methods: `[village_ui_sprites.gd:draw_village_or_ui_sprite]`, `[village_ui_sprites.gd:draw_villager]` +- `[ui_icon_sprites.gd:EvoUiIconSprites]` — High-DPI `64x64` 2D UI and map lens icon generator (`tool_*`, `season_*`, `status_*`, decision glyphs `icon_forage`..`icon_innovate`, geopolitical badges `icon_war`..`icon_siege`, and readability icons `icon_epidemic`, `icon_famine`, `icon_immune`, `icon_vendetta`, `icon_casualties`, `icon_loot`, `icon_migrants`) extending `EvoPixelCanvas`. + - Static Methods: `[ui_icon_sprites.gd:draw_ui_icon_sprite]` + +## Invariants +- `[pixel_canvas.gd:_new_canvas]` creates uncompressed `Image.FORMAT_RGBA8` images filled with transparent black `Color(0, 0, 0, 0)`. +- `[pixel_canvas.gd:_px]` and `[pixel_canvas.gd:_blend_px]` bounds-check coordinates (`0 <= x < img.get_width()`, `0 <= y < img.get_height()`) before writing or alpha-compositing pixels. +- `[terrain_sprites.gd:draw_hex_tile]` returns a `256x224` illustrated `Image` for any biome name (`tropical_forest`, `savanna`, `arid_steppe`, `tundra`, `high_mountain`, `coastal_shallows`, or ocean fallback). +- `[terrain_sprites.gd:draw_terrain_sprite]` returns `128x128` (or `160x160` for `rock_mountain` / `mountain_peak`) illustrated sprites with `#23150c` ink outlines. +- `[village_ui_sprites.gd:draw_village_or_ui_sprite]` returns `128x128`–`160x160` isometric building/rural/ruin sprites and delegates `64x64` UI icons to `[ui_icon_sprites.gd:draw_ui_icon_sprite]`. +- `[village_ui_sprites.gd:draw_villager]` renders `48x48` villager sprites with pose-dependent bobbing and alternating leg stride (`step == 1` vs `step == 2`). + +## Side Effects +- None (pure in-memory `Image` allocation and pixel/scanline manipulation; disk caching and mipmap generation are handled by `EvoPixelArtGen`). + +## Verification +- Run: `godot --headless --path . --script res://tests/test_runner.gd` diff --git a/src/world/sprites/pixel_canvas.gd b/src/world/sprites/pixel_canvas.gd index 201e38a..74558c1 100644 --- a/src/world/sprites/pixel_canvas.gd +++ b/src/world/sprites/pixel_canvas.gd @@ -2,7 +2,6 @@ class_name EvoPixelCanvas extends RefCounted const C_OUTLINE := Color("#23150c") -const C_SHADOW := Color(0.06, 0.04, 0.02, 0.35) static func _new_canvas(w: int, h: int) -> Image: @@ -22,10 +21,33 @@ static func _px(img: Image, x: int, y: int, col: Color) -> void: img.set_pixel(x, y, col) +static func _blend_px(img: Image, x: int, y: int, col: Color) -> void: + if x < 0 or x >= img.get_width() or y < 0 or y >= img.get_height() or col.a <= 0.001: + return + if col.a >= 0.995: + img.set_pixel(x, y, col) + return + var dst := img.get_pixel(x, y) + if dst.a <= 0.001: + img.set_pixel(x, y, col) + return + var out_a := col.a + dst.a * (1.0 - col.a) + var inv_a := 1.0 / maxf(0.0001, out_a) + var r := (col.r * col.a + dst.r * dst.a * (1.0 - col.a)) * inv_a + var g := (col.g * col.a + dst.g * dst.a * (1.0 - col.a)) * inv_a + var b := (col.b * col.a + dst.b * dst.a * (1.0 - col.a)) * inv_a + img.set_pixel(x, y, Color(r, g, b, out_a)) + + static func _rect(img: Image, x: int, y: int, w: int, h: int, col: Color) -> void: - for py in range(y, y + h): - for px_i in range(x, x + w): - _px(img, px_i, py, col) + if w <= 0 or h <= 0: + return + var x0 := maxi(0, x) + var y0 := maxi(0, y) + var x1 := mini(img.get_width(), x + w) + var y1 := mini(img.get_height(), y + h) + if x1 > x0 and y1 > y0: + img.fill_rect(Rect2i(x0, y0, x1 - x0, y1 - y0), col) static func _rect_outlined(img: Image, x: int, y: int, w: int, h: int, fill: Color, outline: Color = C_OUTLINE) -> void: @@ -38,63 +60,296 @@ static func _rect_outlined(img: Image, x: int, y: int, w: int, h: int, fill: Col _px(img, x + w - 1, py, outline) -static func _circle(img: Image, cx: int, cy: int, r: int, col: Color) -> void: - var r2 := r * r - for py in range(cy - r, cy + r + 1): - for px_i in range(cx - r, cx + r + 1): - var dx := px_i - cx - var dy := py - cy - if dx * dx + dy * dy <= r2: - _px(img, px_i, py, col) +static func _drop_shadow(img: Image, cx: float, cy: float, rx: float, ry: float, alpha: float = 0.34) -> void: + if rx <= 0.5 or ry <= 0.5: + return + var w := img.get_width() + var h := img.get_height() + var y0 := maxi(0, int(floor(cy - ry - 1.0))) + var y1 := mini(h - 1, int(ceil(cy + ry + 1.0))) + var x0 := maxi(0, int(floor(cx - rx - 1.0))) + var x1 := mini(w - 1, int(ceil(cx + rx + 1.0))) + for py in range(y0, y1 + 1): + var ny := (float(py) - cy) / ry + var ny2 := ny * ny + if ny2 >= 1.0: + continue + for px_i in range(x0, x1 + 1): + var nx := (float(px_i) - cx) / rx + var d2 := nx * nx + ny2 + if d2 < 1.0: + var falloff := clampf((1.0 - sqrt(d2)) * 2.2, 0.0, 1.0) + _blend_px(img, px_i, py, Color(0.05, 0.03, 0.02, alpha * falloff)) -static func _ellipse(img: Image, cx: int, cy: int, rx: int, ry: int, col: Color) -> void: - if rx <= 0 or ry <= 0: +static func _aa_circle(img: Image, cx: float, cy: float, r: float, col: Color) -> void: + if r <= 0.0: return - for py in range(cy - ry, cy + ry + 1): - for px_i in range(cx - rx, cx + rx + 1): - var nx := float(px_i - cx) / float(rx) - var ny := float(py - cy) / float(ry) - if nx * nx + ny * ny <= 1.0: - _px(img, px_i, py, col) - - -static func _line(img: Image, x0: int, y0: int, x1: int, y1: int, col: Color) -> void: - var dx: int = abs(x1 - x0) - var sx: int = 1 if x0 < x1 else -1 - var dy: int = -abs(y1 - y0) - var sy: int = 1 if y0 < y1 else -1 - var err: int = dx + dy - var cx: int = x0 - var cy: int = y0 - while true: - _px(img, cx, cy, col) - if cx == x1 and cy == y1: - break - var e2: int = 2 * err - if e2 >= dy: - err += dy - cx += sx - if e2 <= dx: - err += dx - cy += sy - - -static func _apply_soft_outline(img: Image, outline_col: Color = C_OUTLINE) -> void: var w := img.get_width() var h := img.get_height() - var copy := img.duplicate() as Image - var dirs := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] - for y in range(h): - for x in range(w): - var c := copy.get_pixel(x, y) - if c.a > 0.5: + var y0 := maxi(0, int(floor(cy - r - 1.0))) + var y1 := mini(h - 1, int(ceil(cy + r + 1.0))) + var r_outer := r + 0.75 + var r_inner := maxf(0.0, r - 0.75) + var r_inner2 := r_inner * r_inner + var r_outer2 := r_outer * r_outer + for py in range(y0, y1 + 1): + var dy := float(py) - cy + var dy2 := dy * dy + if dy2 >= r_outer2: + continue + var max_dx := sqrt(r_outer2 - dy2) + var x0 := maxi(0, int(floor(cx - max_dx))) + var x1 := mini(w - 1, int(ceil(cx + max_dx))) + if col.a >= 0.995 and dy2 < r_inner2: + var in_dx := sqrt(r_inner2 - dy2) + var ix0 := maxi(x0, int(ceil(cx - in_dx))) + var ix1 := mini(x1, int(floor(cx + in_dx))) + if ix1 >= ix0: + img.fill_rect(Rect2i(ix0, py, ix1 - ix0 + 1, 1), col) + for px_i in range(x0, ix0): + var d := sqrt((float(px_i) - cx) * (float(px_i) - cx) + dy2) + var cov := clampf(r + 0.5 - d, 0.0, 1.0) + if cov > 0.0: + _blend_px(img, px_i, py, Color(col.r, col.g, col.b, col.a * cov)) + for px_i in range(ix1 + 1, x1 + 1): + var d := sqrt((float(px_i) - cx) * (float(px_i) - cx) + dy2) + var cov := clampf(r + 0.5 - d, 0.0, 1.0) + if cov > 0.0: + _blend_px(img, px_i, py, Color(col.r, col.g, col.b, col.a * cov)) continue - for d in dirs: - var nx: int = x + d.x - var ny: int = y + d.y - if nx >= 0 and nx < w and ny >= 0 and ny < h: - var nc := copy.get_pixel(nx, ny) - if nc.a > 0.85: - img.set_pixel(x, y, outline_col) - break + for px_i in range(x0, x1 + 1): + var d := sqrt((float(px_i) - cx) * (float(px_i) - cx) + dy2) + var cov := clampf(r + 0.5 - d, 0.0, 1.0) + if cov > 0.0: + _blend_px(img, px_i, py, Color(col.r, col.g, col.b, col.a * cov)) + + +static func _aa_circle_outlined( + img: Image, + cx: float, + cy: float, + r: float, + fill_col: Color, + outline_col: Color = C_OUTLINE, + outline_w: float = 2.0 +) -> void: + _aa_circle(img, cx, cy, r + outline_w * 0.5, outline_col) + _aa_circle(img, cx, cy, maxf(0.5, r - outline_w * 0.5), fill_col) + + +static func _aa_ellipse(img: Image, cx: float, cy: float, rx: float, ry: float, col: Color) -> void: + if rx <= 0.2 or ry <= 0.2: + return + var w := img.get_width() + var h := img.get_height() + var y0 := maxi(0, int(floor(cy - ry - 1.5))) + var y1 := mini(h - 1, int(ceil(cy + ry + 1.5))) + var x0 := maxi(0, int(floor(cx - rx - 1.5))) + var x1 := mini(w - 1, int(ceil(cx + rx + 1.5))) + var avg_r := (rx + ry) * 0.5 + var feather := 1.0 / maxf(1.0, avg_r) + for py in range(y0, y1 + 1): + var ny := (float(py) - cy) / ry + var ny2 := ny * ny + if ny2 > 1.25: + continue + for px_i in range(x0, x1 + 1): + var nx := (float(px_i) - cx) / rx + var dist := sqrt(nx * nx + ny2) + var cov := clampf((1.0 + feather * 0.5 - dist) / feather, 0.0, 1.0) + if cov > 0.0: + _blend_px(img, px_i, py, Color(col.r, col.g, col.b, col.a * cov)) + + +static func _aa_ellipse_outlined( + img: Image, + cx: float, + cy: float, + rx: float, + ry: float, + fill_col: Color, + outline_col: Color = C_OUTLINE, + outline_w: float = 2.0 +) -> void: + _aa_ellipse(img, cx, cy, rx + outline_w * 0.5, ry + outline_w * 0.5, outline_col) + _aa_ellipse(img, cx, cy, maxf(0.5, rx - outline_w * 0.5), maxf(0.5, ry - outline_w * 0.5), fill_col) + + +static func _aa_line( + img: Image, + x0: float, + y0: float, + x1: float, + y1: float, + col: Color, + thickness: float = 2.0 +) -> void: + var w := img.get_width() + var h := img.get_height() + var hw := maxf(0.5, thickness * 0.5) + var pad := hw + 1.0 + var min_y := maxi(0, int(floor(minf(y0, y1) - pad))) + var max_y := mini(h - 1, int(ceil(maxf(y0, y1) + pad))) + var ab := Vector2(x1 - x0, y1 - y0) + var ab_len2 := ab.length_squared() + var dy := y1 - y0 + var abs_dy := absf(dy) + + for py in range(min_y, max_y + 1): + var fy := float(py) + var row_min_x: float + var row_max_x: float + if abs_dy < 0.5: + row_min_x = minf(x0, x1) - pad + row_max_x = maxf(x0, x1) + pad + else: + var t_a := clampf((fy - pad - y0) / dy, 0.0, 1.0) + var t_b := clampf((fy + pad - y0) / dy, 0.0, 1.0) + var xa := x0 + t_a * ab.x + var xb := x0 + t_b * ab.x + row_min_x = minf(xa, xb) - pad + row_max_x = maxf(xa, xb) + pad + + var ix0 := maxi(0, int(floor(row_min_x))) + var ix1 := mini(w - 1, int(ceil(row_max_x))) + for px_i in range(ix0, ix1 + 1): + var p := Vector2(float(px_i), fy) + var ap := p - Vector2(x0, y0) + var t := clampf(ap.dot(ab) / ab_len2, 0.0, 1.0) if ab_len2 > 0.0001 else 0.0 + var closest := Vector2(x0, y0) + ab * t + var d := p.distance_to(closest) + var cov := clampf(hw + 0.5 - d, 0.0, 1.0) + if cov > 0.0: + _blend_px(img, px_i, py, Color(col.r, col.g, col.b, col.a * cov)) + + +static func _poly_filled(img: Image, points: PackedVector2Array, top_col: Color, bot_col: Color) -> void: + var n := points.size() + if n < 3: + return + var w := img.get_width() + var h := img.get_height() + var min_y := float(h) + var max_y := 0.0 + for p in points: + min_y = minf(min_y, p.y) + max_y = maxf(max_y, p.y) + + var y0 := maxi(0, int(floor(min_y))) + var y1 := mini(h - 1, int(ceil(max_y))) + var span_y := maxf(1.0, max_y - min_y) + + for py in range(y0, y1 + 1): + var fy := float(py) + 0.5 + var nodes: Array[float] = [] + var j := n - 1 + for i in range(n): + var pi := points[i] + var pj := points[j] + if (pi.y < fy and pj.y >= fy) or (pj.y < fy and pi.y >= fy): + var ix := pi.x + (fy - pi.y) / (pj.y - pi.y) * (pj.x - pi.x) + nodes.append(ix) + j = i + if nodes.size() < 2: + continue + nodes.sort() + var t_grad := clampf((fy - min_y) / span_y, 0.0, 1.0) + var row_col := top_col.lerp(bot_col, t_grad) + var k := 0 + while k + 1 < nodes.size(): + var x_left := nodes[k] + var x_right := nodes[k + 1] + var ix_start := maxi(0, int(ceil(x_left - 0.5))) + var ix_end := mini(w - 1, int(floor(x_right + 0.5))) + if ix_end >= ix_start: + if row_col.a >= 0.995: + img.fill_rect(Rect2i(ix_start, py, ix_end - ix_start + 1, 1), row_col) + else: + for px_i in range(ix_start, ix_end + 1): + _blend_px(img, px_i, py, row_col) + k += 2 + + +static func _poly_outlined( + img: Image, + points: PackedVector2Array, + top_col: Color, + bot_col: Color, + outline_col: Color = C_OUTLINE, + outline_w: float = 2.2 +) -> void: + _poly_filled(img, points, top_col, bot_col) + if outline_w <= 0.0: + return + var n := points.size() + for i in range(n): + var a := points[i] + var b := points[(i + 1) % n] + _aa_line(img, a.x, a.y, b.x, b.y, outline_col, outline_w) + + +static func _draw_isometric_box( + img: Image, + cx: float, + cy: float, + w: float, + d: float, + h: float, + top_col: Color, + left_col: Color, + right_col: Color, + outline_w: float = 2.2 +) -> void: + var b_front := Vector2(cx, cy) + var b_left := Vector2(cx - w, cy - w * 0.5) + var b_right := Vector2(cx + d, cy - d * 0.5) + var b_back := Vector2(cx + d - w, cy - (w + d) * 0.5) + + var t_front := b_front + Vector2(0.0, -h) + var t_left := b_left + Vector2(0.0, -h) + var t_right := b_right + Vector2(0.0, -h) + var t_back := b_back + Vector2(0.0, -h) + + if h > 0.5: + var left_face := PackedVector2Array([t_left, t_front, b_front, b_left]) + _poly_outlined(img, left_face, left_col.lightened(0.06), left_col.darkened(0.08), C_OUTLINE, outline_w) + + var right_face := PackedVector2Array([t_front, t_right, b_right, b_front]) + _poly_outlined(img, right_face, right_col.lightened(0.04), right_col.darkened(0.10), C_OUTLINE, outline_w) + + var top_face := PackedVector2Array([t_back, t_right, t_front, t_left]) + _poly_outlined(img, top_face, top_col.lightened(0.06), top_col.darkened(0.04), C_OUTLINE, outline_w) + + +static func _draw_pitched_roof( + img: Image, + cx: float, + cy: float, + w: float, + d: float, + roof_h: float, + left_slope_col: Color, + right_slope_col: Color, + gable_col: Color, + outline_w: float = 2.2 +) -> void: + var e_front := Vector2(cx, cy) + var e_left := Vector2(cx - w, cy - w * 0.5) + var e_right := Vector2(cx + d, cy - d * 0.5) + var e_back := Vector2(cx + d - w, cy - (w + d) * 0.5) + + var p_front := Vector2(cx - w * 0.5, cy - w * 0.25 - roof_h) + var p_back := p_front + Vector2(d, -d * 0.5) + + # Back-left slope (drawn first so front faces occlude it cleanly) + var left_slope := PackedVector2Array([e_left, e_back, p_back, p_front]) + _poly_outlined(img, left_slope, left_slope_col.lightened(0.06), left_slope_col.darkened(0.08), C_OUTLINE, outline_w) + + # Front-left triangular gable wall + var gable_face := PackedVector2Array([e_left, p_front, e_front]) + _poly_outlined(img, gable_face, gable_col.lightened(0.05), gable_col.darkened(0.06), C_OUTLINE, outline_w) + + # Front-right main visible roof slope + var right_slope := PackedVector2Array([p_front, p_back, e_right, e_front]) + _poly_outlined(img, right_slope, right_slope_col.lightened(0.08), right_slope_col.darkened(0.10), C_OUTLINE, outline_w) diff --git a/src/world/sprites/terrain_sprites.gd b/src/world/sprites/terrain_sprites.gd index 1df592d..b7bcd74 100644 --- a/src/world/sprites/terrain_sprites.gd +++ b/src/world/sprites/terrain_sprites.gd @@ -41,527 +41,582 @@ static func draw_terrain_sprite(sprite_name: String) -> Image: static func draw_hex_tile(biome: String) -> Image: - var w := 224 - var h := 192 + var w := 256 + var h := 224 var img := _new_canvas(w, h) - var cx := 112.0 - var cy := 96.0 + var cx := 128.0 + var cy := 112.0 match biome: "tropical_forest": - var tones := [ - Color("#1c6e38"), - Color("#258545"), - Color("#2f9e53"), - Color("#3eb864") - ] + var top_c := Color("#4ca83b") + var bot_c := Color("#297324") for y in range(h): - for x in range(w): - var dist := Vector2((float(x) - cx) / 104.0, (float(y) - cy) / 88.0).length() - var n := _hash2d(x / 2, y / 2, 11) % 4 - var fine := _hash2d(x, y, 17) % 5 - var idx := clampi(n + (1 if dist < 0.62 else 0) - (1 if dist > 0.86 else 0), 0, 3) - var col: Color = tones[idx] - if fine == 0: - col = col.lightened(0.06) - elif fine == 1: - col = col.darkened(0.06) - _px(img, x, y, col) - - for ms in [ - Vector2i(68, 60), Vector2i(148, 56), Vector2i(56, 120), - Vector2i(156, 124), Vector2i(110, 96), Vector2i(92, 140) - ]: - _ellipse(img, ms.x, ms.y, 8, 4, Color("#21783d")) - _ellipse(img, ms.x + 1, ms.y, 5, 2, Color("#34a856")) - - for i in range(48): - var fx := 24 + (_hash2d(i, 1, 31) % 176) - var fy := 20 + (_hash2d(i, 2, 31) % 152) - if i % 3 == 0: - _line(img, fx, fy, fx - 3, fy - 2, Color("#124d26")) - _line(img, fx, fy, fx + 3, fy - 2, Color("#124d26")) - _line(img, fx, fy - 1, fx - 2, fy - 3, Color("#52d173")) - _line(img, fx, fy - 1, fx + 2, fy - 3, Color("#52d173")) - _px(img, fx, fy - 2, Color("#7ae582")) - elif i % 3 == 1: - var fl_col := Color("#ff6b8b") if i % 2 == 0 else Color("#ffd166") - _px(img, fx, fy + 1, Color("#1b6332")) - _px(img, fx - 1, fy, fl_col) - _px(img, fx + 1, fy, fl_col) - _px(img, fx, fy - 1, fl_col) - _px(img, fx, fy, Color("#fff8d6")) - else: - _ellipse(img, fx, fy, 2, 1, Color("#576066")) - _px(img, fx, fy - 1, Color("#49c468")) + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) + _aa_ellipse(img, cx, cy, 98.0, 82.0, Color(0.38, 0.74, 0.28, 0.45)) + var moss_spots := [ + Vector2(68, 62), Vector2(182, 58), Vector2(58, 142), + Vector2(188, 146), Vector2(128, 108), Vector2(104, 162), + Vector2(154, 82), Vector2(88, 96) + ] + for ms in moss_spots: + _aa_ellipse(img, ms.x, ms.y, 24.0, 13.0, Color("#246920")) + _aa_ellipse(img, ms.x - 2.0, ms.y - 1.0, 16.0, 8.0, Color("#5ec447")) + for i in range(42): + var fx := 24.0 + float(_hash2d(i, 1, 31) % 208) + var fy := 20.0 + float(_hash2d(i, 2, 31) % 184) + _aa_line(img, fx, fy, fx - 5.0, fy - 4.0, Color("#1b4f18"), 2.0) + _aa_line(img, fx, fy, fx + 5.0, fy - 4.0, Color("#1b4f18"), 2.0) + _aa_line(img, fx, fy - 1.0, fx - 3.0, fy - 6.0, Color("#7ae55c"), 1.6) + _aa_line(img, fx, fy - 1.0, fx + 3.0, fy - 6.0, Color("#7ae55c"), 1.6) "savanna": - var tones := [ - Color("#ab7e24"), - Color("#c4932d"), - Color("#d9ab3b"), - Color("#e8be4e") - ] + var top_c := Color("#9bd142") + var bot_c := Color("#6ea326") for y in range(h): - for x in range(w): - var dist := Vector2((float(x) - cx) / 104.0, (float(y) - cy) / 88.0).length() - var wave := int(sin(float(x) * 0.10 + float(y) * 0.06) * 1.5) - var n := (_hash2d(x / 2, y / 2, 23) + wave) % 4 - var idx := clampi(abs(n) + (1 if dist < 0.58 else 0), 0, 3) - var col: Color = tones[idx] - if _hash2d(x, y, 29) % 6 == 0: - col = col.lightened(0.07) - _px(img, x, y, col) - - for dp in [ - Vector2i(72, 72), Vector2i(148, 112), Vector2i(88, 128), Vector2i(136, 64) - ]: - _ellipse(img, dp.x, dp.y, 7, 3, Color("#bd8b28")) - - for i in range(54): - var gx := 20 + (_hash2d(i, 3, 41) % 184) - var gy := 18 + (_hash2d(i, 4, 41) % 156) - if i % 3 != 0: - _line(img, gx, gy, gx - 1, gy - 3, Color("#8c6518")) - _line(img, gx + 1, gy, gx + 2, gy - 4, Color("#f2d06b")) - _px(img, gx, gy - 2, Color("#e3b646")) - else: - _ellipse(img, gx, gy, 3, 2, Color("#8f4426")) - _px(img, gx - 1, gy - 1, Color("#b85d38")) + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) + _aa_ellipse(img, cx, cy - 6.0, 96.0, 80.0, Color(0.68, 0.88, 0.32, 0.42)) + for dp in [Vector2(76, 76), Vector2(172, 126), Vector2(98, 152), Vector2(158, 72)]: + _aa_ellipse(img, dp.x, dp.y, 22.0, 11.0, Color("#80b52c")) + for i in range(48): + var gx := 22.0 + float(_hash2d(i, 3, 41) % 212) + var gy := 18.0 + float(_hash2d(i, 4, 41) % 188) + _aa_line(img, gx, gy, gx - 3.0, gy - 6.0, Color("#4c7816"), 1.8) + _aa_line(img, gx + 2.0, gy, gx + 4.0, gy - 7.0, Color("#b5e550"), 1.6) "arid_steppe": - var tones := [ - Color("#b86628"), - Color("#cc7735"), - Color("#de8b47"), - Color("#eda05c") - ] + var top_c := Color("#f5d17f") + var bot_c := Color("#d9a44a") for y in range(h): - for x in range(w): - var ripple := sin(float(y) * 0.28 + sin(float(x) * 0.06) * 2.2) - var idx := 1 - if ripple > 0.55: - idx = 3 - elif ripple > 0.0: - idx = 2 - elif ripple < -0.55: - idx = 0 - if _hash2d(x, y, 53) % 5 == 0: - idx = clampi(idx + 1, 0, 3) - _px(img, x, y, tones[idx]) - - for i in range(56): - var sx := 24 + (_hash2d(i, 5, 61) % 176) - var sy := 20 + (_hash2d(i, 6, 61) % 152) - if i % 4 == 0: - _line(img, sx - 4, sy, sx + 3, sy + 2, Color("#6e3819")) - _line(img, sx, sy + 1, sx + 2, sy + 4, Color("#6e3819")) - elif i % 2 == 0: - _ellipse(img, sx, sy, 3, 2, Color("#7d4122")) - _px(img, sx, sy - 1, Color("#a65a32")) - else: - _px(img, sx, sy, Color("#687530")) - _px(img, sx - 1, sy - 1, Color("#83913f")) - _px(img, sx + 1, sy - 1, Color("#83913f")) + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) + _aa_ellipse(img, cx, cy, 102.0, 84.0, Color(0.98, 0.86, 0.56, 0.35)) + # Illustrated Voronoi mud crack network matching land.jpeg / land2.jpeg + var seeds: Array[Vector2] = [] + for gy in range(5): + for gx in range(6): + var sx := 22.0 + float(gx) * 42.0 + float(_hash2d(gx, gy, 53) % 22 - 11) + var sy := 20.0 + float(gy) * 44.0 + float(_hash2d(gx, gy, 59) % 20 - 10) + seeds.append(Vector2(sx, sy)) + for i in range(seeds.size()): + var a := seeds[i] + for j in range(i + 1, seeds.size()): + var b := seeds[j] + var d := a.distance_to(b) + if d > 24.0 and d < 62.0: + var mid := (a + b) * 0.5 + Vector2(float(_hash2d(i, j, 61) % 9 - 4), float(_hash2d(j, i, 67) % 9 - 4)) + _aa_line(img, a.x, a.y, mid.x, mid.y, Color("#78471e"), 2.0) + _aa_line(img, mid.x, mid.y, b.x, b.y, Color("#78471e"), 2.0) + _aa_line(img, a.x, a.y + 1.0, mid.x, mid.y + 1.0, Color("#f7dc9c"), 1.0) "tundra": - var tones := [ - Color("#9eb6c4"), - Color("#b8cedb"), - Color("#d2e3ed"), - Color("#eaf4f8") - ] + var top_c := Color("#eef6fa") + var bot_c := Color("#b3cde0") for y in range(h): - for x in range(w): - var dist := Vector2((float(x) - cx) / 104.0, (float(y) - cy) / 88.0).length() - var n := _hash2d(x / 2, y / 2, 71) % 4 - var idx := clampi(n + (1 if dist < 0.65 else 0), 0, 3) - _px(img, x, y, tones[idx]) - - for mp in [ - Vector2i(68, 68), Vector2i(144, 72), Vector2i(96, 124), Vector2i(128, 52) - ]: - _ellipse(img, mp.x, mp.y, 8, 4, Color("#a5bfd0")) - - for i in range(40): - var ix := 24 + (_hash2d(i, 7, 79) % 176) - var iy := 20 + (_hash2d(i, 8, 79) % 152) - if i % 2 == 0: - _px(img, ix, iy, Color("#ffffff")) - _px(img, ix - 1, iy, Color("#7dd3fc")) - _px(img, ix + 1, iy, Color("#7dd3fc")) - _px(img, ix, iy - 1, Color("#38bdf8")) - _px(img, ix, iy + 1, Color("#38bdf8")) - else: - _ellipse(img, ix, iy, 3, 2, Color("#475569")) - _px(img, ix, iy - 1, Color("#e2e8f0")) + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) + for mp in [Vector2(76, 78), Vector2(174, 82), Vector2(112, 148), Vector2(148, 60)]: + _aa_ellipse(img, mp.x, mp.y, 26.0, 12.0, Color("#9bbcd1")) + _aa_ellipse(img, mp.x - 3.0, mp.y - 2.0, 18.0, 8.0, Color("#ffffff")) + for i in range(32): + var ix := 26.0 + float(_hash2d(i, 7, 79) % 204) + var iy := 22.0 + float(_hash2d(i, 8, 79) % 180) + _aa_line(img, ix - 4.0, iy, ix + 4.0, iy, Color("#ffffff"), 1.8) + _aa_line(img, ix, iy - 4.0, ix, iy + 4.0, Color("#7dd3fc"), 1.6) "high_mountain": - var tones := [ - Color("#524e4a"), - Color("#69645f"), - Color("#807a74"), - Color("#99928b") - ] + var top_c := Color("#8c857e") + var bot_c := Color("#59534e") for y in range(h): - for x in range(w): - var band := ((y + int(sin(float(x) * 0.08) * 5.0)) / 9) % 4 - var n := _hash2d(x, y, 89) % 4 - var idx := clampi((band + (1 if n > 2 else 0)) % 4, 0, 3) - _px(img, x, y, tones[idx]) - + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) var snow_caps := [ - Vector2i(92, 56), Vector2i(132, 68), Vector2i(108, 104), - Vector2i(72, 96), Vector2i(148, 116), Vector2i(68, 132) + Vector2(104, 64), Vector2(152, 78), Vector2(124, 120), + Vector2(82, 112), Vector2(170, 134), Vector2(78, 152) ] for sc in snow_caps: - _ellipse(img, sc.x, sc.y, 15, 7, Color("#cbd5e1")) - _ellipse(img, sc.x, sc.y - 1, 11, 4, Color("#f1f5f9")) - - for i in range(38): - var rx := 26 + (_hash2d(i, 9, 97) % 172) - var ry := 24 + (_hash2d(i, 10, 97) % 144) - _line(img, rx - 4, ry, rx + 4, ry + 1, Color("#2e2b29")) - _line(img, rx - 3, ry - 1, rx + 3, ry, Color("#b5afa8")) + _aa_ellipse(img, sc.x, sc.y, 24.0, 12.0, Color("#cbd5e1")) + _aa_ellipse(img, sc.x - 2.0, sc.y - 2.0, 17.0, 8.0, Color("#f8fafc")) + for i in range(28): + var rx := 28.0 + float(_hash2d(i, 9, 97) % 200) + var ry := 24.0 + float(_hash2d(i, 10, 97) % 176) + _aa_line(img, rx - 8.0, ry, rx + 8.0, ry + 3.0, Color("#332f2c"), 2.2) + _aa_line(img, rx - 6.0, ry - 2.0, rx + 6.0, ry + 1.0, Color("#b8b0a8"), 1.5) "coastal_shallows": + var top_c := Color("#4fd1c5") + var bot_c := Color("#289994") for y in range(h): - for x in range(w): - var d := Vector2((float(x) - cx) / 96.0, (float(y) - cy) / 80.0).length() - var jitter := float(_hash2d(x / 2, y / 2, 103) % 10) * 0.018 - var dj := d + jitter - var col := Color("#258f86") - if dj < 0.50: - col = Color("#ebd294") if _hash2d(x, y, 107) % 3 == 0 else Color("#d9be7e") - elif dj < 0.60: - col = Color("#c2a361") - elif dj < 0.68: - col = Color("#e0f7fa") - elif dj < 0.84: - col = Color("#45c4b8") if _hash2d(x, y, 109) % 2 == 0 else Color("#32a89d") - _px(img, x, y, col) - - var shells := [ - Vector2i(96, 84), Vector2i(128, 100), Vector2i(84, 108), - Vector2i(116, 72), Vector2i(74, 86), Vector2i(138, 82) - ] - for idx in range(shells.size()): - var sp: Vector2i = shells[idx] - if idx % 2 == 0: - _px(img, sp.x, sp.y, Color("#fb923c")) - _px(img, sp.x - 1, sp.y, Color("#f97316")) - _px(img, sp.x + 1, sp.y, Color("#f97316")) - _px(img, sp.x, sp.y - 1, Color("#f97316")) - _px(img, sp.x, sp.y + 1, Color("#f97316")) - else: - _rect(img, sp.x - 1, sp.y - 1, 3, 2, Color("#ff9ebb")) - _px(img, sp.x, sp.y, Color("#ffe3ec")) + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) + # Sandy shoal bank & double white foam contours matching land2.jpeg + _aa_ellipse(img, cx - 18.0, cy - 10.0, 88.0, 70.0, Color("#e8f8fa")) + _aa_ellipse(img, cx - 22.0, cy - 12.0, 78.0, 62.0, Color("#7de2d8")) + _aa_ellipse(img, cx - 26.0, cy - 14.0, 66.0, 50.0, Color("#f2fafd")) + _aa_ellipse(img, cx - 30.0, cy - 16.0, 58.0, 44.0, Color("#ebcf8d")) + _aa_ellipse(img, cx - 34.0, cy - 18.0, 44.0, 32.0, Color("#f5dfa6")) + for i in range(18): + var wx := 36.0 + float(_hash2d(i, 11, 109) % 184) + var wy := 28.0 + float(_hash2d(i, 12, 109) % 168) + _aa_line(img, wx - 10.0, wy, wx + 10.0, wy - 2.0, Color(0.94, 0.99, 1.0, 0.75), 2.2) _: - var tones := [ - Color("#134263"), - Color("#19547d"), - Color("#216899"), - Color("#2b7bb3") - ] + var top_c := Color("#23689b") + var bot_c := Color("#154266") for y in range(h): - for x in range(w): - var wave := sin(float(x) * 0.11 + float(y) * 0.15) - var idx := 1 - if wave > 0.55: - idx = 3 - elif wave > 0.0: - idx = 2 - elif wave < -0.55: - idx = 0 - _px(img, x, y, tones[idx]) - for i in range(36): - var wx := 24 + (_hash2d(i, 11, 131) % 176) - var wy := 20 + (_hash2d(i, 12, 131) % 152) - _line(img, wx - 5, wy, wx + 5, wy, Color("#7dd3fc")) - _line(img, wx - 3, wy - 1, wx + 3, wy - 1, Color("#e0f2fe")) + var t := float(y) / float(h - 1) + img.fill_rect(Rect2i(0, y, w, 1), top_c.lerp(bot_c, t)) + for i in range(28): + var wx := 26.0 + float(_hash2d(i, 11, 131) % 204) + var wy := 22.0 + float(_hash2d(i, 12, 131) % 180) + _aa_line(img, wx - 12.0, wy, wx, wy - 3.0, Color("#7dd3fc"), 2.4) + _aa_line(img, wx, wy - 3.0, wx + 12.0, wy, Color("#7dd3fc"), 2.4) + _aa_line(img, wx - 7.0, wy - 2.0, wx + 7.0, wy - 2.0, Color("#e0f2fe"), 1.6) return img static func _draw_tree_tropical() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 32, 10, 3, C_SHADOW) - _rect(img, 16, 20, 4, 12, Color("#6e3f23")) - _rect(img, 17, 20, 2, 12, Color("#8c5330")) - _line(img, 15, 27, 12, 32, Color("#593119")) - _line(img, 20, 27, 23, 32, Color("#593119")) - _circle(img, 18, 15, 11, Color("#0f5e36")) - _circle(img, 12, 17, 7, Color("#147543")) - _circle(img, 24, 17, 7, Color("#11663b")) - _circle(img, 18, 12, 9, Color("#1d9454")) - _circle(img, 15, 9, 6, Color("#34b86a")) - _circle(img, 22, 13, 5, Color("#27a65c")) - _line(img, 10, 20, 10, 26, Color("#1d8049")) - _line(img, 25, 20, 25, 25, Color("#1d8049")) - _px(img, 13, 10, Color("#ff6b8b")) - _px(img, 14, 10, Color("#ffd166")) - _px(img, 22, 14, Color("#ff6b8b")) - _px(img, 21, 14, Color("#ffd166")) - _px(img, 17, 17, Color("#ff9f1c")) - _apply_soft_outline(img, Color("#172617")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 34.0, 12.0, 0.38) + + # Gnarled broadleaf trunk & buttress roots with dark ink outline + var trunk := PackedVector2Array([ + Vector2(56, 56), Vector2(72, 56), Vector2(75, 96), + Vector2(86, 112), Vector2(68, 112), Vector2(64, 102), + Vector2(60, 112), Vector2(42, 112), Vector2(53, 96) + ]) + _poly_outlined(img, trunk, Color("#8c5230"), Color("#593118"), C_OUTLINE, 2.4) + _aa_line(img, 62.0, 62.0, 60.0, 102.0, Color("#a86840"), 2.2) + _aa_line(img, 54.0, 62.0, 40.0, 44.0, C_OUTLINE, 3.5) + _aa_line(img, 74.0, 62.0, 88.0, 46.0, C_OUTLINE, 3.5) + + # Multi-layered illustrated canopy puffs matching land.jpeg + var back_puffs := [ + Vector3(38, 56, 24), Vector3(90, 56, 24), Vector3(64, 34, 28) + ] + for bp in back_puffs: + _aa_circle_outlined(img, bp.x, bp.y, bp.z, Color("#1f6e32"), C_OUTLINE, 2.6) + + var mid_puffs := [ + Vector3(46, 46, 24), Vector3(82, 48, 23), Vector3(64, 54, 26) + ] + for mp in mid_puffs: + _aa_circle_outlined(img, mp.x, mp.y, mp.z, Color("#329642"), C_OUTLINE, 2.4) + + # Inner sunlit foliage clusters + _aa_circle(img, 44.0, 42.0, 17.0, Color("#52bf58")) + _aa_circle(img, 78.0, 44.0, 16.0, Color("#45ad4b")) + _aa_circle(img, 62.0, 30.0, 19.0, Color("#62cf63")) + _aa_circle(img, 62.0, 48.0, 18.0, Color("#4eb854")) + _aa_ellipse(img, 58.0, 24.0, 12.0, 7.0, Color("#86e87e")) + + # Crisp comic leaf contour arcs inside the crown + _aa_line(img, 48.0, 52.0, 58.0, 46.0, C_OUTLINE, 2.0) + _aa_line(img, 70.0, 50.0, 80.0, 56.0, C_OUTLINE, 2.0) return img static func _draw_tree_tropical_2() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 32, 9, 3, C_SHADOW) - for y in range(13, 32): - var curve_x := 18 + int(sin(float(y - 13) * 0.18) * 2.5) - _rect(img, curve_x - 2, y, 4, 1, Color("#7c4728") if y % 2 == 0 else Color("#995b36")) - _circle(img, 16, 13, 2, Color("#5c3317")) - _circle(img, 20, 13, 2, Color("#6b3e26")) - var frond_tips := [ - Vector2i(5, 14), Vector2i(8, 7), Vector2i(18, 3), - Vector2i(28, 7), Vector2i(31, 14), Vector2i(10, 20), Vector2i(26, 20) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 30.0, 10.0, 0.36) + + # Curved segmented palm trunk matching land.jpeg / land2.jpeg + var segs := 10 + for i in range(segs): + var t0 := float(i) / float(segs) + var t1 := float(i + 1) / float(segs) + var y0 := lerpf(110.0, 46.0, t0) + var y1 := lerpf(110.0, 46.0, t1) + var x0 := 62.0 + sin(t0 * 2.6) * 8.0 + var x1 := 62.0 + sin(t1 * 2.6) * 8.0 + var w0 := lerpf(7.5, 4.2, t0) + var w1 := lerpf(7.5, 4.2, t1) + var quad := PackedVector2Array([ + Vector2(x0 - w0, y0), Vector2(x1 - w1, y1), + Vector2(x1 + w1, y1), Vector2(x0 + w0, y0) + ]) + _poly_outlined(img, quad, Color("#9e6238"), Color("#704222"), C_OUTLINE, 2.2) + + var top_pt := Vector2(62.0 + sin(2.6) * 8.0, 46.0) + # Coconuts at crown base + _aa_circle_outlined(img, top_pt.x - 5.0, top_pt.y + 3.0, 5.5, Color("#5c3418"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, top_pt.x + 5.0, top_pt.y + 4.0, 5.5, Color("#6e4020"), C_OUTLINE, 2.0) + + # Sweeping illustrated palm fronds + var fronds := [ + [Vector2(-44, 14), Vector2(-24, -14)], + [Vector2(-36, -12), Vector2(-16, -24)], + [Vector2(0, -26), Vector2(-4, -16)], + [Vector2(38, -10), Vector2(18, -22)], + [Vector2(44, 16), Vector2(24, -12)], + [Vector2(-26, 26), Vector2(-14, 4)], + [Vector2(28, 26), Vector2(14, 4)] ] - for tip in frond_tips: - _line(img, 18, 11, tip.x, tip.y, Color("#16703d")) - _line(img, 18, 10, tip.x, tip.y - 1, Color("#2eb865")) - _line(img, 18, 12, tip.x, tip.y + 1, Color("#1e8f4e")) - _circle(img, 18, 10, 4, Color("#4ad67d")) - _apply_soft_outline(img, Color("#142618")) + for f in fronds: + var tip: Vector2 = top_pt + (f[0] as Vector2) + var ctrl: Vector2 = top_pt + (f[1] as Vector2) + var leaf := PackedVector2Array([ + top_pt, + ctrl + Vector2(0, -7), + tip, + ctrl + Vector2(0, 5) + ]) + _poly_outlined(img, leaf, Color("#65cf58"), Color("#278234"), C_OUTLINE, 2.2) + _aa_line(img, top_pt.x, top_pt.y, tip.x, tip.y, Color("#1c5e24"), 1.8) return img static func _draw_tree_savanna() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 32, 10, 3, C_SHADOW) - _line(img, 17, 31, 18, 18, Color("#6b3e26")) - _line(img, 18, 31, 19, 18, Color("#8a5232")) - _line(img, 18, 19, 11, 13, Color("#6b3e26")) - _line(img, 19, 19, 26, 14, Color("#6b3e26")) - _ellipse(img, 18, 12, 14, 4, Color("#6e7821")) - _ellipse(img, 18, 11, 12, 3, Color("#8fa329")) - _ellipse(img, 16, 9, 9, 2, Color("#b8c93a")) - _ellipse(img, 25, 14, 7, 2, Color("#7d8c24")) - _ellipse(img, 10, 14, 7, 2, Color("#9eb32d")) - _apply_soft_outline(img, Color("#292111")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 38.0, 12.0, 0.35) + + # Gnarled branching umbrella acacia trunk matching land.jpeg bottom-right + var trunk := PackedVector2Array([ + Vector2(58, 66), Vector2(68, 66), Vector2(72, 110), Vector2(54, 110) + ]) + _poly_outlined(img, trunk, Color("#8a542e"), Color("#593318"), C_OUTLINE, 2.4) + _aa_line(img, 62.0, 74.0, 36.0, 48.0, C_OUTLINE, 6.8) + _aa_line(img, 62.0, 74.0, 36.0, 48.0, Color("#824c28"), 3.6) + + _aa_line(img, 64.0, 72.0, 92.0, 50.0, C_OUTLINE, 6.8) + _aa_line(img, 64.0, 72.0, 92.0, 50.0, Color("#824c28"), 3.6) + + _aa_line(img, 63.0, 68.0, 62.0, 42.0, C_OUTLINE, 6.4) + _aa_line(img, 63.0, 68.0, 62.0, 42.0, Color("#824c28"), 3.4) + + # Flat-topped umbrella canopy layers + _aa_ellipse_outlined(img, 36.0, 48.0, 26.0, 10.0, Color("#4f8728"), C_OUTLINE, 2.4) + _aa_ellipse_outlined(img, 92.0, 50.0, 26.0, 10.0, Color("#467a22"), C_OUTLINE, 2.4) + _aa_ellipse_outlined(img, 64.0, 38.0, 46.0, 14.0, Color("#68a832"), C_OUTLINE, 2.6) + _aa_ellipse(img, 62.0, 35.0, 38.0, 9.0, Color("#88c940")) + _aa_ellipse(img, 56.0, 32.0, 24.0, 5.5, Color("#a5e052")) return img static func _draw_tree_savanna_2() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 32, 10, 3, C_SHADOW) - for y in range(15, 32): - var hw := 3 + int(float(y - 15) * 0.22) - _rect(img, 18 - hw, y, hw * 2 + 1, 1, Color("#8a5a3c") if y % 3 != 0 else Color("#70472e")) - _rect(img, 18 - hw + 1, y, hw, 1, Color("#a36d4b")) - _ellipse(img, 12, 13, 6, 4, Color("#7d8c24")) - _ellipse(img, 24, 13, 6, 4, Color("#6e7821")) - _ellipse(img, 18, 10, 8, 5, Color("#9eb32d")) - _ellipse(img, 17, 9, 5, 3, Color("#c5d945")) - _apply_soft_outline(img, Color("#292111")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 34.0, 11.0, 0.36) + + var trunk := PackedVector2Array([ + Vector2(52, 54), Vector2(76, 54), Vector2(84, 110), Vector2(44, 110) + ]) + _poly_outlined(img, trunk, Color("#9c633e"), Color("#694025"), C_OUTLINE, 2.5) + _aa_line(img, 60.0, 60.0, 56.0, 106.0, Color("#b57850"), 2.2) + + _aa_ellipse_outlined(img, 42.0, 50.0, 24.0, 15.0, Color("#568f2c"), C_OUTLINE, 2.4) + _aa_ellipse_outlined(img, 86.0, 50.0, 24.0, 15.0, Color("#4b8025"), C_OUTLINE, 2.4) + _aa_ellipse_outlined(img, 64.0, 38.0, 32.0, 18.0, Color("#73b538"), C_OUTLINE, 2.5) + _aa_ellipse(img, 60.0, 33.0, 22.0, 11.0, Color("#96d648")) return img static func _draw_tree_tundra() -> Image: - var img := _new_canvas(32, 34) - _ellipse(img, 16, 30, 8, 2, C_SHADOW) - _rect(img, 15, 24, 3, 6, Color("#4f311c")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 30.0, 10.0, 0.36) + + var trunk := PackedVector2Array([ + Vector2(58, 86), Vector2(70, 86), Vector2(72, 112), Vector2(56, 112) + ]) + _poly_outlined(img, trunk, Color("#6b4226"), Color("#422714"), C_OUTLINE, 2.4) + + # 3 tiered conifer cones with snow caps for tier in range(3): - var top_y: int = 4 + tier * 6 - var half_w: int = 5 + tier * 3 - for row in range(8): - var w_row: int = int(float(row) / 7.0 * float(half_w)) - var py: int = top_y + row - for dx in range(-w_row, w_row + 1): - var col := Color("#1e4d3b") if dx > 0 else Color("#2b6951") - if row <= 2 or (row == 3 and (dx + tier) % 2 == 0): - col = Color("#eef6fa") if dx <= 0 else Color("#cbe0ec") - _px(img, 16 + dx, py, col) - _apply_soft_outline(img, Color("#162429")) + var idx := 2 - tier + var top_y := 14.0 + float(idx) * 22.0 + var bot_y := top_y + 34.0 + var hw := 22.0 + float(idx) * 11.0 + var cone := PackedVector2Array([ + Vector2(64.0, top_y), + Vector2(64.0 + hw, bot_y), + Vector2(64.0, bot_y + 5.0), + Vector2(64.0 - hw, bot_y) + ]) + _poly_outlined(img, cone, Color("#2f7556"), Color("#1b4734"), C_OUTLINE, 2.4) + var snow := PackedVector2Array([ + Vector2(64.0, top_y), + Vector2(64.0 + hw * 0.58, top_y + 18.0), + Vector2(64.0, top_y + 21.0), + Vector2(64.0 - hw * 0.58, top_y + 18.0) + ]) + _poly_outlined(img, snow, Color("#ffffff"), Color("#cfe4f2"), C_OUTLINE, 2.0) return img static func _draw_tree_tundra_2() -> Image: - var img := _new_canvas(32, 34) - _ellipse(img, 16, 30, 9, 2, C_SHADOW) - for idx in range(2): - var cx := 11 if idx == 0 else 21 - var top_y := 6 if idx == 0 else 10 - _rect(img, cx - 1, 25, 2, 5, Color("#4f311c")) - for row in range(19 - idx * 3): - var hw := 1 + int(float(row) * 0.34) - var py := top_y + row - for dx in range(-hw, hw + 1): - var col := Color("#245945") if dx <= 0 else Color("#194233") - if row % 5 <= 1: - col = Color("#f0f7fa") if dx <= 0 else Color("#c8dfeb") - _px(img, cx + dx, py, col) - _apply_soft_outline(img, Color("#162429")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 34.0, 11.0, 0.36) + for tree_i in range(2): + var cx := 46.0 if tree_i == 0 else 82.0 + var scale_f := 1.0 if tree_i == 0 else 0.84 + var base_y := 110.0 + var trunk := PackedVector2Array([ + Vector2(cx - 4.0, base_y - 18.0), Vector2(cx + 4.0, base_y - 18.0), + Vector2(cx + 5.0, base_y), Vector2(cx - 5.0, base_y) + ]) + _poly_outlined(img, trunk, Color("#6b4226"), Color("#422714"), C_OUTLINE, 2.2) + for tier in range(3): + var idx := 2 - tier + var top_y := (20.0 if tree_i == 0 else 32.0) + float(idx) * 19.0 * scale_f + var bot_y := top_y + 28.0 * scale_f + var hw := (16.0 + float(idx) * 8.0) * scale_f + var cone := PackedVector2Array([ + Vector2(cx, top_y), + Vector2(cx + hw, bot_y), + Vector2(cx - hw, bot_y) + ]) + _poly_outlined(img, cone, Color("#2c6e50"), Color("#194230"), C_OUTLINE, 2.2) + var snow := PackedVector2Array([ + Vector2(cx, top_y), + Vector2(cx + hw * 0.52, top_y + 13.0 * scale_f), + Vector2(cx - hw * 0.52, top_y + 13.0 * scale_f) + ]) + _poly_outlined(img, snow, Color("#f8fafc"), Color("#cbd5e1"), C_OUTLINE, 1.8) return img static func _draw_bush_arid() -> Image: - var img := _new_canvas(26, 26) - _ellipse(img, 13, 22, 7, 2, C_SHADOW) - _rect(img, 11, 7, 4, 15, Color("#3a7d44")) - _rect(img, 12, 7, 2, 15, Color("#52a35d")) - _rect(img, 7, 11, 4, 3, Color("#3a7d44")) - _rect(img, 7, 8, 3, 4, Color("#52a35d")) - _rect(img, 15, 13, 4, 3, Color("#3a7d44")) - _rect(img, 16, 10, 3, 4, Color("#3a7d44")) - _px(img, 12, 6, Color("#ff5964")) - _px(img, 13, 6, Color("#ffe74c")) - _ellipse(img, 18, 20, 4, 2, Color("#9c662c")) - _px(img, 17, 19, Color("#c48b3f")) - _apply_soft_outline(img, Color("#241b10")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 108.0, 28.0, 9.0, 0.34) + # Desert boulder + saguaro cactus with flowers + var rock := PackedVector2Array([ + Vector2(70, 92), Vector2(88, 86), Vector2(98, 98), Vector2(92, 108), Vector2(66, 108) + ]) + _poly_outlined(img, rock, Color("#c98a4b"), Color("#8c5828"), C_OUTLINE, 2.2) + + var main_stem := PackedVector2Array([ + Vector2(52, 34), Vector2(66, 34), Vector2(68, 108), Vector2(50, 108) + ]) + _poly_outlined(img, main_stem, Color("#58b05c"), Color("#2f7534"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 59.0, 34.0, 7.0, Color("#58b05c"), C_OUTLINE, 2.4) + + var left_arm := PackedVector2Array([ + Vector2(34, 48), Vector2(44, 48), Vector2(44, 64), Vector2(52, 64), + Vector2(52, 74), Vector2(34, 74) + ]) + _poly_outlined(img, left_arm, Color("#58b05c"), Color("#2f7534"), C_OUTLINE, 2.2) + + var right_arm := PackedVector2Array([ + Vector2(66, 56), Vector2(82, 56), Vector2(82, 42), Vector2(92, 42), + Vector2(92, 68), Vector2(66, 68) + ]) + _poly_outlined(img, right_arm, Color("#4ea352"), Color("#2b6930"), C_OUTLINE, 2.2) + _aa_circle_outlined(img, 59.0, 26.0, 4.5, Color("#ff5964"), C_OUTLINE, 1.8) return img static func _draw_bush_berry() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 20, 8, 2, C_SHADOW) - _circle(img, 9, 15, 5, Color("#156134")) - _circle(img, 15, 15, 5, Color("#12522b")) - _circle(img, 12, 12, 6, Color("#1f8749")) - _circle(img, 11, 11, 4, Color("#34b364")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 108.0, 32.0, 10.0, 0.35) + _aa_circle_outlined(img, 46.0, 86.0, 20.0, Color("#247834"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 82.0, 86.0, 20.0, Color("#1f692d"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 64.0, 74.0, 24.0, Color("#379c47"), C_OUTLINE, 2.5) + _aa_circle(img, 58.0, 68.0, 16.0, Color("#58c462")) + var berries := [ - Vector2i(8, 13), Vector2i(14, 11), Vector2i(11, 15), - Vector2i(16, 15), Vector2i(10, 10) + Vector2(44, 82), Vector2(56, 70), Vector2(74, 72), + Vector2(82, 86), Vector2(64, 88), Vector2(50, 92), Vector2(72, 92) ] for b in berries: - _px(img, b.x, b.y, Color("#ef4444")) - _px(img, b.x + 1, b.y, Color("#dc2626")) - _px(img, b.x, b.y - 1, Color("#fca5a5")) - _apply_soft_outline(img, Color("#142617")) + _aa_circle_outlined(img, b.x, b.y, 4.2, Color("#ef4444"), C_OUTLINE, 1.6) + _aa_circle(img, b.x - 1.0, b.y - 1.0, 1.5, Color("#fecaca")) return img static func _draw_fallen_log() -> Image: - var img := _new_canvas(28, 20) - _ellipse(img, 14, 16, 11, 2, C_SHADOW) - _rect(img, 5, 10, 18, 6, Color("#6e3f23")) - _rect(img, 5, 11, 18, 2, Color("#8c5330")) - _ellipse(img, 5, 13, 2, 3, Color("#d4a373")) - _px(img, 5, 13, Color("#3d2314")) - _ellipse(img, 23, 13, 2, 3, Color("#4a2c18")) - _rect(img, 8, 9, 9, 2, Color("#2d9447")) - _rect(img, 10, 8, 5, 1, Color("#4cc470")) - _px(img, 19, 8, Color("#fefae0")) - _rect(img, 18, 6, 3, 2, Color("#ef4444")) - _px(img, 19, 6, Color("#ffffff")) - _apply_soft_outline(img, C_OUTLINE) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 104.0, 38.0, 10.0, 0.34) + var body := PackedVector2Array([ + Vector2(24, 78), Vector2(102, 72), Vector2(104, 96), Vector2(26, 102) + ]) + _poly_outlined(img, body, Color("#8c5330"), Color("#593118"), C_OUTLINE, 2.4) + _aa_ellipse_outlined(img, 25.0, 90.0, 7.0, 12.0, Color("#deb887"), C_OUTLINE, 2.2) + _aa_ellipse_outlined(img, 103.0, 84.0, 6.0, 12.0, Color("#4a2c18"), C_OUTLINE, 2.2) + # Moss patch on top of log + var moss := PackedVector2Array([ + Vector2(42, 75), Vector2(82, 72), Vector2(86, 82), Vector2(40, 84) + ]) + _poly_outlined(img, moss, Color("#5ce07a"), Color("#2d9447"), C_OUTLINE, 2.0) return img static func _draw_mossy_rock() -> Image: - var img := _new_canvas(26, 22) - _ellipse(img, 13, 18, 9, 2, C_SHADOW) - _ellipse(img, 13, 14, 8, 5, Color("#64686e")) - _ellipse(img, 12, 13, 6, 4, Color("#848991")) - _ellipse(img, 11, 11, 5, 2, Color("#34a853")) - _ellipse(img, 12, 10, 3, 1, Color("#5ce07a")) - _px(img, 16, 14, Color("#ffd166")) - _apply_soft_outline(img, Color("#22252a")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 34.0, 10.0, 0.35) + var left_facet := PackedVector2Array([ + Vector2(30, 98), Vector2(46, 66), Vector2(68, 60), Vector2(66, 104) + ]) + var right_facet := PackedVector2Array([ + Vector2(68, 60), Vector2(92, 70), Vector2(98, 98), Vector2(66, 104) + ]) + _poly_outlined(img, left_facet, Color("#949ba3"), Color("#6c727a"), C_OUTLINE, 2.4) + _poly_outlined(img, right_facet, Color("#6c727a"), Color("#494e54"), C_OUTLINE, 2.4) + var moss := PackedVector2Array([ + Vector2(44, 66), Vector2(68, 58), Vector2(84, 68), Vector2(64, 76) + ]) + _poly_outlined(img, moss, Color("#65cf58"), Color("#2b8230"), C_OUTLINE, 2.0) return img static func _draw_tall_grass() -> Image: - var img := _new_canvas(20, 20) - var blades := [4, 7, 10, 13, 16] - for i in range(blades.size()): - var bx: int = blades[i] - var top_y: int = 5 + (i % 2) * 2 - var bend: int = -1 if i % 2 == 0 else 1 - _line(img, bx, 17, bx + bend, top_y, Color("#248243")) - _line(img, bx + 1, 17, bx + bend + 1, top_y + 1, Color("#46bf6b")) - _px(img, bx + bend, top_y - 1, Color("#eab308")) - _apply_soft_outline(img, Color("#152e1c")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 22.0, 7.0, 0.28) + var blades := [ + [Vector2(46, 104), Vector2(32, 56)], + [Vector2(54, 106), Vector2(44, 46)], + [Vector2(62, 106), Vector2(60, 40)], + [Vector2(68, 106), Vector2(74, 44)], + [Vector2(76, 104), Vector2(90, 54)], + [Vector2(82, 104), Vector2(98, 68)] + ] + for b in blades: + var base: Vector2 = b[0] + var tip: Vector2 = b[1] + var blade_poly := PackedVector2Array([ + base + Vector2(-3.5, 0), + tip, + base + Vector2(3.5, 0) + ]) + _poly_outlined(img, blade_poly, Color("#a3e635"), Color("#3f8226"), C_OUTLINE, 2.0) return img static func _draw_dry_scrub() -> Image: - var img := _new_canvas(22, 20) - _ellipse(img, 11, 17, 6, 2, C_SHADOW) - _line(img, 11, 16, 6, 9, Color("#784b27")) - _line(img, 11, 16, 16, 8, Color("#784b27")) - _line(img, 11, 16, 11, 7, Color("#996236")) - _line(img, 9, 13, 5, 12, Color("#996236")) - _line(img, 13, 12, 17, 11, Color("#996236")) - _apply_soft_outline(img, Color("#2b1b0e")) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 26.0, 8.0, 0.30) + # Illustrated golden-brown desert scrub tuft matching land.jpeg bottom-right + var spikes := [ + Vector2(30, 68), Vector2(40, 52), Vector2(54, 44), + Vector2(66, 40), Vector2(78, 46), Vector2(90, 56), Vector2(98, 72) + ] + for sp in spikes: + var blade := PackedVector2Array([ + Vector2(60, 104), + sp, + Vector2(68, 104) + ]) + _poly_outlined(img, blade, Color("#d4a359"), Color("#855826"), C_OUTLINE, 2.0) return img static func _draw_rock_mountain() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 27, 10, 3, C_SHADOW) - for y in range(8, 27): - var progress := float(y - 8) / 18.0 - var half_w := int(2.0 + progress * 9.0) - for dx in range(-half_w, half_w + 1): - var col := Color("#6c6965") - if dx < -2: - col = Color("#8c8781") - elif dx > 2: - col = Color("#4f4c49") - if y < 14 + (abs(dx) % 2): - col = Color("#f4f9fc") if dx <= 0 else Color("#cfe0eb") - _px(img, 16 + dx, y, col) - _apply_soft_outline(img, Color("#242220")) + var img := _new_canvas(160, 160) + _drop_shadow(img, 80.0, 142.0, 64.0, 18.0, 0.40) + + # Back-left secondary peak + var bl_left := PackedVector2Array([ + Vector2(14, 134), Vector2(48, 44), Vector2(56, 134) + ]) + var bl_right := PackedVector2Array([ + Vector2(48, 44), Vector2(86, 134), Vector2(56, 134) + ]) + _poly_outlined(img, bl_left, Color("#a39589"), Color("#7d7065"), C_OUTLINE, 2.5) + _poly_outlined(img, bl_right, Color("#6e6258"), Color("#4f463e"), C_OUTLINE, 2.5) + var bl_snow := PackedVector2Array([ + Vector2(48, 44), Vector2(64, 82), Vector2(52, 76), Vector2(42, 86), Vector2(33, 80) + ]) + _poly_outlined(img, bl_snow, Color("#ffffff"), Color("#d0e0ec"), C_OUTLINE, 2.2) + + # Back-right secondary peak + var br_left := PackedVector2Array([ + Vector2(76, 134), Vector2(114, 50), Vector2(118, 134) + ]) + var br_right := PackedVector2Array([ + Vector2(114, 50), Vector2(148, 134), Vector2(118, 134) + ]) + _poly_outlined(img, br_left, Color("#9e9084"), Color("#786b60"), C_OUTLINE, 2.5) + _poly_outlined(img, br_right, Color("#665a50"), Color("#473e37"), C_OUTLINE, 2.5) + var br_snow := PackedVector2Array([ + Vector2(114, 50), Vector2(129, 86), Vector2(118, 80), Vector2(108, 88), Vector2(99, 82) + ]) + _poly_outlined(img, br_snow, Color("#ffffff"), Color("#cbdbe8"), C_OUTLINE, 2.2) + + # Tall central main peak with faceted ridge matching land.jpeg top-right + var m_left := PackedVector2Array([ + Vector2(34, 140), Vector2(80, 18), Vector2(84, 142) + ]) + var m_right := PackedVector2Array([ + Vector2(80, 18), Vector2(130, 140), Vector2(84, 142) + ]) + _poly_outlined(img, m_left, Color("#b0a194"), Color("#85776b"), C_OUTLINE, 2.8) + _poly_outlined(img, m_right, Color("#73665b"), Color("#524840"), C_OUTLINE, 2.8) + + # Snow cap on main peak (sunlit left facet + cool blue-grey right facet) + var snow_l := PackedVector2Array([ + Vector2(80, 18), Vector2(82, 76), Vector2(68, 68), Vector2(58, 78) + ]) + var snow_r := PackedVector2Array([ + Vector2(80, 18), Vector2(102, 74), Vector2(92, 66), Vector2(82, 76) + ]) + _poly_outlined(img, snow_l, Color("#ffffff"), Color("#eef6fc"), C_OUTLINE, 2.4) + _poly_outlined(img, snow_r, Color("#dce8f2"), Color("#b8ccdc"), C_OUTLINE, 2.4) + + # Foreground foothills + var fh_l := PackedVector2Array([ + Vector2(48, 142), Vector2(72, 92), Vector2(96, 142) + ]) + _poly_outlined(img, fh_l, Color("#96887c"), Color("#63584e"), C_OUTLINE, 2.4) return img static func _draw_coastal_reeds() -> Image: - var img := _new_canvas(24, 24) - var stalks := [7, 10, 13, 16] + var img := _new_canvas(128, 128) + _aa_ellipse(img, 64.0, 108.0, 32.0, 9.0, Color(0.85, 0.97, 1.0, 0.65)) + var stalks := [42.0, 52.0, 62.0, 72.0, 82.0] for idx in range(stalks.size()): - var sx: int = stalks[idx] - var top_y: int = 6 + (idx % 2) * 2 - _line(img, sx, 20, sx, top_y, Color("#3d8c40")) - _rect(img, sx - 1, top_y + 1, 2, 4, Color("#6b3e26")) - _rect(img, 15, 18, 4, 3, Color("#ff9ebb")) - _px(img, 16, 19, Color("#ffd6e0")) - _apply_soft_outline(img, Color("#1d2b1f")) + var sx: float = stalks[idx] + var top_y: float = 30.0 + float(idx % 2) * 10.0 + var bend: float = -4.0 if idx % 2 == 0 else 4.0 + _aa_line(img, sx, 106.0, sx + bend, top_y, C_OUTLINE, 4.2) + _aa_line(img, sx, 106.0, sx + bend, top_y, Color("#4da843"), 2.4) + # Cattail brown cylinder head + var head := PackedVector2Array([ + Vector2(sx + bend - 3.5, top_y + 8.0), + Vector2(sx + bend + 3.5, top_y + 8.0), + Vector2(sx + bend + 3.5, top_y + 24.0), + Vector2(sx + bend - 3.5, top_y + 24.0) + ]) + _poly_outlined(img, head, Color("#7c4724"), Color("#4f2b14"), C_OUTLINE, 2.0) return img static func _draw_tree_stump() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 19, 7, 2, C_SHADOW) - _rect(img, 7, 13, 10, 6, Color("#6e3f23")) - _rect(img, 8, 13, 4, 6, Color("#854f2d")) - _px(img, 6, 18, Color("#593119")) - _px(img, 17, 18, Color("#593119")) - _ellipse(img, 12, 13, 5, 2, Color("#deb887")) - _ellipse(img, 12, 13, 3, 1, Color("#b88655")) - _px(img, 12, 13, Color("#6e3f23")) - _apply_soft_outline(img, C_OUTLINE) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 30.0, 9.0, 0.35) + var body := PackedVector2Array([ + Vector2(44, 72), Vector2(84, 72), Vector2(92, 106), Vector2(36, 106) + ]) + _poly_outlined(img, body, Color("#854f2d"), Color("#593119"), C_OUTLINE, 2.4) + _aa_ellipse_outlined(img, 64.0, 72.0, 20.0, 9.0, Color("#deb887"), C_OUTLINE, 2.4) + _aa_ellipse(img, 64.0, 72.0, 12.0, 5.0, Color("#b88655")) return img static func _draw_soil_crack() -> Image: - var img := _new_canvas(24, 24) - _line(img, 4, 12, 11, 14, Color("#3b1f0e")) - _line(img, 11, 14, 19, 10, Color("#3b1f0e")) - _line(img, 11, 14, 14, 20, Color("#3b1f0e")) - _line(img, 8, 13, 5, 18, Color("#593119")) - _line(img, 4, 11, 11, 13, Color("#8a5432")) + var img := _new_canvas(128, 128) + _aa_line(img, 24.0, 68.0, 62.0, 76.0, Color("#3b1f0e"), 3.2) + _aa_line(img, 62.0, 76.0, 102.0, 58.0, Color("#3b1f0e"), 3.0) + _aa_line(img, 62.0, 76.0, 76.0, 104.0, Color("#3b1f0e"), 2.6) + _aa_line(img, 44.0, 72.0, 30.0, 96.0, Color("#593119"), 2.2) return img static func _draw_wildflowers() -> Image: - var img := _new_canvas(24, 24) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 26.0, 8.0, 0.25) var spots := [ - {"x": 7, "y": 14, "c": Color("#ff6b8b")}, - {"x": 13, "y": 11, "c": Color("#ffd166")}, - {"x": 17, "y": 15, "c": Color("#70d6ff")}, - {"x": 10, "y": 18, "c": Color("#ff9f1c")} + {"x": 42.0, "y": 78.0, "c": Color("#ff6b8b")}, + {"x": 64.0, "y": 66.0, "c": Color("#ffd166")}, + {"x": 84.0, "y": 82.0, "c": Color("#70d6ff")}, + {"x": 54.0, "y": 92.0, "c": Color("#ff9f1c")} ] for sp in spots: - var sx: int = sp["x"] - var sy: int = sp["y"] + var sx: float = sp["x"] + var sy: float = sp["y"] var fl_col: Color = sp["c"] - _line(img, sx, sy + 1, sx, sy + 4, Color("#2d8a4e")) - _px(img, sx - 1, sy, fl_col) - _px(img, sx + 1, sy, fl_col) - _px(img, sx, sy - 1, fl_col) - _px(img, sx, sy + 1, fl_col) - _px(img, sx, sy, Color("#fff3b0")) + _aa_line(img, sx, sy + 4.0, sx, sy + 20.0, Color("#2d8a4e"), 2.6) + _aa_circle_outlined(img, sx, sy, 8.0, fl_col, C_OUTLINE, 2.0) + _aa_circle(img, sx, sy, 3.5, Color("#fff3b0")) return img diff --git a/src/world/sprites/ui_icon_sprites.gd b/src/world/sprites/ui_icon_sprites.gd new file mode 100644 index 0000000..92b30cc --- /dev/null +++ b/src/world/sprites/ui_icon_sprites.gd @@ -0,0 +1,249 @@ +class_name EvoUiIconSprites +extends EvoPixelCanvas + + +static func draw_ui_icon_sprite(sprite_name: String) -> Image: + match sprite_name: + "icon_war": + return _draw_icon_war() + "icon_raid": + return _draw_icon_raid() + "icon_pact": + return _draw_icon_pact() + "icon_betrayal": + return _draw_icon_betrayal() + "icon_defense": + return _draw_icon_defense() + "icon_merge": + return _draw_icon_merge() + "icon_tribute": + return _draw_icon_tribute() + "icon_siege": + return _draw_icon_siege() + _: + return _draw_ui_icon(sprite_name) + + +static func _draw_icon_war() -> Image: + var img := _new_canvas(64, 64) + _aa_line(img, 10.0, 54.0, 54.0, 10.0, Color("#cbd5e1"), 5.0) + _aa_line(img, 54.0, 54.0, 10.0, 10.0, Color("#cbd5e1"), 5.0) + var shield := PackedVector2Array([ + Vector2(20, 16), Vector2(44, 16), Vector2(44, 38), Vector2(32, 52), Vector2(20, 38) + ]) + _poly_outlined(img, shield, Color("#ef4444"), Color("#991b1b"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 5.0, Color("#fde047")) + return img + + +static func _draw_icon_raid() -> Image: + var img := _new_canvas(64, 64) + _aa_ellipse_outlined(img, 28.0, 40.0, 16.0, 14.0, Color("#d97706"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 46.0, 24.0, 9.0, Color("#ef4444"), C_OUTLINE, 2.2) + _aa_circle(img, 46.0, 24.0, 4.5, Color("#fde047")) + return img + + +static func _draw_icon_pact() -> Image: + var img := _new_canvas(64, 64) + _aa_circle_outlined(img, 32.0, 32.0, 22.0, Color("#fbbf24"), C_OUTLINE, 2.5) + _aa_circle_outlined(img, 32.0, 32.0, 15.0, Color("#10b981"), C_OUTLINE, 2.0) + _aa_circle(img, 32.0, 32.0, 6.0, Color("#fef08a")) + return img + + +static func _draw_icon_betrayal() -> Image: + var img := _new_canvas(64, 64) + _aa_circle_outlined(img, 22.0, 36.0, 12.0, Color("#d97706"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 42.0, 36.0, 12.0, Color("#d97706"), C_OUTLINE, 2.4) + _aa_line(img, 48.0, 10.0, 18.0, 54.0, Color("#e11d48"), 5.5) + return img + + +static func _draw_icon_defense() -> Image: + var img := _new_canvas(64, 64) + var shield := PackedVector2Array([ + Vector2(14, 14), Vector2(50, 14), Vector2(48, 40), Vector2(32, 54), Vector2(16, 40) + ]) + _poly_outlined(img, shield, Color("#cbd5e1"), Color("#64748b"), C_OUTLINE, 2.5) + _aa_circle_outlined(img, 32.0, 32.0, 8.0, Color("#fbbf24"), C_OUTLINE, 2.0) + return img + + +static func _draw_icon_merge() -> Image: + var img := _new_canvas(64, 64) + _aa_circle_outlined(img, 22.0, 36.0, 12.0, Color("#10b981"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 42.0, 36.0, 12.0, Color("#38bdf8"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 32.0, 22.0, 10.0, Color("#fbbf24"), C_OUTLINE, 2.4) + return img + + +static func _draw_icon_tribute() -> Image: + var img := _new_canvas(64, 64) + var chest := PackedVector2Array([ + Vector2(14, 28), Vector2(50, 28), Vector2(50, 50), Vector2(14, 50) + ]) + _poly_outlined(img, chest, Color("#d97706"), Color("#78350f"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 32.0, 24.0, 10.0, Color("#fde047"), C_OUTLINE, 2.2) + return img + + +static func _draw_icon_siege() -> Image: + var img := _new_canvas(64, 64) + var tower := PackedVector2Array([ + Vector2(20, 16), Vector2(44, 16), Vector2(46, 52), Vector2(18, 52) + ]) + _poly_outlined(img, tower, Color("#94a3b8"), Color("#475569"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 18.0, 44.0, 8.0, Color("#ef4444"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 46.0, 42.0, 8.0, Color("#f97316"), C_OUTLINE, 2.0) + return img + + +static func _draw_ui_icon(icon_name: String) -> Image: + var img := _new_canvas(64, 64) + match icon_name: + "tool_inspect": + _aa_circle_outlined(img, 28.0, 28.0, 16.0, Color("#bae6fd"), C_OUTLINE, 2.5) + _aa_line(img, 40.0, 40.0, 54.0, 54.0, Color("#8c5230"), 6.0) + "tool_biome": + _aa_ellipse_outlined(img, 32.0, 42.0, 16.0, 12.0, Color("#d4a373"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 32.0, 22.0, 12.0, Color("#22c55e"), C_OUTLINE, 2.4) + "tool_altitude": + var mtn := PackedVector2Array([Vector2(12, 50), Vector2(32, 14), Vector2(52, 50)]) + _poly_outlined(img, mtn, Color("#cbd5e1"), Color("#64748b"), C_OUTLINE, 2.5) + "tool_fertility": + _aa_circle_outlined(img, 32.0, 32.0, 18.0, Color("#fde047"), C_OUTLINE, 2.5) + "tool_sealevel": + _aa_ellipse_outlined(img, 32.0, 36.0, 20.0, 12.0, Color("#38bdf8"), C_OUTLINE, 2.5) + "season_wet": + _aa_circle_outlined(img, 32.0, 32.0, 16.0, Color("#fbbf24"), C_OUTLINE, 2.5) + "season_dry": + _aa_circle_outlined(img, 32.0, 32.0, 16.0, Color("#7dd3fc"), C_OUTLINE, 2.5) + "status_thriving": + _draw_heart(img, Color("#10b981"), Color("#6ee7b7")) + "status_stable": + _draw_heart(img, Color("#0ea5e9"), Color("#7dd3fc")) + "status_struggling": + _draw_heart(img, Color("#f59e0b"), Color("#fde047")) + "status_starving": + _draw_heart(img, Color("#ef4444"), Color("#fca5a5")) + "icon_forage": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#16a34a"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#22c55e")) + _aa_line(img, 24.0, 44.0, 38.0, 20.0, Color("#fef08a"), 3.6) + _aa_ellipse_outlined(img, 27.0, 30.0, 6.0, 4.0, Color("#fde047"), C_OUTLINE, 1.6) + _aa_ellipse_outlined(img, 37.0, 34.0, 6.0, 4.0, Color("#fde047"), C_OUTLINE, 1.6) + _aa_ellipse_outlined(img, 35.0, 23.0, 6.0, 4.0, Color("#fef08a"), C_OUTLINE, 1.6) + "icon_overexploit": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#ea580c"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#f97316")) + _aa_line(img, 22.0, 42.0, 40.0, 22.0, Color("#78350f"), 4.2) + var pick_head := PackedVector2Array([ + Vector2(24, 20), Vector2(38, 18), Vector2(46, 30), Vector2(36, 26) + ]) + _poly_outlined(img, pick_head, Color("#f8fafc"), Color("#cbd5e1"), C_OUTLINE, 1.8) + "icon_migrate": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#0284c7"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#38bdf8")) + _aa_line(img, 18.0, 32.0, 38.0, 32.0, Color("#f8fafc"), 4.5) + var arrow_head := PackedVector2Array([ + Vector2(34, 21), Vector2(47, 32), Vector2(34, 43) + ]) + _poly_outlined(img, arrow_head, Color("#ffffff"), Color("#e0f2fe"), C_OUTLINE, 2.0) + "icon_cooperate": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#059669"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#10b981")) + _aa_ellipse_outlined(img, 26.0, 33.0, 9.0, 6.0, Color("#fde047"), C_OUTLINE, 2.0) + _aa_ellipse_outlined(img, 38.0, 33.0, 9.0, 6.0, Color("#fef3c7"), C_OUTLINE, 2.0) + _aa_circle(img, 32.0, 33.0, 4.5, Color("#fbbf24")) + "icon_exchange": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#0d9488"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#14b8a6")) + _aa_line(img, 20.0, 25.0, 42.0, 25.0, Color("#fef08a"), 3.6) + _aa_line(img, 44.0, 39.0, 22.0, 39.0, Color("#fef08a"), 3.6) + _aa_circle_outlined(img, 32.0, 32.0, 6.5, Color("#fbbf24"), C_OUTLINE, 1.8) + "icon_dispute": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#be123c"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#e11d48")) + _aa_line(img, 20.0, 42.0, 44.0, 20.0, Color("#f8fafc"), 4.0) + _aa_line(img, 44.0, 42.0, 20.0, 20.0, Color("#f8fafc"), 4.0) + _aa_circle(img, 32.0, 31.0, 4.5, Color("#fde047")) + "icon_innovate": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#7e22ce"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 15.0, Color("#a855f7")) + var star_v := PackedVector2Array([ + Vector2(32, 16), Vector2(36, 28), Vector2(48, 32), + Vector2(36, 36), Vector2(32, 48), Vector2(28, 36), + Vector2(16, 32), Vector2(28, 28) + ]) + _poly_outlined(img, star_v, Color("#fef08a"), Color("#fbbf24"), C_OUTLINE, 1.8) + "icon_coop_link": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#10b981"), C_OUTLINE, 2.5) + _aa_circle_outlined(img, 26.0, 32.0, 7.5, Color("#fef3c7"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 38.0, 32.0, 7.5, Color("#fde047"), C_OUTLINE, 2.0) + "icon_conflict_swords": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#ef4444"), C_OUTLINE, 2.5) + _aa_line(img, 20.0, 42.0, 44.0, 20.0, Color("#f8fafc"), 4.2) + _aa_line(img, 44.0, 42.0, 20.0, 20.0, Color("#f8fafc"), 4.2) + "icon_broken_link": + _aa_circle_outlined(img, 32.0, 32.0, 20.0, Color("#d97706"), C_OUTLINE, 2.5) + _aa_line(img, 20.0, 24.0, 44.0, 40.0, Color("#fef3c7"), 4.0) + _aa_line(img, 34.0, 18.0, 30.0, 46.0, Color("#ef4444"), 3.6) + "icon_epidemic": + _aa_circle_outlined(img, 32.0, 32.0, 21.0, Color("#65a30d"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 16.0, Color("#84cc16")) + _aa_circle_outlined(img, 32.0, 24.0, 6.5, Color("#ecfccb"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 25.0, 37.0, 6.5, Color("#ecfccb"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 39.0, 37.0, 6.5, Color("#ecfccb"), C_OUTLINE, 2.0) + _aa_circle(img, 32.0, 32.0, 4.0, C_OUTLINE) + "icon_famine": + _aa_circle_outlined(img, 32.0, 32.0, 21.0, Color("#b45309"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 16.0, Color("#d97706")) + var bowl := PackedVector2Array([ + Vector2(18, 32), Vector2(46, 32), Vector2(40, 44), Vector2(24, 44) + ]) + _poly_outlined(img, bowl, Color("#fef3c7"), Color("#fde68a"), C_OUTLINE, 2.0) + _aa_line(img, 24.0, 22.0, 40.0, 28.0, Color("#ef4444"), 3.4) + "icon_immune": + var shield := PackedVector2Array([ + Vector2(16, 14), Vector2(48, 14), Vector2(46, 38), Vector2(32, 52), Vector2(18, 38) + ]) + _poly_outlined(img, shield, Color("#22d3ee"), Color("#06b6d4"), C_OUTLINE, 2.5) + _aa_line(img, 32.0, 22.0, 32.0, 40.0, Color("#ffffff"), 5.0) + _aa_line(img, 23.0, 31.0, 41.0, 31.0, Color("#ffffff"), 5.0) + "icon_vendetta": + _aa_circle_outlined(img, 32.0, 32.0, 21.0, Color("#991b1b"), C_OUTLINE, 2.5) + var drop := PackedVector2Array([ + Vector2(32, 14), Vector2(44, 34), Vector2(38, 48), Vector2(26, 48), Vector2(20, 34) + ]) + _poly_outlined(img, drop, Color("#ef4444"), Color("#dc2626"), C_OUTLINE, 2.2) + _aa_line(img, 22.0, 18.0, 42.0, 46.0, Color("#e2e8f0"), 3.6) + "icon_casualties": + _aa_circle_outlined(img, 32.0, 32.0, 21.0, Color("#dc2626"), C_OUTLINE, 2.5) + _aa_circle_outlined(img, 32.0, 28.0, 10.0, Color("#f8fafc"), C_OUTLINE, 2.0) + _rect_outlined(img, 26, 34, 12, 9, Color("#f8fafc"), C_OUTLINE) + _aa_circle(img, 28.5, 28.0, 2.5, C_OUTLINE) + _aa_circle(img, 35.5, 28.0, 2.5, C_OUTLINE) + "icon_loot": + _aa_circle_outlined(img, 32.0, 32.0, 21.0, Color("#ca8a04"), C_OUTLINE, 2.5) + _aa_circle_outlined(img, 32.0, 32.0, 15.0, Color("#eab308"), C_OUTLINE, 2.0) + _aa_circle(img, 32.0, 32.0, 9.0, Color("#fef08a")) + "icon_migrants": + _aa_circle_outlined(img, 32.0, 32.0, 21.0, Color("#1d4ed8"), C_OUTLINE, 2.5) + _aa_circle(img, 32.0, 32.0, 16.0, Color("#3b82f6")) + _aa_ellipse_outlined(img, 25.0, 34.0, 7.0, 6.0, Color("#fef3c7"), C_OUTLINE, 1.8) + var m_arrow := PackedVector2Array([ + Vector2(33, 22), Vector2(46, 32), Vector2(33, 42) + ]) + _poly_outlined(img, m_arrow, Color("#ffffff"), Color("#bae6fd"), C_OUTLINE, 2.0) + _: + _aa_circle_outlined(img, 32.0, 32.0, 16.0, Color("#eab308"), C_OUTLINE, 2.4) + return img + + +static func _draw_heart(img: Image, main_col: Color, hi_col: Color) -> void: + var pts := PackedVector2Array([ + Vector2(32, 52), Vector2(14, 32), Vector2(18, 18), + Vector2(32, 24), Vector2(46, 18), Vector2(50, 32) + ]) + _poly_outlined(img, pts, hi_col, main_col, C_OUTLINE, 2.4) diff --git a/src/world/sprites/ui_icon_sprites.gd.uid b/src/world/sprites/ui_icon_sprites.gd.uid new file mode 100644 index 0000000..dbdd6fe --- /dev/null +++ b/src/world/sprites/ui_icon_sprites.gd.uid @@ -0,0 +1 @@ +uid://3b7635nle6r7 diff --git a/src/world/sprites/village_ui_sprites.gd b/src/world/sprites/village_ui_sprites.gd index fa5282d..a39488b 100644 --- a/src/world/sprites/village_ui_sprites.gd +++ b/src/world/sprites/village_ui_sprites.gd @@ -4,64 +4,58 @@ extends EvoPixelCanvas static func draw_village_or_ui_sprite(sprite_name: String) -> Image: match sprite_name: - "tier1_tent": - return _draw_tier1_tent() - "tier1_campfire": - return _draw_tier1_campfire(0) + "hut_thatch": + return _draw_hut_thatch() + "campfire": + return _draw_campfire(0) "tier1_campfire_2": - return _draw_tier1_campfire(1) - "tier2_cottage": - return _draw_tier2_cottage() - "tier2_barn": - return _draw_tier2_barn() - "tier3_townhall": - return _draw_tier3_townhall() - "tier3_house": - return _draw_tier3_house() - "tier4_citadel": - return _draw_tier4_citadel() - "tier4_windmill": - return _draw_tier4_windmill() - "tier4_tower": - return _draw_tier4_tower() - "tier5_metropolis": - return _draw_tier5_metropolis() - "prop_unity_monument", "act_merge": + return _draw_campfire(1) + "house_clay": + return _draw_house_clay() + "granary": + return _draw_granary() + "temple_totem": + return _draw_temple_totem() + "house_stone": + return _draw_house_stone() + "citadel_keep": + return _draw_citadel_keep() + "windmill": + return _draw_windmill() + "watchtower": + return _draw_watchtower() + "cathedral": + return _draw_cathedral() + "prop_unity_monument": return _draw_prop_unity_monument() - "prop_defense_palisade": - return _draw_prop_defense_palisade() - "prop_defense_wall": - return _draw_prop_defense_wall() - "prop_defense_fortress", "act_fortify": - return _draw_prop_defense_fortress() - "prop_tribute_chest", "act_tribute": + "palisade_wall": + return _draw_palisade_wall() + "stone_wall": + return _draw_stone_wall() + "prop_tribute_chest": return _draw_prop_tribute_chest() - "prop_siege_engine", "act_siege": + "siege_totem": return _draw_prop_siege_engine() - "act_forage": - return _draw_act_forage() + "crop_field", "act_forage": + return _draw_crop_field() + "pasture_pen": + return _draw_pasture_pen() "act_overexploit": return _draw_act_overexploit() - "act_migrate": - return _draw_act_migrate() - "act_cooperate": - return _draw_act_cooperate() + "covered_wagon", "act_migrate": + return _draw_covered_wagon() + "totem_coop", "act_cooperate": + return _draw_totem_pole(Color("#10b981"), Color("#fbbf24")) "act_exchange": return _draw_act_exchange() - "act_dispute": - return _draw_act_dispute() - "act_innovate": - return _draw_act_innovate() - "act_custom": - return _draw_act_custom() - "cultural_totem": - return _draw_cultural_totem() - "ruins": - return _draw_ruins() - "caravan_cart": - return _draw_caravan_cart() - "settler_wagon": - return _draw_settler_wagon() + "totem_aggro", "act_dispute": + return _draw_totem_pole(Color("#ef4444"), Color("#f97316")) + "totem_innov", "cultural_totem", "act_innovate", "act_custom": + return _draw_totem_pole(Color("#2a9d8f"), Color("#e9c46a")) + "ruins_stone": + return _draw_ruins_stone() + "prop_betrayal_ruin", "act_betrayal": + return _draw_prop_betrayal_ruin() "barricade_spikes": return _draw_barricade_spikes() "road_block_sign": @@ -74,26 +68,8 @@ static func draw_village_or_ui_sprite(sprite_name: String) -> Image: return _draw_prop_raid_loot() "prop_pact_totem", "act_pact": return _draw_prop_pact_totem() - "prop_betrayal_ruin", "act_betrayal": - return _draw_prop_betrayal_ruin() "effect_battle_sparks": return _draw_effect_battle_sparks() - "icon_war": - return _draw_icon_war() - "icon_raid": - return _draw_icon_raid() - "icon_pact": - return _draw_icon_pact() - "icon_betrayal": - return _draw_icon_betrayal() - "icon_defense": - return _draw_icon_defense() - "icon_merge": - return _draw_icon_merge() - "icon_tribute": - return _draw_icon_tribute() - "icon_siege": - return _draw_icon_siege() "villager_idle": return draw_villager(0, Color("#e07a5f")) "villager_walk1": @@ -101,986 +77,809 @@ static func draw_village_or_ui_sprite(sprite_name: String) -> Image: "villager_walk2": return draw_villager(2, Color("#e07a5f")) _: - return _draw_ui_icon(sprite_name) - - -static func _draw_tier1_tent() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 27, 11, 3, C_SHADOW) - for y in range(10, 27): - var half_w := int(float(y - 9) * 0.65) - for dx in range(-half_w, half_w + 1): - var col := Color("#e8d1a7") if dx < 0 else Color("#cbb082") - if abs(dx) <= 2 and y >= 18: - col = Color("#3d2314") - elif y == 14 or y == 20: - col = col.darkened(0.08) - _px(img, 16 + dx, y, col) - _rect(img, 15, 8, 2, 3, Color("#6b3e26")) - _apply_soft_outline(img) - return img - - -static func _draw_tier1_campfire(frame: int) -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 18, 6, 2, C_SHADOW) - _rect(img, 6, 16, 12, 3, Color("#6b3e26")) - _rect(img, 8, 15, 8, 4, Color("#8c5230")) - _circle(img, 7, 18, 2, Color("#78716c")) - _circle(img, 17, 18, 2, Color("#78716c")) - _circle(img, 12, 19, 2, Color("#57534e")) - var flame_shift := -1 if frame == 0 else 1 - _ellipse(img, 12 + flame_shift, 13, 4, 4, Color("#ef4444")) - _ellipse(img, 12, 13, 3, 3, Color("#f97316")) - _ellipse(img, 12 - flame_shift, 14, 2, 2, Color("#fde047")) - _px(img, 12 + flame_shift, 7 + frame, Color("#fb923c")) - _apply_soft_outline(img) - return img - - -static func _draw_tier2_cottage() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 31, 13, 3, C_SHADOW) - _rect(img, 24, 7, 4, 9, Color("#78716c")) - _rect(img, 23, 6, 6, 2, Color("#57534e")) - _rect(img, 8, 17, 20, 13, Color("#b07d4f")) - for y in range(18, 30, 3): - _line(img, 8, y, 27, y, Color("#8c5830")) - for y in range(8, 19): - var half_w := 3 + int(float(y - 8) * 1.1) - for dx in range(-half_w, half_w + 1): - var col := Color("#a8382b") if (y + dx) % 2 == 0 else Color("#8f2b20") - if dx < 0: - col = col.lightened(0.10) - _px(img, 18 + dx, y, col) - _rect(img, 13, 22, 4, 8, Color("#5c3317")) - _px(img, 16, 26, Color("#fbbf24")) - _rect(img, 20, 21, 5, 4, Color("#fde047")) - _line(img, 22, 21, 22, 24, Color("#6b3e26")) - _line(img, 20, 22, 24, 22, Color("#6b3e26")) - _apply_soft_outline(img) - return img - - -static func _draw_tier2_barn() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 31, 13, 3, C_SHADOW) - _rect(img, 8, 16, 20, 14, Color("#b9382c")) - _rect(img, 9, 16, 9, 14, Color("#cf4638")) - for y in range(7, 17): - var half_w := 4 + int(float(y - 7) * 1.05) - for dx in range(-half_w, half_w + 1): - _px(img, 18 + dx, y, Color("#6b3e26") if (y % 2 == 0) else Color("#542f1b")) - _rect_outlined(img, 13, 21, 10, 9, Color("#9e2a20"), Color("#f5ebe0")) - _line(img, 13, 21, 22, 29, Color("#f5ebe0")) - _line(img, 22, 21, 13, 29, Color("#f5ebe0")) - _rect(img, 16, 13, 4, 4, Color("#3b2112")) - _rect(img, 16, 15, 4, 2, Color("#eab308")) - _apply_soft_outline(img) - return img - - -static func _draw_tier3_townhall() -> Image: - var img := _new_canvas(44, 44) - _ellipse(img, 22, 38, 17, 4, C_SHADOW) - _rect(img, 7, 20, 30, 17, Color("#e6ccb2")) - _rect(img, 7, 32, 30, 5, Color("#78716c")) - for px_i in [7, 15, 28, 36]: - _rect(img, px_i, 20, 2, 17, Color("#6b3e26")) - for y in range(11, 21): - var half_w := 6 + int(float(y - 11) * 1.3) - for dx in range(-half_w, half_w + 1): - var col := Color("#3b6e8c") if (dx + y) % 2 == 0 else Color("#2d5873") - if dx < 0: - col = col.lightened(0.12) - _px(img, 22 + dx, y, col) - _rect(img, 17, 6, 10, 15, Color("#d4a373")) - for y in range(2, 7): - var hw := 1 + (y - 2) * 1 - _rect(img, 22 - hw, y, hw * 2 + 1, 1, Color("#b5651d")) - _circle(img, 22, 13, 3, Color("#fefae0")) - _px(img, 22, 13, C_OUTLINE) - _px(img, 22, 11, C_OUTLINE) - _px(img, 24, 13, C_OUTLINE) - _rect(img, 19, 28, 6, 9, Color("#5c3317")) - _rect(img, 10, 24, 4, 5, Color("#fde047")) - _rect(img, 30, 24, 4, 5, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_tier3_house() -> Image: - var img := _new_canvas(36, 36) - _ellipse(img, 18, 31, 13, 3, C_SHADOW) - _rect(img, 8, 17, 20, 13, Color("#f2d0a9")) - _rect(img, 8, 27, 20, 3, Color("#8d99ae")) - for y in range(8, 18): - var hw := 3 + int(float(y - 8) * 1.1) - for dx in range(-hw, hw + 1): - _px(img, 18 + dx, y, Color("#c85a32") if dx < 0 else Color("#a64421")) - _rect(img, 11, 22, 4, 8, Color("#6b3e26")) - _rect(img, 19, 20, 6, 5, Color("#fde047")) - _rect(img, 18, 25, 8, 2, Color("#6b3e26")) - _px(img, 19, 24, Color("#ff5964")) - _px(img, 21, 24, Color("#2ec4b6")) - _px(img, 24, 24, Color("#ffbe0b")) - _apply_soft_outline(img) - return img - - -static func _draw_tier4_citadel() -> Image: - var img := _new_canvas(48, 48) - _ellipse(img, 24, 42, 19, 4, C_SHADOW) - _rect(img, 6, 24, 36, 17, Color("#9ca3af")) - _rect(img, 8, 24, 16, 17, Color("#d1d5db")) - _rect(img, 5, 16, 8, 25, Color("#9ca3af")) - _rect(img, 35, 16, 8, 25, Color("#6b7280")) - _rect(img, 14, 12, 20, 16, Color("#e5e7eb")) - for y in range(4, 13): - var hw := 2 + int(float(y - 4) * 1.15) - _rect(img, 24 - hw, y, hw * 2 + 1, 1, Color("#1d4ed8") if y % 2 == 0 else Color("#2563eb")) - for cx in [9, 39]: - for y in range(10, 17): - var hw := 1 + int(float(y - 10) * 0.65) - _rect(img, cx - hw, y, hw * 2 + 1, 1, Color("#b45309")) - _circle(img, 24, 18, 3, Color("#fbbf24")) - _rect(img, 20, 31, 8, 10, Color("#451a03")) - _rect(img, 15, 19, 3, 4, Color("#fde047")) - _rect(img, 30, 19, 3, 4, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_tier4_windmill() -> Image: - var img := _new_canvas(40, 40) - _ellipse(img, 20, 35, 11, 3, C_SHADOW) - for y in range(12, 35): - var hw := 4 + int(float(y - 12) * 0.22) - _rect(img, 20 - hw, y, hw * 2, 1, Color("#f3e5ab") if y % 3 != 0 else Color("#e0ce8b")) - _ellipse(img, 20, 12, 5, 3, Color("#8c3b2b")) - _line(img, 7, 4, 33, 24, Color("#6b3e26")) - _line(img, 33, 4, 7, 24, Color("#6b3e26")) - _line(img, 8, 4, 33, 23, Color("#fefae0")) - _line(img, 32, 4, 7, 23, Color("#fefae0")) - _circle(img, 20, 14, 2, Color("#d97706")) - _rect(img, 18, 28, 4, 7, Color("#5c3317")) - _apply_soft_outline(img) - return img - - -static func _draw_tier4_tower() -> Image: - var img := _new_canvas(32, 36) - _ellipse(img, 16, 32, 9, 2, C_SHADOW) - _rect(img, 10, 11, 12, 21, Color("#94a3b8")) - _rect(img, 11, 11, 5, 21, Color("#cbd5e1")) - for bx in [9, 13, 17, 21]: - _rect(img, bx, 8, 2, 4, Color("#cbd5e1")) - _rect(img, 14, 16, 4, 5, Color("#fde047")) - _rect(img, 14, 26, 4, 6, Color("#451a03")) - _apply_soft_outline(img) - return img - - -static func _draw_tier5_metropolis() -> Image: - var img := _new_canvas(52, 52) - _ellipse(img, 26, 46, 22, 4, C_SHADOW) - # Stepped marble terrace - _rect(img, 4, 41, 44, 5, Color("#94a3b8")) - _rect(img, 6, 37, 40, 4, Color("#cbd5e1")) - # Central gilded bronze dome - _circle(img, 26, 20, 10, Color("#f59e0b")) - _circle(img, 25, 19, 8, Color("#fbbf24")) - _ellipse(img, 24, 16, 4, 3, Color("#fef08a")) - _rect(img, 25, 6, 2, 5, Color("#fbbf24")) - _circle(img, 26, 6, 2, Color("#fef08a")) - # Central palace body & white colonnade pillars - _rect(img, 15, 20, 22, 17, Color("#e2e8f0")) - _rect(img, 14, 19, 24, 2, Color("#fbbf24")) - for px_i in [16, 20, 31, 35]: - _rect(img, px_i, 21, 2, 16, Color("#f8fafc")) - # Twin flanking stone towers - _rect(img, 6, 14, 10, 23, Color("#cbd5e1")) - _rect(img, 11, 14, 5, 23, Color("#94a3b8")) - _rect(img, 37, 14, 10, 23, Color("#cbd5e1")) - _rect(img, 42, 14, 5, 23, Color("#94a3b8")) - # Royal blue spires & gold finials - for cx in [11, 42]: - for y in range(6, 15): - var hw := 1 + int(float(y - 6) * 0.55) - _rect(img, cx - hw, y, hw * 2 + 1, 1, Color("#1e3a8a") if y % 2 == 0 else Color("#2563eb")) - _rect(img, cx, 3, 2, 3, Color("#fbbf24")) - _rect(img, cx - 1, 18, 3, 5, Color("#fde047")) - # Arched double portal - _circle(img, 26, 29, 4, Color("#451a03")) - _rect(img, 22, 29, 8, 8, Color("#451a03")) - _line(img, 26, 26, 26, 36, Color("#fbbf24")) - _apply_soft_outline(img) - return img - - + return EvoUiIconSprites.draw_ui_icon_sprite(sprite_name) + + +# 1. Tier 1 Yurt / Hide Tent (land3.jpeg top-left) +static func _draw_hut_thatch() -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 46.0, 16.0, 0.38) + + # Cylindrical yurt body with felt walls + var body := PackedVector2Array([ + Vector2(24, 62), Vector2(104, 62), + Vector2(104, 96), Vector2(86, 108), + Vector2(42, 108), Vector2(24, 96) + ]) + _poly_outlined(img, body, Color("#ede2cc"), Color("#c9b898"), C_OUTLINE, 2.5) + + # Ornate decorative textile bands (upper & lower) + var upper_band := PackedVector2Array([ + Vector2(24, 62), Vector2(104, 62), Vector2(104, 71), Vector2(24, 71) + ]) + _poly_outlined(img, upper_band, Color("#e0c3fc"), Color("#c084fc"), C_OUTLINE, 2.0) + var lower_band := PackedVector2Array([ + Vector2(24, 92), Vector2(104, 92), Vector2(86, 108), Vector2(42, 108) + ]) + _poly_outlined(img, lower_band, Color("#f3d5b5"), Color("#c084fc"), C_OUTLINE, 2.0) + + # Conical yurt dome roof + var dome := PackedVector2Array([ + Vector2(22, 62), Vector2(46, 36), Vector2(82, 36), Vector2(106, 62), Vector2(64, 70) + ]) + _poly_outlined(img, dome, Color("#f5ebd6"), Color("#d4c3a3"), C_OUTLINE, 2.5) + + # Wooden crown wheel (toono) at apex + radiating ribs + _aa_ellipse_outlined(img, 64.0, 36.0, 16.0, 7.0, Color("#7c4d28"), C_OUTLINE, 2.4) + _aa_line(img, 50.0, 36.0, 78.0, 36.0, C_OUTLINE, 2.0) + _aa_line(img, 64.0, 30.0, 64.0, 42.0, C_OUTLINE, 2.0) + for rx_v in [32.0, 48.0, 64.0, 80.0, 96.0]: + var rx: float = float(rx_v) + _aa_line(img, 64.0, 42.0, rx, 64.0, Color("#8c5a32"), 1.8) + + # Front-left wooden doorway with rolled curtain flap + var door := PackedVector2Array([ + Vector2(38, 68), Vector2(56, 72), Vector2(56, 106), Vector2(38, 102) + ]) + _poly_outlined(img, door, Color("#5c381e"), Color("#361f0e"), C_OUTLINE, 2.4) + var flap := PackedVector2Array([ + Vector2(36, 66), Vector2(58, 70), Vector2(58, 77), Vector2(36, 73) + ]) + _poly_outlined(img, flap, Color("#d97706"), Color("#92400e"), C_OUTLINE, 2.0) + return img + + +# 2. Tier 1 Blazing Campfire with Logs (land3.jpeg top-left) +static func _draw_campfire(frame: int) -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 104.0, 36.0, 13.0, 0.36) + # Warm ground glow + _aa_ellipse(img, 64.0, 100.0, 42.0, 16.0, Color(0.98, 0.65, 0.15, 0.28)) + + # Radial firewood logs with cut ends + var logs := [ + [Vector2(30, 98), Vector2(64, 92)], + [Vector2(98, 98), Vector2(64, 92)], + [Vector2(38, 108), Vector2(64, 94)], + [Vector2(90, 108), Vector2(64, 94)], + [Vector2(64, 112), Vector2(64, 94)] + ] + for lg in logs: + var p0: Vector2 = lg[0] + var p1: Vector2 = lg[1] + _aa_line(img, p0.x, p0.y, p1.x, p1.y, C_OUTLINE, 13.0) + _aa_line(img, p0.x, p0.y, p1.x, p1.y, Color("#8c5228"), 9.0) + _aa_circle_outlined(img, p0.x, p0.y, 5.5, Color("#e6be8a"), C_OUTLINE, 2.0) + + var shift := -3.0 if frame == 0 else 3.0 + # Outer crimson/orange flame + var outer_flame := PackedVector2Array([ + Vector2(40, 94), Vector2(36, 68), Vector2(50, 52), + Vector2(64.0 + shift, 24.0), Vector2(78, 50), Vector2(92, 68), Vector2(88, 94) + ]) + _poly_outlined(img, outer_flame, Color("#f97316"), Color("#dc2626"), C_OUTLINE, 2.2) + + # Mid golden flame + var mid_flame := PackedVector2Array([ + Vector2(48, 94), Vector2(46, 70), Vector2(64.0 - shift * 0.6, 38.0), + Vector2(82, 70), Vector2(80, 94) + ]) + _poly_filled(img, mid_flame, Color("#fde047"), Color("#f59e0b")) + + # Inner white-hot core + var inner_flame := PackedVector2Array([ + Vector2(55, 94), Vector2(64.0, 58.0), Vector2(73, 94) + ]) + _poly_filled(img, inner_flame, Color("#ffffff"), Color("#fef08a")) + return img + + +# 3. Tier 2 Timber Log Cabin (land3.jpeg top-left second platform) +static func _draw_house_clay() -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 108.0, 48.0, 18.0, 0.38) + + # Isometric log walls (base front at 64, 110) + _draw_isometric_box( + img, 64.0, 110.0, 38.0, 36.0, 32.0, + Color("#784b25"), Color("#a66e3e"), Color("#7c4e29"), 2.4 + ) + # Horizontal log plank lines on left and right walls + for h_off_v in [8.0, 16.0, 24.0]: + var y_base: float = 110.0 - float(h_off_v) + _aa_line(img, 26.0, y_base - 19.0, 64.0, y_base, C_OUTLINE, 1.6) + _aa_line(img, 64.0, y_base, 100.0, y_base - 18.0, C_OUTLINE, 1.6) + + # Stone chimney on roof back-left + _draw_isometric_box( + img, 54.0, 48.0, 8.0, 8.0, 18.0, + Color("#cbd5e1"), Color("#94a3b8"), Color("#64748b"), 2.0 + ) + + # Pitched wooden plank roof + _draw_pitched_roof( + img, 64.0, 78.0, 42.0, 40.0, 26.0, + Color("#8c5a34"), Color("#6e4425"), Color("#996238"), 2.5 + ) + + # Door on front-left wall & warm window on front-right wall + var door := PackedVector2Array([ + Vector2(42, 99), Vector2(52, 104), Vector2(52, 86), Vector2(42, 81) + ]) + _poly_outlined(img, door, Color("#5c361a"), Color("#3d2310"), C_OUTLINE, 2.0) + + var win := PackedVector2Array([ + Vector2(74, 96), Vector2(88, 89), Vector2(88, 77), Vector2(74, 84) + ]) + _poly_outlined(img, win, Color("#fef08a"), Color("#f59e0b"), C_OUTLINE, 2.0) + return img + + +# 4. Tier 2 Thatched Wooden Barn / Granary with Barrels (land3.jpeg top-left) +static func _draw_granary() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 122.0, 56.0, 20.0, 0.38) + + # Isometric timber barn box + _draw_isometric_box( + img, 72.0, 124.0, 44.0, 44.0, 36.0, + Color("#7c4e29"), Color("#9e6738"), Color("#754824"), 2.5 + ) + # Double barn doors on front-left wall + var doors := PackedVector2Array([ + Vector2(38, 107), Vector2(60, 118), Vector2(60, 92), Vector2(38, 81) + ]) + _poly_outlined(img, doors, Color("#85522a"), Color("#593518"), C_OUTLINE, 2.2) + _aa_line(img, 49.0, 86.5, 49.0, 112.5, C_OUTLINE, 2.0) + + # Golden straw thatched pitched roof + _draw_pitched_roof( + img, 72.0, 88.0, 48.0, 48.0, 30.0, + Color("#f5d061"), Color("#dfb034"), Color("#8c582e"), 2.6 + ) + # Wooden barrels resting against front-right wall + _aa_ellipse_outlined(img, 86.0, 112.0, 8.0, 10.0, Color("#6e4220"), C_OUTLINE, 2.0) + _aa_ellipse_outlined(img, 98.0, 106.0, 8.0, 10.0, Color("#5c3618"), C_OUTLINE, 2.0) + return img + + +# 5. Tier 3 Half-Timbered Medieval House (land3.jpeg top-center) +static func _draw_house_stone() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 124.0, 54.0, 19.0, 0.40) + + # Lower stone & plaster story + upper overhang jetty story + _draw_isometric_box( + img, 72.0, 126.0, 42.0, 40.0, 46.0, + Color("#d6c5ab"), Color("#f2e6d4"), Color("#d4c3aa"), 2.5 + ) + # Half-timber beams along left & right walls + _aa_line(img, 30.0, 82.0, 72.0, 103.0, Color("#4a2e19"), 3.2) + _aa_line(img, 72.0, 103.0, 112.0, 83.0, Color("#4a2e19"), 3.2) + for vx_v in [44.0, 58.0]: + var vx: float = float(vx_v) + var vy: float = 126.0 - (72.0 - vx) * 0.5 + _aa_line(img, vx, vy, vx, vy - 46.0, Color("#4a2e19"), 2.8) + for vx_v in [86.0, 100.0]: + var vx: float = float(vx_v) + var vy: float = 126.0 - (vx - 72.0) * 0.5 + _aa_line(img, vx, vy, vx, vy - 46.0, Color("#4a2e19"), 2.8) + + # Steep tiled pitched roof + _draw_pitched_roof( + img, 72.0, 80.0, 46.0, 44.0, 32.0, + Color("#ab784e"), Color("#8c5d38"), Color("#ebe0ce"), 2.6 + ) + # Illuminated leaded windows + var win_l := PackedVector2Array([ + Vector2(46, 94), Vector2(56, 99), Vector2(56, 87), Vector2(46, 82) + ]) + _poly_outlined(img, win_l, Color("#fde047"), Color("#d97706"), C_OUTLINE, 2.0) + var win_r := PackedVector2Array([ + Vector2(86, 94), Vector2(98, 88), Vector2(98, 76), Vector2(86, 82) + ]) + _poly_outlined(img, win_r, Color("#fde047"), Color("#d97706"), C_OUTLINE, 2.0) + return img + + +# 6. Tier 3 Bell-Tower Church / Temple (land3.jpeg top-center right) +static func _draw_temple_totem() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 124.0, 54.0, 19.0, 0.40) + + # Rear bell tower shaft & clock + _draw_isometric_box( + img, 88.0, 96.0, 22.0, 22.0, 56.0, + Color("#d6c5ab"), Color("#f0e4d0"), Color("#cfc0a8"), 2.4 + ) + var spire := PackedVector2Array([ + Vector2(66, 29), Vector2(88, 12), Vector2(110, 29), Vector2(88, 40) + ]) + _poly_outlined(img, spire, Color("#9e6238"), Color("#6e4222"), C_OUTLINE, 2.4) + _aa_line(img, 88.0, 4.0, 88.0, 14.0, C_OUTLINE, 2.6) + _aa_circle_outlined(img, 77.0, 52.0, 6.0, Color("#fefae0"), C_OUTLINE, 2.0) + + # Main church nave body & pitched roof + _draw_isometric_box( + img, 64.0, 126.0, 36.0, 42.0, 34.0, + Color("#d6c5ab"), Color("#f2e6d4"), Color("#d4c3aa"), 2.5 + ) + _draw_pitched_roof( + img, 64.0, 92.0, 40.0, 44.0, 26.0, + Color("#b57647"), Color("#945c34"), Color("#ebe0ce"), 2.5 + ) + # Portal doors on front-left gable + var portal := PackedVector2Array([ + Vector2(40, 114), Vector2(52, 120), Vector2(52, 98), Vector2(40, 92) + ]) + _poly_outlined(img, portal, Color("#7c4d28"), Color("#4f2f16"), C_OUTLINE, 2.2) + return img + + +# 7. Tier 4 Four-Turret Crenellated Castle Keep (land3.jpeg middle-left) +static func _draw_citadel_keep() -> Image: + var img := _new_canvas(160, 160) + _drop_shadow(img, 80.0, 138.0, 66.0, 24.0, 0.42) + + # Back corner tower + _draw_isometric_box( + img, 80.0, 92.0, 20.0, 20.0, 52.0, + Color("#d4d0c8"), Color("#bdb8b0"), Color("#8f8a82"), 2.2 + ) + # Left & Right corner towers (drawn behind front wall) + _draw_isometric_box( + img, 36.0, 114.0, 20.0, 20.0, 54.0, + Color("#d4d0c8"), Color("#c4bfb6"), Color("#948f86"), 2.4 + ) + _draw_isometric_box( + img, 124.0, 114.0, 20.0, 20.0, 54.0, + Color("#d4d0c8"), Color("#bab5ac"), Color("#87827a"), 2.4 + ) + # Main curtain walls box + _draw_isometric_box( + img, 80.0, 136.0, 48.0, 48.0, 42.0, + Color("#9e9990"), Color("#c7c2b8"), Color("#969188"), 2.5 + ) + # Front corner tower + _draw_isometric_box( + img, 80.0, 142.0, 20.0, 20.0, 54.0, + Color("#dedad2"), Color("#ccc7be"), Color("#969188"), 2.5 + ) + # Arched iron portcullis gate on front-left curtain wall + var gate := PackedVector2Array([ + Vector2(46, 119), Vector2(62, 127), Vector2(62, 105), Vector2(54, 97), Vector2(46, 99) + ]) + _poly_outlined(img, gate, Color("#3b3530"), Color("#1f1b18"), C_OUTLINE, 2.4) + _aa_line(img, 51.0, 99.0, 51.0, 121.0, Color("#78716c"), 1.8) + _aa_line(img, 57.0, 101.0, 57.0, 124.0, Color("#78716c"), 1.8) + return img + + +# 8. Tier 3/4 Windmill with Four Canvas Sails (land3.jpeg center) +static func _draw_windmill() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 124.0, 38.0, 14.0, 0.38) + + # Tapering cylindrical/octagonal stone & timber tower + var tower := PackedVector2Array([ + Vector2(54, 54), Vector2(90, 54), Vector2(98, 118), + Vector2(72, 126), Vector2(46, 118) + ]) + _poly_outlined(img, tower, Color("#ebdcc4"), Color("#bfa98a"), C_OUTLINE, 2.5) + + # Conical wooden cap + var cap := PackedVector2Array([ + Vector2(50, 54), Vector2(72, 34), Vector2(94, 54), Vector2(72, 60) + ]) + _poly_outlined(img, cap, Color("#996236"), Color("#633d1e"), C_OUTLINE, 2.5) + + # Four angled wooden/canvas sails radiating from hub (64, 54) + var hub := Vector2(64.0, 54.0) + var dirs := [ + Vector2(-38, -38), Vector2(38, -38), Vector2(38, 38), Vector2(-38, 38) + ] + for d in dirs: + var tip: Vector2 = hub + d + var perp := Vector2(-d.y, d.x).normalized() * 9.0 + var blade := PackedVector2Array([ + hub + d * 0.18, + tip, + tip + perp, + hub + d * 0.18 + perp + ]) + _poly_outlined(img, blade, Color("#fbf7ee"), Color("#dfd3bc"), C_OUTLINE, 2.2) + _aa_line(img, hub.x, hub.y, tip.x, tip.y, Color("#5c3618"), 3.2) + _aa_circle_outlined(img, hub.x, hub.y, 5.5, Color("#784820"), C_OUTLINE, 2.2) + return img + + +# 9. Tier 3/4 Two-Tier Guard Watchtower (land3.jpeg center-left) +static func _draw_watchtower() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 124.0, 38.0, 14.0, 0.38) + + # Lower stone-and-timber shaft + _draw_isometric_box( + img, 72.0, 126.0, 26.0, 26.0, 48.0, + Color("#b8a68e"), Color("#d9cbb8"), Color("#b09e86"), 2.4 + ) + # Upper timber lookout gallery + _draw_isometric_box( + img, 72.0, 78.0, 30.0, 30.0, 22.0, + Color("#7c4e29"), Color("#9c6438"), Color("#704523"), 2.4 + ) + # Pyramidal shingled roof + var roof_l := PackedVector2Array([ + Vector2(42, 41), Vector2(72, 18), Vector2(72, 56) + ]) + var roof_r := PackedVector2Array([ + Vector2(72, 18), Vector2(102, 41), Vector2(72, 56) + ]) + _poly_outlined(img, roof_l, Color("#ab7042"), Color("#85532c"), C_OUTLINE, 2.4) + _poly_outlined(img, roof_r, Color("#85532c"), Color("#613b1e"), C_OUTLINE, 2.4) + return img + + +# 10. Tier 4 Grand Gothic Cathedral (land3.jpeg middle-right) +static func _draw_cathedral() -> Image: + var img := _new_canvas(160, 160) + _drop_shadow(img, 80.0, 140.0, 66.0, 24.0, 0.42) + + # Main stone nave & transept boxes + _draw_isometric_box( + img, 80.0, 136.0, 52.0, 52.0, 48.0, + Color("#b8b2a8"), Color("#e0d6c5"), Color("#b5aa99"), 2.5 + ) + # Steep gothic slate roof + _draw_pitched_roof( + img, 80.0, 88.0, 54.0, 54.0, 32.0, + Color("#7c858e"), Color("#576069"), Color("#dfd5c4"), 2.5 + ) + # Central crossing bell tower & tall gothic spire + _draw_isometric_box( + img, 80.0, 68.0, 22.0, 22.0, 32.0, + Color("#b8b2a8"), Color("#e0d6c5"), Color("#b5aa99"), 2.4 + ) + var spire_l := PackedVector2Array([ + Vector2(58, 25), Vector2(80, 4), Vector2(80, 36) + ]) + var spire_r := PackedVector2Array([ + Vector2(80, 4), Vector2(102, 25), Vector2(80, 36) + ]) + _poly_outlined(img, spire_l, Color("#7c858e"), Color("#5c646d"), C_OUTLINE, 2.4) + _poly_outlined(img, spire_r, Color("#5c646d"), Color("#3f464d"), C_OUTLINE, 2.4) + + # Glowing stained-glass lancet windows + for wx_v in [44.0, 60.0]: + var wx: float = float(wx_v) + var wy: float = 136.0 - (80.0 - wx) * 0.5 + var win := PackedVector2Array([ + Vector2(wx, wy - 12.0), Vector2(wx + 8.0, wy - 8.0), + Vector2(wx + 8.0, wy - 32.0), Vector2(wx + 4.0, wy - 38.0), Vector2(wx, wy - 34.0) + ]) + _poly_outlined(img, win, Color("#f59e0b"), Color("#ef4444"), C_OUTLINE, 2.0) + + # Sweeping stone front stairs + var stairs := PackedVector2Array([ + Vector2(68, 130), Vector2(92, 130), Vector2(84, 148), Vector2(64, 148) + ]) + _poly_outlined(img, stairs, Color("#cbd5e1"), Color("#94a3b8"), C_OUTLINE, 2.2) + return img + + +# 11. Megalithic Stone Circle / Unity Monument (land3.jpeg top-right) static func _draw_prop_unity_monument() -> Image: - var img := _new_canvas(28, 32) - _ellipse(img, 14, 28, 10, 3, C_SHADOW) - # Stepped stone plinth - _rect(img, 4, 25, 20, 4, Color("#94a3b8")) - _rect(img, 5, 23, 18, 2, Color("#cbd5e1")) - # Twin marble pillars - _rect(img, 6, 10, 5, 15, Color("#f8fafc")) - _rect(img, 9, 10, 2, 15, Color("#cbd5e1")) - _rect(img, 18, 10, 5, 15, Color("#f8fafc")) - _rect(img, 21, 10, 2, 15, Color("#cbd5e1")) - # Overarching stone lintel - _rect(img, 5, 7, 19, 5, Color("#cbd5e1")) - _rect(img, 6, 8, 17, 3, Color("#e2e8f0")) - # Central golden keystone & jade inlay - _rect(img, 12, 6, 5, 6, Color("#fbbf24")) - _rect(img, 13, 7, 3, 4, Color("#10b981")) - _px(img, 14, 8, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_prop_defense_palisade() -> Image: - var img := _new_canvas(32, 28) - _ellipse(img, 16, 24, 13, 3, C_SHADOW) - # 7 pointed vertical timber logs - var log_xs := [4, 8, 12, 16, 20, 24, 27] - for lx in log_xs: - _rect(img, lx, 9, 3, 15, Color("#78350f")) - _rect(img, lx, 9, 2, 15, Color("#92400e")) - _rect(img, lx, 7, 3, 2, Color("#fde68a")) - _px(img, lx + 1, 6, Color("#fde68a")) - # Horizontal binding beams - _rect(img, 4, 12, 26, 2, Color("#451a03")) - _rect(img, 4, 19, 26, 2, Color("#451a03")) - # Central arched timber gate - _circle(img, 16, 16, 4, Color("#271508")) - _rect(img, 12, 16, 8, 8, Color("#271508")) - _line(img, 16, 13, 16, 23, Color("#78350f")) - _apply_soft_outline(img) - return img - - -static func _draw_prop_defense_wall() -> Image: - var img := _new_canvas(36, 32) - _ellipse(img, 18, 28, 15, 3, C_SHADOW) - # Ashlar stone curtain wall - _rect(img, 4, 11, 28, 17, Color("#64748b")) - _rect(img, 5, 12, 26, 15, Color("#94a3b8")) - for y in [15, 19, 23]: - _line(img, 5, y, 30, y, Color("#475569")) - # 5 top crenellated merlons - for mx in [4, 10, 16, 22, 28]: - _rect(img, mx, 7, 4, 5, Color("#cbd5e1")) - # Central arched gateway with iron portcullis grid - _circle(img, 18, 19, 5, Color("#1e293b")) - _rect(img, 13, 19, 10, 9, Color("#1e293b")) - for gx in [15, 18, 21]: - _line(img, gx, 15, gx, 27, Color("#475569")) - for gy in [18, 22, 25]: - _line(img, 14, gy, 22, gy, Color("#475569")) - _apply_soft_outline(img) - return img - - -static func _draw_prop_defense_fortress() -> Image: - var img := _new_canvas(40, 40) - _ellipse(img, 20, 36, 17, 3, C_SHADOW) - # Reinforced gatehouse - _rect(img, 14, 14, 12, 21, Color("#64748b")) - _rect(img, 15, 15, 10, 19, Color("#94a3b8")) - # Twin cylindrical corner stone towers - _rect(img, 4, 10, 10, 25, Color("#94a3b8")) - _rect(img, 5, 10, 5, 25, Color("#cbd5e1")) - _rect(img, 26, 10, 10, 25, Color("#94a3b8")) - _rect(img, 27, 10, 5, 25, Color("#cbd5e1")) - # Crimson/gold conical roofs on towers - for cx in [9, 31]: - for y in range(4, 11): - var hw := 1 + int(float(y - 4) * 0.75) - _rect(img, cx - hw, y, hw * 2 + 1, 1, Color("#991b1b") if y % 2 == 0 else Color("#dc2626")) - _px(img, cx, 3, Color("#fbbf24")) - # Gatehouse portal & golden shield emblem at (20, 19) - _circle(img, 20, 26, 4, Color("#1e293b")) - _rect(img, 16, 26, 8, 9, Color("#1e293b")) - _circle(img, 20, 19, 3, Color("#fbbf24")) - _px(img, 20, 19, Color("#fef08a")) - _apply_soft_outline(img) - return img - - + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 114.0, 56.0, 20.0, 0.36) + + var stones := [ + Vector2(34, 88), Vector2(54, 68), Vector2(82, 66), + Vector2(110, 82), Vector2(96, 112), Vector2(48, 112) + ] + for st in stones: + _draw_isometric_box( + img, st.x, st.y, 10.0, 8.0, 34.0, + Color("#cbd5e1"), Color("#94a3b8"), Color("#64748b"), 2.2 + ) + # Central square altar with glowing crimson/gold seal + _draw_isometric_box( + img, 72.0, 96.0, 16.0, 16.0, 10.0, + Color("#ef4444"), Color("#94a3b8"), Color("#64748b"), 2.2 + ) + return img + + +# 12. Isometric Timber Palisade Wall (land3.jpeg bottom-left) +static func _draw_palisade_wall() -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 104.0, 48.0, 16.0, 0.35) + for i in range(9): + var t := float(i) / 8.0 + var lx := lerpf(26.0, 102.0, t) + var ly := lerpf(84.0, 116.0, t) + var post := PackedVector2Array([ + Vector2(lx - 5.0, ly), Vector2(lx - 5.0, ly - 46.0), + Vector2(lx, ly - 56.0), Vector2(lx + 5.0, ly - 46.0), Vector2(lx + 5.0, ly) + ]) + _poly_outlined(img, post, Color("#b07d4f"), Color("#784820"), C_OUTLINE, 2.2) + _aa_line(img, 24.0, 62.0, 104.0, 94.0, Color("#4a2c14"), 4.5) + _aa_line(img, 24.0, 76.0, 104.0, 108.0, Color("#4a2c14"), 4.5) + return img + + +# 13. Isometric Crenellated Stone Wall Segment (land3.jpeg bottom-left) +static func _draw_stone_wall() -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 50.0, 18.0, 0.38) + _draw_isometric_box( + img, 88.0, 114.0, 64.0, 16.0, 42.0, + Color("#d4d0c8"), Color("#b8b2a8"), Color("#87827a"), 2.5 + ) + for m in range(4): + var mx := 34.0 + float(m) * 18.0 + var my := 45.0 + float(m) * 9.0 + _draw_isometric_box( + img, mx, my, 10.0, 14.0, 10.0, + Color("#e5e0d8"), Color("#c7c2b8"), Color("#948f86"), 2.0 + ) + return img + + +# 14. Overflowing Gold Treasure Chest (land3.jpeg bottom-right-center) static func _draw_prop_tribute_chest() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 20, 9, 2, C_SHADOW) - # Overflowing reserve sacks/coins around chest - _circle(img, 5, 18, 3, Color("#d97706")) - _circle(img, 19, 18, 2, Color("#f59e0b")) - # Ornate wooden chest - _rect(img, 6, 11, 12, 9, Color("#78350f")) - _rect(img, 7, 12, 10, 7, Color("#92400e")) - # Overflowing gold/amber coins at top - _ellipse(img, 12, 11, 5, 2, Color("#fbbf24")) - _rect(img, 8, 9, 8, 2, Color("#fde047")) - # Golden bands and lock - _rect(img, 6, 13, 12, 2, Color("#fbbf24")) - _rect(img, 8, 11, 2, 9, Color("#fbbf24")) - _rect(img, 14, 11, 2, 9, Color("#fbbf24")) - _rect(img, 11, 13, 2, 3, Color("#fef08a")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 38.0, 14.0, 0.38) + _draw_isometric_box( + img, 64.0, 108.0, 32.0, 26.0, 24.0, + Color("#fbbf24"), Color("#9c5a25"), Color("#6e3d16"), 2.5 + ) + # Gleaming gold coin mound inside open chest + _aa_ellipse_outlined(img, 64.0, 68.0, 24.0, 12.0, Color("#fde047"), C_OUTLINE, 2.2) + _aa_ellipse(img, 62.0, 66.0, 16.0, 8.0, Color("#fef08a")) return img +# 15. Isometric Timber Catapult / Siege Engine (land3.jpeg bottom-right) static func _draw_prop_siege_engine() -> Image: - var img := _new_canvas(28, 28) - _ellipse(img, 14, 24, 11, 3, C_SHADOW) - # Timber catapult frame - _rect(img, 5, 18, 18, 4, Color("#78350f")) - _rect(img, 6, 19, 16, 2, Color("#92400e")) - _line(img, 9, 19, 14, 10, Color("#78350f")) - _line(img, 10, 19, 15, 10, Color("#92400e")) - _line(img, 19, 19, 14, 10, Color("#78350f")) - # Torsion arm & flaming stone payload - _line(img, 15, 15, 6, 6, Color("#92400e")) - _line(img, 16, 15, 7, 6, Color("#b45309")) - _circle(img, 6, 6, 3, Color("#f97316")) - _circle(img, 6, 6, 2, Color("#fbbf24")) - _px(img, 6, 4, Color("#ef4444")) - # Iron wheels - _circle(img, 8, 22, 3, Color("#475569")) - _px(img, 8, 22, Color("#cbd5e1")) - _circle(img, 20, 22, 3, Color("#475569")) - _px(img, 20, 22, Color("#cbd5e1")) - _apply_soft_outline(img) - return img - - -static func _draw_act_forage() -> Image: - var img := _new_canvas(32, 32) - _rect_outlined(img, 4, 12, 24, 15, Color("#6b4226"), Color("#9c6644")) - for row_y in [15, 19, 23]: - _line(img, 6, row_y, 25, row_y, Color("#4a2c18")) - for cx in [8, 14, 20, 24]: - _circle(img, cx, 15, 2, Color("#22c55e")) - _circle(img, cx, 21, 2, Color("#f97316")) - _px(img, cx, 19, Color("#15803d")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 44.0, 16.0, 0.38) + # Base timber frame + _draw_isometric_box( + img, 64.0, 104.0, 38.0, 28.0, 8.0, + Color("#9c6638"), Color("#7c4d25"), Color("#5c3618"), 2.4 + ) + # Upright A-frame & throwing arm with spoon + _aa_line(img, 42.0, 88.0, 60.0, 46.0, C_OUTLINE, 7.0) + _aa_line(img, 42.0, 88.0, 60.0, 46.0, Color("#8c582e"), 4.2) + _aa_line(img, 76.0, 92.0, 60.0, 46.0, C_OUTLINE, 7.0) + _aa_line(img, 76.0, 92.0, 60.0, 46.0, Color("#8c582e"), 4.2) + _aa_line(img, 52.0, 74.0, 96.0, 28.0, C_OUTLINE, 7.5) + _aa_line(img, 52.0, 74.0, 96.0, 28.0, Color("#a86e3e"), 4.5) + _aa_circle_outlined(img, 98.0, 26.0, 10.0, Color("#784820"), C_OUTLINE, 2.4) + # Wooden wheels + _aa_circle_outlined(img, 42.0, 96.0, 11.0, Color("#6e4220"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 84.0, 96.0, 11.0, Color("#6e4220"), C_OUTLINE, 2.4) + return img + + +# 16. Rural Plowed Golden Wheat Field with Haystacks (crop_field) +static func _draw_crop_field() -> Image: + var img := _new_canvas(144, 144) + var plot := PackedVector2Array([ + Vector2(72, 34), Vector2(132, 66), Vector2(72, 102), Vector2(12, 66) + ]) + _poly_outlined(img, plot, Color("#8c5a32"), Color("#5c381c"), C_OUTLINE, 2.4) + # Golden wheat furrow strips + for i in range(1, 6): + var t := float(i) / 6.0 + var a := Vector2(12, 66).lerp(Vector2(72, 34), t) + var b := Vector2(72, 102).lerp(Vector2(132, 66), t) + _aa_line(img, a.x + 8.0, a.y + 4.0, b.x - 8.0, b.y - 4.0, Color("#eab308"), 5.5) + _aa_line(img, a.x + 8.0, a.y + 2.0, b.x - 8.0, b.y - 6.0, Color("#fde047"), 3.0) + # Bundled golden haystacks + for hs in [Vector2(56, 62), Vector2(88, 72)]: + var stack := PackedVector2Array([ + Vector2(hs.x - 16.0, hs.y + 10.0), Vector2(hs.x, hs.y - 16.0), Vector2(hs.x + 16.0, hs.y + 10.0) + ]) + _poly_outlined(img, stack, Color("#fde047"), Color("#d97706"), C_OUTLINE, 2.2) + return img + + +# 17. Rural Wooden Paddock with Grazing Sheep & Cattle (pasture_pen) +static func _draw_pasture_pen() -> Image: + var img := _new_canvas(144, 144) + var meadow := PackedVector2Array([ + Vector2(72, 36), Vector2(130, 68), Vector2(72, 102), Vector2(14, 68) + ]) + _poly_outlined(img, meadow, Color("#84cc16"), Color("#4d7c0f"), C_OUTLINE, 2.4) + # Split-rail wooden fence along edges + var corners := [Vector2(72, 36), Vector2(130, 68), Vector2(72, 102), Vector2(14, 68)] + for i in range(4): + var c0: Vector2 = corners[i] + var c1: Vector2 = corners[(i + 1) % 4] + _aa_line(img, c0.x, c0.y - 10.0, c1.x, c1.y - 10.0, Color("#8c582e"), 3.0) + _aa_line(img, c0.x, c0.y, c0.x, c0.y - 16.0, C_OUTLINE, 3.5) + # Grazing fluffy white sheep & brown cow + _aa_ellipse_outlined(img, 54.0, 66.0, 11.0, 8.0, Color("#f8fafc"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 44.0, 64.0, 4.5, Color("#334155"), C_OUTLINE, 1.8) + _aa_ellipse_outlined(img, 86.0, 72.0, 14.0, 9.0, Color("#9a5828"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 98.0, 69.0, 5.5, Color("#783f18"), C_OUTLINE, 1.8) return img static func _draw_act_overexploit() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 28, 12, 3, C_SHADOW) - _ellipse(img, 13, 20, 9, 7, Color("#78716c")) - _rect(img, 9, 17, 8, 9, Color("#1c1917")) - _rect(img, 8, 16, 10, 2, Color("#8c5230")) - _rect(img, 20, 12, 7, 15, Color("#57534e")) - _rect(img, 22, 22, 3, 4, Color("#f97316")) - _circle(img, 23, 8, 3, Color("#9ca3af")) - _circle(img, 25, 5, 2, Color("#d1d5db")) - _apply_soft_outline(img) - return img - - -static func _draw_act_migrate() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 15, 27, 11, 2, C_SHADOW) - _rect(img, 6, 18, 16, 5, Color("#8c5230")) - _ellipse(img, 14, 15, 8, 5, Color("#f5ebe0")) - _line(img, 11, 11, 11, 18, Color("#d6c7b2")) - _line(img, 17, 11, 17, 18, Color("#d6c7b2")) - _circle(img, 9, 24, 3, Color("#5c3317")) - _circle(img, 19, 24, 3, Color("#5c3317")) - _rect(img, 25, 12, 2, 14, Color("#6b3e26")) - _rect(img, 23, 14, 7, 3, Color("#deb887")) - _apply_soft_outline(img) - return img - - -static func _draw_act_cooperate() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 28, 9, 2, C_SHADOW) - _rect(img, 10, 11, 12, 17, Color("#c83e31")) - _rect(img, 11, 11, 5, 17, Color("#e05346")) - _line(img, 10, 16, 21, 16, Color("#fbbf24")) - _line(img, 10, 22, 21, 22, Color("#fbbf24")) - _ellipse(img, 16, 11, 6, 4, Color("#f59e0b")) - _ellipse(img, 15, 10, 4, 2, Color("#fde047")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 108.0, 42.0, 15.0, 0.38) + _draw_isometric_box( + img, 64.0, 108.0, 34.0, 34.0, 36.0, + Color("#475569"), Color("#78716c"), Color("#57534e"), 2.5 + ) + _aa_circle_outlined(img, 52.0, 84.0, 10.0, Color("#f97316"), C_OUTLINE, 2.2) + _aa_circle(img, 52.0, 84.0, 5.0, Color("#fde047")) + return img + + +# 18. Isometric Covered Trade Wagon (land3.jpeg bottom-center) +static func _draw_covered_wagon() -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 44.0, 15.0, 0.38) + + # Wooden wagon box + _draw_isometric_box( + img, 68.0, 98.0, 36.0, 26.0, 14.0, + Color("#9c6638"), Color("#8c582e"), Color("#694020"), 2.4 + ) + # Rounded canvas canopy hood with stripes + var hood := PackedVector2Array([ + Vector2(34, 68), Vector2(44, 38), Vector2(74, 30), + Vector2(98, 46), Vector2(94, 72), Vector2(68, 84) + ]) + _poly_outlined(img, hood, Color("#f7f2e4"), Color("#d8cbb2"), C_OUTLINE, 2.5) + _aa_line(img, 52.0, 36.0, 50.0, 74.0, Color("#10b981"), 4.0) + _aa_line(img, 72.0, 32.0, 72.0, 80.0, Color("#f97316"), 4.0) + + # Spoked wooden wheels + for wc in [Vector2(48, 96), Vector2(82, 92)]: + _aa_circle_outlined(img, wc.x, wc.y, 13.0, Color("#855228"), C_OUTLINE, 2.5) + _aa_circle_outlined(img, wc.x, wc.y, 8.0, Color("#c9b898"), C_OUTLINE, 1.8) + _aa_circle(img, wc.x, wc.y, 3.0, C_OUTLINE) + return img + + +# 19. Carved Eagle/Owl Totem Pole (land3.jpeg bottom-left) +static func _draw_totem_pole(wing_col: Color, accent_col: Color) -> Image: + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 112.0, 28.0, 10.0, 0.38) + + # Outstretched carved wings + var wings := PackedVector2Array([ + Vector2(18, 42), Vector2(64, 52), Vector2(110, 42), + Vector2(102, 58), Vector2(64, 66), Vector2(26, 58) + ]) + _poly_outlined(img, wings, wing_col, accent_col, C_OUTLINE, 2.5) + + # Vertical carved cedar column + var pole := PackedVector2Array([ + Vector2(50, 22), Vector2(78, 22), Vector2(78, 112), Vector2(50, 112) + ]) + _poly_outlined(img, pole, Color("#b07848"), Color("#784b26"), C_OUTLINE, 2.6) + + # Eagle beak & eyes + var beak := PackedVector2Array([ + Vector2(58, 36), Vector2(70, 36), Vector2(64, 48) + ]) + _poly_outlined(img, beak, Color("#fbbf24"), Color("#d97706"), C_OUTLINE, 2.0) + _aa_circle_outlined(img, 57.0, 32.0, 4.0, Color("#fefae0"), C_OUTLINE, 1.8) + _aa_circle_outlined(img, 71.0, 32.0, 4.0, Color("#fefae0"), C_OUTLINE, 1.8) return img static func _draw_act_exchange() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 28, 12, 2, C_SHADOW) - _rect(img, 6, 20, 20, 7, Color("#9c6644")) - _rect(img, 7, 12, 2, 9, Color("#6b3e26")) - _rect(img, 23, 12, 2, 9, Color("#6b3e26")) - for x in range(5, 27): - var stripe_col := Color("#10b981") if ((x - 5) / 3) % 2 == 0 else Color("#fefae0") - _rect(img, x, 9, 1, 5, stripe_col) - _circle(img, 12, 18, 2, Color("#f59e0b")) - _circle(img, 17, 18, 2, Color("#ef4444")) - _circle(img, 21, 18, 2, Color("#3b82f6")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 108.0, 44.0, 15.0, 0.36) + _draw_isometric_box( + img, 64.0, 108.0, 36.0, 28.0, 18.0, + Color("#deb887"), Color("#9c6644"), Color("#6b3e26"), 2.4 + ) + var awning := PackedVector2Array([ + Vector2(24, 56), Vector2(64, 38), Vector2(104, 56), Vector2(64, 74) + ]) + _poly_outlined(img, awning, Color("#10b981"), Color("#059669"), C_OUTLINE, 2.5) return img -static func _draw_act_dispute() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 28, 12, 2, C_SHADOW) - for x in range(5, 26, 4): - _rect(img, x, 14, 3, 13, Color("#7f4f24")) - _px(img, x + 1, 12, Color("#b07d4f")) - _rect(img, x, 13, 3, 1, Color("#b07d4f")) - _rect(img, 14, 6, 2, 12, Color("#4a2c18")) - _rect(img, 16, 7, 7, 5, Color("#ef4444")) - _px(img, 18, 9, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_act_innovate() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 28, 11, 2, C_SHADOW) - _rect(img, 6, 19, 20, 4, Color("#8c5230")) - _rect(img, 8, 23, 3, 5, Color("#5c3317")) - _rect(img, 21, 23, 3, 5, Color("#5c3317")) - _rect(img, 9, 17, 6, 2, Color("#fefae0")) - for dy in range(-5, 6): - var hw: int = 4 - int(abs(dy) * 0.75) - if hw > 0: - _rect(img, 19 - hw, 11 + dy, hw * 2, 1, Color("#38bdf8") if dy < 0 else Color("#0284c7")) - _px(img, 18, 9, Color("#e0f2fe")) - _apply_soft_outline(img) - return img - - -static func _draw_act_custom() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 28, 10, 2, C_SHADOW) - for y in range(6, 27): - var hw := 1 + int(float(y - 6) * 0.2) - _rect(img, 16 - hw, y, hw * 2 + 1, 1, Color("#fbbf24") if y % 2 == 0 else Color("#d97706")) - _rect(img, 11, 25, 11, 3, Color("#78716c")) - _px(img, 16, 12, Color("#a855f7")) - _px(img, 16, 13, Color("#e9d5ff")) - _apply_soft_outline(img) - return img +# 20. Ivy-Covered Crumbling Stone Ruins (land3.jpeg bottom-right) +static func _draw_ruins_stone() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 122.0, 56.0, 20.0, 0.38) + # Broken left stone wall with jagged top + var left_wall := PackedVector2Array([ + Vector2(24, 96), Vector2(24, 52), Vector2(40, 46), + Vector2(52, 68), Vector2(66, 62), Vector2(72, 84), Vector2(72, 120) + ]) + _poly_outlined(img, left_wall, Color("#c7c2b8"), Color("#948f86"), C_OUTLINE, 2.5) -static func _draw_cultural_totem() -> Image: - var img := _new_canvas(28, 32) - _ellipse(img, 14, 28, 7, 2, C_SHADOW) - _rect(img, 11, 8, 6, 20, Color("#7c3aed")) - _rect(img, 12, 8, 4, 20, Color("#9333ea")) - _rect(img, 6, 12, 16, 3, Color("#fbbf24")) - _px(img, 12, 11, Color("#34d399")) - _px(img, 15, 11, Color("#34d399")) - _rect(img, 12, 17, 4, 2, Color("#fde047")) - _apply_soft_outline(img) - return img - + # Broken right stone wall + var right_wall := PackedVector2Array([ + Vector2(72, 120), Vector2(72, 74), Vector2(88, 44), + Vector2(104, 56), Vector2(120, 48), Vector2(120, 96) + ]) + _poly_outlined(img, right_wall, Color("#9e9990"), Color("#706b63"), C_OUTLINE, 2.5) -static func _draw_ruins() -> Image: - var img := _new_canvas(32, 32) - _ellipse(img, 16, 27, 12, 3, C_SHADOW) - _rect(img, 7, 19, 6, 8, Color("#78716c")) - _rect(img, 14, 22, 5, 5, Color("#57534e")) - _rect(img, 20, 16, 5, 11, Color("#78716c")) - _rect(img, 7, 18, 5, 2, Color("#4d7c0f")) - _rect(img, 20, 15, 4, 3, Color("#65a30d")) - _rect(img, 14, 21, 4, 2, Color("#4d7c0f")) - _apply_soft_outline(img) + # Overgrown green ivy & moss clusters + for iv in [Vector2(30, 56), Vector2(86, 52), Vector2(114, 62), Vector2(68, 96)]: + _aa_circle_outlined(img, iv.x, iv.y, 12.0, Color("#52a838"), C_OUTLINE, 2.0) + _aa_circle(img, iv.x - 2.0, iv.y - 2.0, 7.0, Color("#7ae058")) return img -static func _draw_caravan_cart() -> Image: - var img := _new_canvas(28, 24) - _ellipse(img, 14, 20, 10, 2, C_SHADOW) - _rect(img, 4, 11, 13, 5, Color("#8c5230")) - _rect(img, 5, 12, 11, 3, Color("#b07d4f")) - _rect(img, 5, 7, 5, 4, Color("#d97706")) - _rect(img, 6, 8, 3, 2, Color("#fde047")) - _circle(img, 13, 9, 3, Color("#eab308")) - _circle(img, 7, 17, 3, Color("#4a2c18")) - _px(img, 7, 17, Color("#d6b88a")) - _circle(img, 14, 17, 3, Color("#4a2c18")) - _px(img, 14, 17, Color("#d6b88a")) - _rect(img, 18, 12, 6, 4, Color("#a16207")) - _rect(img, 22, 9, 3, 4, Color("#a16207")) - _line(img, 19, 16, 19, 19, Color("#713f12")) - _line(img, 23, 16, 23, 19, Color("#713f12")) - _apply_soft_outline(img) - return img - - -static func _draw_settler_wagon() -> Image: - var img := _new_canvas(32, 28) - _ellipse(img, 16, 23, 12, 3, C_SHADOW) - _rect(img, 6, 15, 18, 5, Color("#8c5230")) - _ellipse(img, 15, 12, 9, 6, Color("#fefae0")) - _line(img, 11, 7, 11, 15, Color("#d4c5b1")) - _line(img, 15, 6, 15, 15, Color("#d4c5b1")) - _line(img, 19, 7, 19, 15, Color("#d4c5b1")) - _circle(img, 10, 21, 3, Color("#5c3317")) - _px(img, 10, 21, Color("#eab308")) - _circle(img, 20, 21, 3, Color("#5c3317")) - _px(img, 20, 21, Color("#eab308")) - _apply_soft_outline(img) +static func _draw_prop_betrayal_ruin() -> Image: + var img := _new_canvas(144, 144) + _drop_shadow(img, 72.0, 122.0, 54.0, 18.0, 0.42) + + # Shattered left obsidian monolith + var left_shard := PackedVector2Array([ + Vector2(28, 110), Vector2(32, 42), Vector2(54, 30), + Vector2(64, 68), Vector2(60, 118) + ]) + _poly_outlined(img, left_shard, Color("#475569"), Color("#1e293b"), C_OUTLINE, 2.5) + + # Shattered right obsidian monolith + var right_shard := PackedVector2Array([ + Vector2(82, 118), Vector2(78, 64), Vector2(92, 34), + Vector2(114, 48), Vector2(110, 110) + ]) + _poly_outlined(img, right_shard, Color("#334155"), Color("#0f172a"), C_OUTLINE, 2.5) + + # Glowing violet/crimson fracture crack between shards + _aa_line(img, 70.0, 26.0, 66.0, 68.0, Color("#7e22ce"), 7.0) + _aa_line(img, 66.0, 68.0, 74.0, 118.0, Color("#7e22ce"), 7.0) + _aa_line(img, 70.0, 26.0, 66.0, 68.0, Color("#e11d48"), 3.6) + _aa_line(img, 66.0, 68.0, 74.0, 118.0, Color("#e11d48"), 3.6) + _aa_line(img, 70.0, 26.0, 66.0, 68.0, Color("#fda4af"), 1.6) + + # Broken spear & torn alliance banner + _aa_line(img, 42.0, 116.0, 102.0, 44.0, C_OUTLINE, 6.0) + _aa_line(img, 42.0, 116.0, 102.0, 44.0, Color("#78350f"), 3.6) + var torn_banner := PackedVector2Array([ + Vector2(76, 56), Vector2(106, 50), Vector2(100, 74), + Vector2(88, 68), Vector2(80, 84) + ]) + _poly_outlined(img, torn_banner, Color("#be123c"), Color("#581c87"), C_OUTLINE, 2.2) return img static func _draw_barricade_spikes() -> Image: - var img := _new_canvas(32, 28) - _ellipse(img, 16, 23, 13, 3, C_SHADOW) - _rect(img, 5, 15, 22, 4, Color("#5c3317")) - _rect(img, 6, 16, 20, 2, Color("#7f4f24")) - var spike_xs := [7, 12, 17, 22] - for sx in spike_xs: - _line(img, sx - 3, 22, sx + 3, 7, Color("#6b3e26")) - _line(img, sx - 2, 22, sx + 4, 7, Color("#9c6644")) - _px(img, sx + 3, 6, Color("#fde047")) - _line(img, sx + 3, 22, sx - 3, 8, Color("#5c3317")) - _line(img, sx + 2, 22, sx - 2, 8, Color("#8c5230")) - _circle(img, 16, 16, 4, Color("#dc2626")) - _circle(img, 16, 16, 2, Color("#fbbf24")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 44.0, 14.0, 0.35) + _aa_line(img, 24.0, 76.0, 104.0, 96.0, C_OUTLINE, 9.0) + _aa_line(img, 24.0, 76.0, 104.0, 96.0, Color("#8c582e"), 5.5) + for sx_v in [36.0, 54.0, 72.0, 90.0]: + var sx: float = float(sx_v) + _aa_line(img, sx - 12.0, 106.0, sx + 12.0, 52.0, C_OUTLINE, 7.0) + _aa_line(img, sx - 12.0, 106.0, sx + 12.0, 52.0, Color("#a86e3e"), 4.2) + _aa_line(img, sx + 12.0, 106.0, sx - 12.0, 54.0, C_OUTLINE, 7.0) + _aa_line(img, sx + 12.0, 106.0, sx - 12.0, 54.0, Color("#784820"), 4.2) return img static func _draw_road_block_sign() -> Image: - var img := _new_canvas(28, 26) - _ellipse(img, 14, 22, 10, 2, C_SHADOW) - _ellipse(img, 10, 20, 4, 2, Color("#4d7c0f")) - _ellipse(img, 18, 20, 4, 2, Color("#65a30d")) - _rect(img, 7, 10, 3, 12, Color("#5c3317")) - _rect(img, 18, 10, 3, 12, Color("#5c3317")) - _line(img, 5, 9, 23, 19, Color("#b45309")) - _line(img, 5, 10, 23, 20, Color("#d97706")) - _line(img, 5, 11, 23, 21, Color("#f59e0b")) - _line(img, 23, 9, 5, 19, Color("#b45309")) - _line(img, 23, 10, 5, 20, Color("#d97706")) - _line(img, 23, 11, 5, 21, Color("#f59e0b")) - _rect(img, 5, 8, 18, 3, Color("#d97706")) - for sx in range(6, 22, 4): - _rect(img, sx, 8, 2, 3, Color("#ef4444")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 106.0, 36.0, 12.0, 0.34) + _aa_line(img, 40.0, 104.0, 40.0, 54.0, Color("#5c3317"), 6.0) + _aa_line(img, 88.0, 104.0, 88.0, 54.0, Color("#5c3317"), 6.0) + var board := PackedVector2Array([ + Vector2(28, 52), Vector2(100, 52), Vector2(100, 74), Vector2(28, 74) + ]) + _poly_outlined(img, board, Color("#f59e0b"), Color("#d97706"), C_OUTLINE, 2.4) return img +# 21. Wooden Lamppost with Glowing Lantern (land3.jpeg bottom-right) static func _draw_road_lantern() -> Image: - var img := _new_canvas(20, 28) - _ellipse(img, 10, 24, 5, 2, C_SHADOW) - _rect(img, 8, 7, 3, 17, Color("#5c3317")) - _rect(img, 9, 7, 1, 17, Color("#8c5230")) - _rect(img, 8, 7, 7, 2, Color("#5c3317")) - _rect(img, 12, 9, 4, 5, Color("#fbbf24")) - _rect(img, 13, 10, 2, 3, Color("#fef08a")) - _rect(img, 11, 8, 6, 1, Color("#374151")) - _rect(img, 12, 14, 4, 1, Color("#374151")) - _apply_soft_outline(img) - return img - - + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 110.0, 22.0, 8.0, 0.34) + var post := PackedVector2Array([ + Vector2(66, 26), Vector2(76, 26), Vector2(76, 110), Vector2(66, 110) + ]) + _poly_outlined(img, post, Color("#9c6638"), Color("#694020"), C_OUTLINE, 2.4) + var arm := PackedVector2Array([ + Vector2(44, 32), Vector2(78, 26), Vector2(78, 34), Vector2(44, 40) + ]) + _poly_outlined(img, arm, Color("#9c6638"), Color("#694020"), C_OUTLINE, 2.2) + # Glowing lantern box + var lantern := PackedVector2Array([ + Vector2(40, 48), Vector2(56, 48), Vector2(54, 68), Vector2(42, 68) + ]) + _poly_outlined(img, lantern, Color("#fef08a"), Color("#f59e0b"), C_OUTLINE, 2.2) + return img + + +# 22. Heraldic War Banner Standard (land3.jpeg bottom-right-center) static func _draw_prop_war_banner() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 21, 7, 2, C_SHADOW) - # Crossed war spears behind standard - _line(img, 4, 20, 19, 5, Color("#5c3317")) - _line(img, 5, 20, 19, 6, Color("#8c5230")) - _line(img, 20, 20, 5, 5, Color("#5c3317")) - _line(img, 19, 20, 5, 6, Color("#8c5230")) - # Steel spearheads - _rect(img, 18, 3, 3, 3, Color("#cbd5e1")) - _px(img, 20, 2, Color("#f8fafc")) - _rect(img, 3, 3, 3, 3, Color("#cbd5e1")) - _px(img, 3, 2, Color("#f8fafc")) - # Central iron pole & crossbar - _rect(img, 11, 3, 2, 18, Color("#475569")) - _line(img, 11, 3, 11, 20, Color("#94a3b8")) - _rect(img, 6, 6, 12, 2, Color("#d97706")) - _line(img, 7, 6, 16, 6, Color("#fde047")) - _circle(img, 12, 3, 2, Color("#fbbf24")) - # Tattered crimson battle pennant - _rect(img, 8, 8, 8, 8, Color("#dc2626")) - _rect(img, 9, 8, 6, 7, Color("#ef4444")) - _rect(img, 8, 16, 2, 3, Color("#991b1b")) - _rect(img, 11, 16, 2, 2, Color("#991b1b")) - _rect(img, 14, 16, 2, 3, Color("#991b1b")) - # Golden war emblem on banner - _circle(img, 12, 11, 2, Color("#fde047")) - _px(img, 12, 11, Color("#991b1b")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 110.0, 28.0, 10.0, 0.36) + _aa_line(img, 64.0, 18.0, 64.0, 110.0, C_OUTLINE, 7.0) + _aa_line(img, 64.0, 18.0, 64.0, 110.0, Color("#8c582e"), 4.2) + _aa_line(img, 36.0, 28.0, 92.0, 28.0, C_OUTLINE, 6.5) + _aa_line(img, 36.0, 28.0, 92.0, 28.0, Color("#d97706"), 4.0) + var banner := PackedVector2Array([ + Vector2(42, 30), Vector2(86, 30), Vector2(86, 82), + Vector2(64, 96), Vector2(42, 82) + ]) + _poly_outlined(img, banner, Color("#ef4444"), Color("#b91c1c"), C_OUTLINE, 2.4) + _aa_circle_outlined(img, 64.0, 58.0, 11.0, Color("#fde047"), C_OUTLINE, 2.0) return img static func _draw_prop_raid_loot() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 21, 8, 2, C_SHADOW) - # Spilled golden grain / amber reserves - _circle(img, 5, 19, 2, Color("#f59e0b")) - _px(img, 5, 18, Color("#fde047")) - _circle(img, 18, 20, 2, Color("#fbbf24")) - _px(img, 15, 21, Color("#fde047")) - _px(img, 7, 21, Color("#fde047")) - # Raider plunder sack - _ellipse(img, 11, 15, 6, 5, Color("#7f4f24")) - _ellipse(img, 10, 15, 5, 4, Color("#b07d4f")) - _rect(img, 8, 9, 6, 2, Color("#d97706")) - _line(img, 8, 10, 13, 10, Color("#fde047")) - # Overflowing golden grain at sack mouth - _ellipse(img, 11, 8, 4, 2, Color("#f59e0b")) - _rect(img, 9, 7, 4, 2, Color("#fde047")) - # Burning torch leaning on sack - _line(img, 15, 20, 19, 10, Color("#5c3317")) - _line(img, 16, 20, 20, 10, Color("#8c5230")) - _ellipse(img, 19, 8, 3, 3, Color("#ef4444")) - _circle(img, 19, 8, 2, Color("#f97316")) - _px(img, 19, 7, Color("#fde047")) - _px(img, 20, 5, Color("#fb923c")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 108.0, 36.0, 12.0, 0.36) + _aa_ellipse_outlined(img, 58.0, 86.0, 26.0, 22.0, Color("#b07d4f"), C_OUTLINE, 2.5) + _aa_ellipse_outlined(img, 58.0, 62.0, 16.0, 8.0, Color("#fbbf24"), C_OUTLINE, 2.2) + _aa_line(img, 76.0, 104.0, 94.0, 48.0, Color("#5c3317"), 5.5) + _aa_circle_outlined(img, 94.0, 42.0, 11.0, Color("#f97316"), C_OUTLINE, 2.0) + _aa_circle(img, 94.0, 42.0, 6.0, Color("#fde047")) return img static func _draw_prop_pact_totem() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 21, 8, 2, C_SHADOW) - # Stepped stone pedestal - _rect(img, 6, 18, 12, 3, Color("#78716c")) - _rect(img, 7, 17, 10, 2, Color("#a8a29e")) - # Twin emerald/gold banners flanking obelisk - _rect(img, 4, 7, 16, 2, Color("#d97706")) - _rect(img, 5, 9, 3, 7, Color("#059669")) - _line(img, 6, 9, 6, 15, Color("#34d399")) - _rect(img, 16, 9, 3, 7, Color("#059669")) - _line(img, 17, 9, 17, 15, Color("#34d399")) - # Carved stone & gold alliance obelisk - _rect(img, 9, 6, 6, 12, Color("#d6d3d1")) - _rect(img, 10, 6, 4, 12, Color("#e7e5e4")) - for y in range(2, 6): - var hw := y - 2 - _rect(img, 12 - hw, y, hw * 2 + 1, 1, Color("#fbbf24") if y < 5 else Color("#d97706")) - # Emerald/cyan harmony bands & golden seal - _rect(img, 9, 8, 6, 2, Color("#10b981")) - _line(img, 10, 8, 13, 8, Color("#6ee7b7")) - _rect(img, 9, 13, 6, 2, Color("#0d9488")) - _line(img, 10, 13, 13, 13, Color("#2dd4bf")) - _circle(img, 12, 11, 2, Color("#fbbf24")) - _px(img, 12, 11, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_prop_betrayal_ruin() -> Image: - var img := _new_canvas(24, 24) - _ellipse(img, 12, 21, 8, 2, C_SHADOW) - # Dark purple/charcoal smoke wisps - _circle(img, 6, 10, 2, Color("#3b0764")) - _circle(img, 7, 6, 2, Color("#4c1d95")) - _circle(img, 18, 9, 2, Color("#374151")) - _px(img, 5, 4, Color("#6b21a8")) - # Shattered alliance pillar base - _rect(img, 6, 18, 12, 3, Color("#44403c")) - _rect(img, 8, 12, 8, 7, Color("#78716c")) - _rect(img, 9, 13, 6, 5, Color("#57534e")) - # Broken emerald band & rubble - _rect(img, 8, 15, 3, 2, Color("#047857")) - _rect(img, 17, 18, 3, 3, Color("#78716c")) - # Jagged crimson fracture & piercing dagger - _line(img, 9, 12, 14, 18, Color("#be123c")) - _line(img, 10, 12, 13, 17, Color("#f43f5e")) - _line(img, 17, 4, 11, 14, Color("#e2e8f0")) - _line(img, 18, 5, 12, 14, Color("#ef4444")) - _line(img, 15, 4, 19, 8, Color("#fbbf24")) - _circle(img, 18, 4, 2, Color("#be123c")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _drop_shadow(img, 64.0, 110.0, 34.0, 12.0, 0.36) + _draw_isometric_box( + img, 64.0, 110.0, 26.0, 26.0, 64.0, + Color("#fbbf24"), Color("#e2e8f0"), Color("#94a3b8"), 2.4 + ) + _aa_circle_outlined(img, 64.0, 62.0, 12.0, Color("#10b981"), C_OUTLINE, 2.2) return img static func _draw_effect_battle_sparks() -> Image: - var img := _new_canvas(24, 24) - _line(img, 4, 12, 20, 12, Color("#ef4444")) - _line(img, 12, 4, 12, 20, Color("#ef4444")) - _line(img, 6, 6, 18, 18, Color("#f97316")) - _line(img, 18, 6, 6, 18, Color("#f97316")) - _circle(img, 12, 12, 4, Color("#fbbf24")) - _circle(img, 12, 12, 2, Color("#fef08a")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_war() -> Image: - var img := _new_canvas(16, 16) - # Crossed war spears in steel - _line(img, 2, 13, 13, 2, Color("#94a3b8")) - _line(img, 3, 13, 13, 3, Color("#f8fafc")) - _line(img, 13, 13, 2, 2, Color("#94a3b8")) - _line(img, 12, 13, 2, 3, Color("#f8fafc")) - # Vivid crimson & steel battle shield - _rect(img, 5, 4, 6, 7, Color("#991b1b")) - _rect(img, 6, 5, 4, 5, Color("#ef4444")) - _rect(img, 6, 11, 4, 2, Color("#dc2626")) - _line(img, 5, 4, 10, 4, Color("#cbd5e1")) - _px(img, 8, 8, Color("#fde047")) - _px(img, 7, 8, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_raid() -> Image: - var img := _new_canvas(16, 16) - # Plunder loot sack - _ellipse(img, 7, 10, 4, 4, Color("#92400e")) - _ellipse(img, 7, 10, 3, 3, Color("#d97706")) - _rect(img, 5, 6, 4, 2, Color("#b45309")) - _line(img, 5, 7, 8, 7, Color("#fde047")) - # Amber coins / grain & flame spark - _rect(img, 6, 4, 3, 2, Color("#fbbf24")) - _px(img, 7, 9, Color("#fde047")) - _px(img, 3, 13, Color("#fbbf24")) - _px(img, 11, 13, Color("#fde047")) - _circle(img, 12, 6, 2, Color("#ef4444")) - _px(img, 12, 6, Color("#fde047")) - _px(img, 12, 4, Color("#f97316")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_pact() -> Image: - var img := _new_canvas(16, 16) - # Golden/emerald alliance seal - _circle(img, 8, 8, 6, Color("#d97706")) - _circle(img, 8, 8, 5, Color("#fbbf24")) - _circle(img, 8, 8, 4, Color("#059669")) - _circle(img, 8, 8, 3, Color("#10b981")) - # Clasped emblem across center - _rect(img, 4, 7, 8, 2, Color("#fde047")) - _rect(img, 6, 6, 4, 4, Color("#fef08a")) - _rect(img, 7, 7, 2, 2, Color("#10b981")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_betrayal() -> Image: - var img := _new_canvas(16, 16) - # Broken golden ring/seal halves - _circle(img, 5, 9, 3, Color("#d97706")) - _px(img, 4, 9, Color("#fde047")) - _circle(img, 11, 9, 3, Color("#d97706")) - _px(img, 12, 9, Color("#fde047")) - _rect(img, 7, 6, 2, 7, Color(0, 0, 0, 0)) - # Jagged crimson dagger breaking the seal - _line(img, 12, 2, 5, 13, Color("#be123c")) - _line(img, 11, 2, 4, 13, Color("#f8fafc")) - _line(img, 11, 3, 6, 11, Color("#ef4444")) - _line(img, 9, 2, 13, 6, Color("#fbbf24")) - _px(img, 13, 2, Color("#f43f5e")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_defense() -> Image: - var img := _new_canvas(16, 16) - # Crenellated silver/gold fortress shield - _rect(img, 3, 4, 10, 7, Color("#64748b")) - _rect(img, 4, 5, 8, 6, Color("#cbd5e1")) - _rect(img, 5, 11, 6, 2, Color("#94a3b8")) - _rect(img, 7, 13, 2, 1, Color("#64748b")) - # Top merlons - for mx in [3, 7, 11]: - _rect(img, mx, 2, 2, 2, Color("#e2e8f0")) - # Gold trim & central emblem - _rect(img, 6, 6, 4, 4, Color("#fbbf24")) - _rect(img, 7, 7, 2, 2, Color("#fef08a")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_merge() -> Image: - var img := _new_canvas(16, 16) - # Twin banners/pillars uniting under a golden crown arch - _rect(img, 3, 7, 3, 6, Color("#10b981")) - _rect(img, 10, 7, 3, 6, Color("#38bdf8")) - _rect(img, 2, 4, 12, 3, Color("#fbbf24")) - _rect(img, 6, 3, 4, 4, Color("#fde047")) - _line(img, 4, 12, 8, 8, Color("#fbbf24")) - _line(img, 11, 12, 8, 8, Color("#fbbf24")) - _circle(img, 8, 8, 2, Color("#fef08a")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_tribute() -> Image: - var img := _new_canvas(16, 16) - # Golden chest with an offering arrow - _rect(img, 3, 8, 9, 6, Color("#92400e")) - _rect(img, 4, 6, 7, 2, Color("#fbbf24")) - _rect(img, 3, 10, 9, 1, Color("#fbbf24")) - _rect(img, 7, 9, 2, 3, Color("#fef08a")) - # Offering arrow pointing up-right - _line(img, 9, 6, 13, 2, Color("#fde047")) - _line(img, 10, 6, 13, 3, Color("#f59e0b")) - _rect(img, 11, 2, 3, 2, Color("#fde047")) - _apply_soft_outline(img) - return img - - -static func _draw_icon_siege() -> Image: - var img := _new_canvas(16, 16) - # Crenellated tower with orange/red siege flames - _rect(img, 5, 5, 6, 9, Color("#64748b")) - _rect(img, 6, 6, 4, 7, Color("#94a3b8")) - for mx in [4, 7, 10]: - _rect(img, mx, 3, 2, 2, Color("#cbd5e1")) - # Siege flames around tower - _circle(img, 4, 11, 2, Color("#ef4444")) - _px(img, 4, 11, Color("#fbbf24")) - _circle(img, 12, 10, 2, Color("#f97316")) - _px(img, 12, 10, Color("#fde047")) - _circle(img, 8, 12, 2, Color("#ef4444")) - _px(img, 8, 12, Color("#fde047")) - _apply_soft_outline(img) + var img := _new_canvas(128, 128) + _aa_line(img, 24.0, 64.0, 104.0, 64.0, Color("#ef4444"), 4.5) + _aa_line(img, 64.0, 24.0, 64.0, 104.0, Color("#ef4444"), 4.5) + _aa_line(img, 34.0, 34.0, 94.0, 94.0, Color("#f97316"), 4.0) + _aa_line(img, 94.0, 34.0, 34.0, 94.0, Color("#f97316"), 4.0) + _aa_circle_outlined(img, 64.0, 64.0, 18.0, Color("#fbbf24"), C_OUTLINE, 2.2) + _aa_circle(img, 64.0, 64.0, 9.0, Color("#fef08a")) return img +# High-DPI 48x48 Illustrated Villager (land.jpeg / land2.jpeg) static func draw_villager(step: int, tunic_color: Color) -> Image: - var img := _new_canvas(16, 16) - _ellipse(img, 8, 14, 4, 1, C_SHADOW) - var bob := -1 if step != 0 else 0 - _rect(img, 6, 2 + bob, 4, 2, Color("#5c3317")) - _rect(img, 6, 4 + bob, 4, 3, Color("#fcd5b5")) - _px(img, 7, 5 + bob, C_OUTLINE) - _px(img, 9, 5 + bob, C_OUTLINE) - _rect(img, 5, 7 + bob, 6, 4, tunic_color) - _rect(img, 6, 7 + bob, 4, 3, tunic_color.lightened(0.18)) - var left_y := 11 - var right_y := 11 + var img := _new_canvas(48, 48) + _drop_shadow(img, 24.0, 42.0, 11.0, 4.5, 0.35) + var bob := -2.0 if step != 0 else 0.0 + + # Legs + var left_y := 33.0 + var right_y := 33.0 if step == 1: - left_y = 10 - right_y = 12 + left_y = 30.0 + right_y = 35.0 elif step == 2: - left_y = 12 - right_y = 10 - _rect(img, 6, left_y, 2, 3, Color("#3b4252")) - _rect(img, 8, right_y, 2, 3, Color("#3b4252")) - _apply_soft_outline(img) + left_y = 35.0 + right_y = 30.0 + _aa_line(img, 20.0, 28.0 + bob, 19.0, left_y + 7.0, C_OUTLINE, 6.5) + _aa_line(img, 20.0, 28.0 + bob, 19.0, left_y + 7.0, Color("#475569"), 3.8) + _aa_line(img, 28.0, 28.0 + bob, 29.0, right_y + 7.0, C_OUTLINE, 6.5) + _aa_line(img, 28.0, 28.0 + bob, 29.0, right_y + 7.0, Color("#475569"), 3.8) + + # Tunic torso + var tunic := PackedVector2Array([ + Vector2(16, 18.0 + bob), Vector2(32, 18.0 + bob), + Vector2(34, 32.0 + bob), Vector2(14, 32.0 + bob) + ]) + _poly_outlined(img, tunic, tunic_color.lightened(0.15), tunic_color.darkened(0.12), C_OUTLINE, 2.2) + + # Head & hair + _aa_circle_outlined(img, 24.0, 13.0 + bob, 7.5, Color("#fcd5b5"), C_OUTLINE, 2.2) + var hair := PackedVector2Array([ + Vector2(16, 12.0 + bob), Vector2(20, 5.0 + bob), + Vector2(28, 5.0 + bob), Vector2(32, 12.0 + bob) + ]) + _poly_outlined(img, hair, Color("#6e3f20"), Color("#4a2912"), C_OUTLINE, 2.0) + _aa_circle(img, 21.5, 13.5 + bob, 1.2, C_OUTLINE) + _aa_circle(img, 26.5, 13.5 + bob, 1.2, C_OUTLINE) return img - - -static func _draw_ui_icon(icon_name: String) -> Image: - if icon_name == "thought_bubble": - return _draw_thought_bubble() - - var img := _new_canvas(24, 24) - match icon_name: - "tool_inspect": - _circle(img, 10, 10, 6, Color("#38bdf8")) - _circle(img, 10, 10, 4, Color("#bae6fd")) - _line(img, 14, 14, 20, 20, Color("#8c5230")) - _line(img, 15, 14, 20, 19, Color("#d97706")) - "tool_biome": - _ellipse(img, 12, 15, 5, 5, Color("#d4a373")) - _line(img, 12, 10, 12, 5, Color("#22c55e")) - _ellipse(img, 9, 7, 3, 2, Color("#4ade80")) - _ellipse(img, 15, 7, 3, 2, Color("#22c55e")) - "tool_altitude": - _line(img, 5, 19, 18, 6, Color("#8c5230")) - _line(img, 6, 19, 18, 7, Color("#b07d4f")) - _line(img, 13, 4, 20, 11, Color("#94a3b8")) - _line(img, 14, 4, 20, 10, Color("#e2e8f0")) - "tool_fertility": - _rect(img, 7, 11, 9, 8, Color("#fbbf24")) - _rect(img, 8, 12, 7, 6, Color("#fde047")) - _line(img, 16, 15, 21, 10, Color("#f59e0b")) - _circle(img, 5, 14, 2, Color("#d97706")) - "tool_sealevel": - _ellipse(img, 12, 13, 7, 5, Color("#38bdf8")) - _ellipse(img, 12, 11, 5, 3, Color("#7dd3fc")) - _line(img, 4, 18, 20, 18, Color("#0284c7")) - "season_wet": - _circle(img, 12, 12, 5, Color("#fbbf24")) - _circle(img, 12, 12, 3, Color("#fde047")) - _ellipse(img, 16, 16, 4, 2, Color("#22c55e")) - "season_dry": - _line(img, 12, 4, 12, 20, Color("#7dd3fc")) - _line(img, 4, 12, 20, 12, Color("#7dd3fc")) - _line(img, 6, 6, 18, 18, Color("#bae6fd")) - _line(img, 18, 6, 6, 18, Color("#bae6fd")) - "status_thriving": - _draw_heart(img, Color("#10b981"), Color("#6ee7b7")) - "status_stable": - _draw_heart(img, Color("#0ea5e9"), Color("#7dd3fc")) - "status_struggling": - _draw_heart(img, Color("#f59e0b"), Color("#fde047")) - "status_starving": - _draw_heart(img, Color("#ef4444"), Color("#fca5a5")) - "icon_forage": - _circle(img, 12, 13, 6, Color("#22c55e")) - _circle(img, 10, 11, 2, Color("#ef4444")) - _circle(img, 15, 14, 2, Color("#fbbf24")) - "icon_overexploit": - _ellipse(img, 12, 15, 6, 5, Color("#f97316")) - _rect(img, 10, 6, 4, 6, Color("#78716c")) - "icon_migrate": - _rect(img, 6, 11, 12, 6, Color("#38bdf8")) - _circle(img, 9, 18, 2, Color("#8c5230")) - _circle(img, 15, 18, 2, Color("#8c5230")) - "icon_cooperate": - _rect(img, 8, 9, 8, 11, Color("#ef4444")) - _ellipse(img, 12, 9, 4, 3, Color("#fbbf24")) - "icon_exchange": - _rect(img, 5, 7, 14, 4, Color("#10b981")) - _rect(img, 6, 11, 12, 8, Color("#d4a373")) - "icon_dispute": - _line(img, 6, 18, 18, 6, Color("#ef4444")) - _line(img, 6, 6, 18, 18, Color("#fca5a5")) - "icon_innovate": - _circle(img, 12, 12, 5, Color("#a855f7")) - _circle(img, 12, 12, 3, Color("#e9d5ff")) - "icon_coop_link": - _circle(img, 12, 12, 9, Color("#059669")) - _circle(img, 12, 12, 7, Color("#10b981")) - _rect(img, 7, 10, 10, 6, Color("#d97706")) - _rect(img, 7, 8, 10, 3, Color("#fbbf24")) - _rect(img, 11, 10, 2, 3, Color("#fef08a")) - "icon_conflict_swords": - _circle(img, 12, 12, 9, Color("#991b1b")) - _circle(img, 12, 12, 7, Color("#ef4444")) - _line(img, 6, 18, 18, 6, Color("#f8fafc")) - _line(img, 7, 18, 18, 7, Color("#cbd5e1")) - _line(img, 6, 6, 18, 18, Color("#f8fafc")) - _line(img, 7, 6, 18, 17, Color("#cbd5e1")) - _circle(img, 12, 12, 2, Color("#fde047")) - "icon_broken_link": - _circle(img, 12, 12, 9, Color("#78350f")) - _circle(img, 12, 12, 7, Color("#d97706")) - _rect(img, 5, 10, 5, 4, Color("#e2e8f0")) - _rect(img, 14, 10, 5, 4, Color("#e2e8f0")) - _line(img, 10, 7, 14, 17, Color("#ef4444")) - _line(img, 14, 7, 10, 17, Color("#ef4444")) - _: - _circle(img, 12, 12, 6, Color("#eab308")) - _apply_soft_outline(img) - return img - - -static func _draw_thought_bubble() -> Image: - var img := _new_canvas(28, 28) - _ellipse(img, 14, 12, 10, 8, Color("#fffbeb")) - _circle(img, 8, 12, 5, Color("#fffbeb")) - _circle(img, 20, 12, 5, Color("#fffbeb")) - _circle(img, 14, 7, 5, Color("#fffbeb")) - _circle(img, 9, 22, 2, Color("#fffbeb")) - _circle(img, 6, 25, 1, Color("#fffbeb")) - _apply_soft_outline(img, Color("#4a2c18")) - return img - - -static func _draw_heart(img: Image, main_col: Color, hi_col: Color) -> void: - _circle(img, 9, 10, 4, main_col) - _circle(img, 15, 10, 4, main_col) - for y in range(11, 19): - var hw := 19 - y - _rect(img, 12 - hw, y, hw * 2 + 1, 1, main_col) - _px(img, 8, 8, hi_col) - _px(img, 9, 8, hi_col) diff --git a/src/world/world_renderer.gd b/src/world/world_renderer.gd index 5f1c7ac..e5b058b 100644 --- a/src/world/world_renderer.gd +++ b/src/world/world_renderer.gd @@ -3,6 +3,7 @@ extends Node2D const PixelArtGen = preload("res://src/world/pixel_art_gen.gd") const CameraController = preload("res://src/world/camera_controller.gd") +const OceanWavesShader = preload("res://src/world/shaders/ocean_waves.gdshader") const Prop = EvoPropFloraRenderer.Prop signal hex_clicked(hex_id: String, button_index: int) @@ -10,72 +11,7 @@ signal hex_dragged(hex_id: String) signal deme_clicked(deme_id: String, hex_id: String) const MAX_COLUMN_HEIGHT_PX: float = 96.0 - -const BIOME_PALETTE: Dictionary = { - "tropical_forest": { - "top": Color("#2a8c4a"), - "edge": Color("#196132"), - "tuft": Color("#3eb864"), - "cliff_top": Color("#7c5333"), - "cliff_bot": Color("#4d301b"), - "overhang": Color("#258545") - }, - "savanna": { - "top": Color("#c99e32"), - "edge": Color("#946c1c"), - "tuft": Color("#e8be4e"), - "cliff_top": Color("#9c6234"), - "cliff_bot": Color("#633a1c"), - "overhang": Color("#cfa136") - }, - "arid_steppe": { - "top": Color("#d4873f"), - "edge": Color("#9e5822"), - "tuft": Color("#eda05c"), - "cliff_top": Color("#a85832"), - "cliff_bot": Color("#6e3419"), - "overhang": Color("#cc7735") - }, - "tundra": { - "top": Color("#b8ced9"), - "edge": Color("#7592a3"), - "tuft": Color("#eaf4f8"), - "cliff_top": Color("#64748b"), - "cliff_bot": Color("#3f4c5c"), - "overhang": Color("#e2eff5") - }, - "high_mountain": { - "top": Color("#787572"), - "edge": Color("#4f4c49"), - "tuft": Color("#cbd5e1"), - "cliff_top": Color("#5c5855"), - "cliff_bot": Color("#363331"), - "overhang": Color("#99928b") - }, - "coastal_shallows": { - "top": Color("#32a89d"), - "edge": Color("#d9be7e"), - "tuft": Color("#ebd294"), - "cliff_top": Color("#a88e58"), - "cliff_bot": Color("#705c35"), - "overhang": Color("#d9be7e") - }, - "ocean": { - "top": Color("#1d5f8a"), - "edge": Color("#144566"), - "tuft": Color("#7dd3fc"), - "cliff_top": Color("#144566"), - "cliff_bot": Color("#0d2f47"), - "overhang": Color("#216899") - } -} - -const C_ROCK_CRACK := Color("#23150c") -const C_CLIFF_OUTLINE := Color("#26160c") -const C_SAND := Color("#ebd294") -const C_SURF := Color(0.95, 0.99, 1.0, 0.85) -const C_RIPPLE := Color(0.90, 0.98, 1.0, 0.60) -const C_SELECTION := Color("#fde047") +const BIOME_PALETTE := EvoHexColumnRenderer.BIOME_PALETTE var world_state: EvoTypes.WorldState var camera: CameraController @@ -89,6 +25,8 @@ var hovered_hex_id: String = "" var hovered_deme_id: String = "" var brush_radius: int = 1 +var _backdrop_rect := ColorRect.new() +var _ocean_shader_mat := ShaderMaterial.new() var _world_layer := Node2D.new() var _terrain_layer := Node2D.new() var _network_layer := Node2D.new() @@ -97,8 +35,12 @@ var _overlay_layer := Node2D.new() var _anim_time: float = 0.0 var _fx_accum: float = 0.0 +var _walker_accum: float = 0.0 var _last_vp_size: Vector2 = Vector2.ZERO var _dirty: bool = true +var _overlay_dirty: bool = true +var _last_camera_zoom: float = -1.0 +var _last_active_lens: String = "biome" var _built_yaw: float = NAN var _built_pitch: float = NAN var _ground := Transform2D.IDENTITY @@ -118,11 +60,15 @@ var _ocean_ids: Array[String] = [] var _brush_hexes: Array[String] = [] var _roads: Array[Dictionary] = [] var _villages: Array[Dictionary] = [] +var _village_by_deme_id: Dictionary = {} +var _rural_cell_ids: Array[String] = [] var _static_props: Array[Prop] = [] var _deme_hit_rects: Array[Dictionary] = [] var _column_state_cache: Dictionary = {} var _last_sort_signature: Array = [] var _cell_flora_cache: Dictionary = {} +var _last_season: String = "" +var _last_network_cull_rect: Rect2 = Rect2() var _press_pos: Vector2 = Vector2.ZERO var _is_left_down: bool = false @@ -133,10 +79,26 @@ var _last_world_transform: Transform2D = Transform2D.IDENTITY var _moving_prop_pool: Array[Prop] = [] var _moving_prop_count: int = 0 var _villager_tex_sets: Dictionary = {} +var _fx_active: Array[Dictionary] = [] +var _fx_last_gen: int = -1 +var replay_seconds: float = 2.0 func _init() -> void: - texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _world_layer.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _terrain_layer.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _network_layer.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _anim_layer.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _overlay_layer.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + + _ocean_shader_mat.shader = OceanWavesShader + _backdrop_rect.material = _ocean_shader_mat + _backdrop_rect.color = Color("#0e3352") + _backdrop_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + _backdrop_rect.show_behind_parent = true + add_child(_backdrop_rect) + for i in range(6): var rad := deg_to_rad(60.0 * float(i) - 30.0) _hex_uvs.append(Vector2(0.5 + cos(rad) * 0.46, 0.5 + sin(rad) * 0.46)) @@ -157,12 +119,20 @@ func _ready() -> void: PixelArtGen.ensure_all_assets() +func _get_camera_zoom() -> float: + return camera.zoom_level if camera != null else 1.0 + + func _process(delta: float) -> void: if world_state == null or camera == null: return _anim_time += delta EvoWorldOverlayRenderer.update_particles(self, delta) + if active_lens != _last_active_lens: + _last_active_lens = active_lens + _overlay_dirty = true + if _dirty: _rebuild() elif camera.yaw_deg != _built_yaw or camera.pitch_deg != _built_pitch: @@ -170,9 +140,19 @@ func _process(delta: float) -> void: var vp_size := get_viewport_rect().size if is_inside_tree() else Vector2(1600.0, 960.0) var vp_changed := vp_size != _last_vp_size + if vp_changed: + _overlay_dirty = true _last_vp_size = vp_size + _backdrop_rect.size = vp_size + if world_state.season != _last_season: + _update_ocean_shader_season() _world_layer.position = vp_size * 0.5 + camera.pan_offset - _world_layer.scale = Vector2(camera.zoom_level, camera.zoom_level) + var zoom_val := camera.zoom_level + _world_layer.scale = Vector2(zoom_val, zoom_val) + var zoom_changed := absf(zoom_val - _last_camera_zoom) > 0.005 + if zoom_changed: + _last_camera_zoom = zoom_val + _overlay_dirty = true var inv_xform := _world_layer.transform.affine_inverse() var tl := inv_xform * Vector2.ZERO @@ -181,15 +161,8 @@ func _process(delta: float) -> void: if is_inside_tree() and _world_layer.transform != _last_world_transform: _last_world_transform = _world_layer.transform - var mouse_pos := get_viewport().get_mouse_position() - var hit_hex := _pick_hex_at(mouse_pos) - if hit_hex != hovered_hex_id: - hovered_hex_id = hit_hex - _update_brush() - var hit_deme := _pick_deme_at(mouse_pos) - var new_hov_deme := str(hit_deme.get("deme_id", "")) - if new_hov_deme != hovered_deme_id: - hovered_deme_id = new_hov_deme + _overlay_dirty = true + if _last_network_cull_rect.size == Vector2.ZERO or not _last_network_cull_rect.encloses(_visible_world_rect): _network_layer.queue_redraw() _fx_accum += delta @@ -198,18 +171,31 @@ func _process(delta: float) -> void: _fx_accum = fposmod(_fx_accum, 0.083) step_fx = true - _bob_ocean_columns() + _walker_accum += delta + var step_walker := false + if _walker_accum >= 0.05: + _walker_accum = fposmod(_walker_accum, 0.05) + step_walker = true + + if step_fx or step_walker or zoom_changed: + _bob_ocean_columns() + _anim_layer.queue_redraw() + if not _fx_active.is_empty() or active_lens == "health": + _overlay_dirty = true + if step_fx or vp_changed: for hex_id in _animated_fx_ids: if _visible_world_rect.has_point(_hex_centers[hex_id]): _water_fx[hex_id].queue_redraw() - queue_redraw() - _anim_layer.queue_redraw() - _overlay_layer.queue_redraw() + + if _overlay_dirty: + _overlay_layer.queue_redraw() func set_world_state(p_state: EvoTypes.WorldState) -> void: world_state = p_state + _fx_last_gen = p_state.generation if p_state != null else -1 + _fx_active.clear() _create_columns() _rebuild() @@ -217,10 +203,50 @@ func set_world_state(p_state: EvoTypes.WorldState) -> void: func set_camera(p_camera: CameraController) -> void: camera = p_camera _dirty = true + _overlay_dirty = true func refresh() -> void: _dirty = true + _overlay_dirty = true + + +func refresh_overlay(state: EvoTypes.WorldState = null) -> void: + if state != null: + world_state = state + _brush_hexes = _compute_brush_hexes() + for village in _villages: + var deme: EvoTypes.Deme = village["deme"] + var is_dimmed: bool = highlighted_lineage_id != "" and deme.lineage_id != highlighted_lineage_id + village["is_dimmed"] = is_dimmed + var base_mod: Color = Color(1.0, 0.72, 0.68, 1.0) if deme.get_vital_status() == "starving" else Color.WHITE + if is_dimmed: + base_mod.a = 0.42 + village["modulate"] = base_mod + _overlay_dirty = true + _overlay_layer.queue_redraw() + + +func update_hover(screen_pos: Vector2) -> void: + var hit_hex := _pick_hex_at(screen_pos) + if hit_hex != hovered_hex_id: + hovered_hex_id = hit_hex + _update_brush() + _overlay_dirty = true + var hit_deme := _pick_deme_at(screen_pos) + var new_hov_deme := str(hit_deme.get("deme_id", "")) + if new_hov_deme != hovered_deme_id: + hovered_deme_id = new_hov_deme + _overlay_dirty = true + + +func _update_ocean_shader_season() -> void: + if world_state == null: + return + _last_season = world_state.season + var is_wet := _last_season == "wet_warm" + _ocean_shader_mat.set_shader_parameter("deep_color", Vector4(0.055, 0.20, 0.32, 1.0) if is_wet else Vector4(0.043, 0.157, 0.251, 1.0)) + _ocean_shader_mat.set_shader_parameter("mid_color", Vector4(0.095, 0.34, 0.51, 1.0) if is_wet else Vector4(0.075, 0.27, 0.42, 1.0)) func refresh_village_decisions() -> void: @@ -234,24 +260,19 @@ func refresh_village_decisions() -> void: if any_arch_changed: _static_props = EvoPropFloraRenderer.collect_static_props(self) _network_layer.queue_redraw() + _overlay_dirty = true _overlay_layer.queue_redraw() func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion: + if camera != null and camera.is_dragging(): + return var mm := event as InputEventMouseMotion - var hit_hex := _pick_hex_at(mm.position) - if hit_hex != hovered_hex_id: - hovered_hex_id = hit_hex - _update_brush() - var hit_deme := _pick_deme_at(mm.position) - var new_hov_deme := str(hit_deme.get("deme_id", "")) - if new_hov_deme != hovered_deme_id: - hovered_deme_id = new_hov_deme - _network_layer.queue_redraw() - if _is_left_down and active_tool != "inspect" and hit_hex != "" and hit_hex != _last_dragged_hex_id: - _last_dragged_hex_id = hit_hex - hex_dragged.emit(hit_hex) + update_hover(mm.position) + if _is_left_down and active_tool != "inspect" and hovered_hex_id != "" and hovered_hex_id != _last_dragged_hex_id: + _last_dragged_hex_id = hovered_hex_id + hex_dragged.emit(hovered_hex_id) elif event is InputEventMouseButton: var mb := event as InputEventMouseButton @@ -312,21 +333,28 @@ func _create_columns() -> void: func _rebuild() -> void: _dirty = false + _overlay_dirty = true _built_yaw = camera.yaw_deg _built_pitch = camera.pitch_deg _ground = camera.ground_transform() _lift = camera.height_scale() + _update_ocean_shader_season() _brush_hexes = _compute_brush_hexes() _rebuild_cell_topology() _layout_columns() _villages = EvoPropFloraRenderer.collect_villages(self) _roads = EvoRoadNetworkRenderer.collect_roads(self) _static_props = EvoPropFloraRenderer.collect_static_props(self) + if world_state != null and world_state.generation != _fx_last_gen: + _fx_last_gen = world_state.generation + EvoEventFxRenderer.enqueue_turn(self, world_state.turn_events, replay_seconds) _network_layer.queue_redraw() - queue_redraw() + _anim_layer.queue_redraw() + _overlay_layer.queue_redraw() func _reproject() -> void: + _overlay_dirty = true _built_yaw = camera.yaw_deg _built_pitch = camera.pitch_deg _ground = camera.ground_transform() @@ -334,14 +362,15 @@ func _reproject() -> void: _layout_columns() for village in _villages: var v_pos := _project(village["world_xy"], village["top_z"]) - var sf: float = village["sf"] village["pos"] = v_pos - village["hit_rect"] = Rect2(v_pos - Vector2(74, 76) * sf, Vector2(148, 116) * sf) + village["hit_rect"] = Rect2(v_pos - Vector2(104, 92), Vector2(208, 168)) EvoRoadNetworkRenderer.reproject_roads(self) for prop in _static_props: prop.project(_ground, _lift) _static_props.sort_custom(func(a: Prop, b: Prop) -> bool: return a.y < b.y) _network_layer.queue_redraw() + _anim_layer.queue_redraw() + _overlay_layer.queue_redraw() func _rebuild_cell_topology() -> void: @@ -408,6 +437,12 @@ func _layout_columns() -> void: _terrain_layer.move_child(column, i) _terrain_layer.modulate = Color(0.94, 0.96, 0.99, 1.0) if (active_lens == "biome" and world_state.season == "dry_cold") else Color.WHITE + if active_lens == "biome": + _anim_layer.modulate = Color.WHITE + _network_layer.modulate = Color.WHITE + else: + _anim_layer.modulate = Color(1.0, 1.0, 1.0, 0.42) + _network_layer.modulate = Color(1.0, 1.0, 1.0, 0.35) for i in range(_sorted_hex_ids.size()): var hex_id := _sorted_hex_ids[i] @@ -419,8 +454,7 @@ func _layout_columns() -> void: var top_z := _get_cell_top_z(cell) var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) var k_band: int = 0 if k_ratio < 0.60 else (1 if k_ratio < 0.85 else 2) - var r_band: int = int(roundf(clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) * 50.0)) if active_lens == "food" else 0 - var is_brushed := _brush_hexes.has(hex_id) + var lens_band := _compute_lens_band(cell) var col_key: Array = [ _built_yaw, @@ -429,12 +463,12 @@ func _layout_columns() -> void: top_z, active_lens, k_band, - r_band, - hex_id == selected_hex_id, - is_brushed, - active_tool == "inspect" if is_brushed else false, + lens_band, _coast_dirs[hex_id], - _diff_biome_dirs[hex_id] + _diff_biome_dirs[hex_id], + cell.land_use, + cell.owner_deme_id, + cell.has_ruins ] if not _column_state_cache.has(hex_id) or _column_state_cache[hex_id] != col_key: @@ -443,6 +477,25 @@ func _layout_columns() -> void: _water_fx[hex_id].queue_redraw() +func _compute_lens_band(cell: EvoTypes.HexCell) -> int: + if active_lens == "food": + return int(roundf(clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) * 50.0)) + if active_lens == "health": + if not cell.deme_ids.is_empty() and world_state.demes.has(cell.deme_ids[0]): + var d: EvoTypes.Deme = world_state.demes[cell.deme_ids[0]] + return (1000 if d.infection_turns > 0 else 0) + (500 if d.immunity_turns > 0 else 0) + int(roundf(d.salubrity * 100.0)) + return -1 + if active_lens == "diplomacy" or active_lens == "territory": + if not cell.deme_ids.is_empty() and world_state.demes.has(cell.deme_ids[0]): + var d: EvoTypes.Deme = world_state.demes[cell.deme_ids[0]] + return d.strategy_id.hash() ^ (1 if EvoTypes._is_side_b_deme(world_state, d) else 2) + if (cell.land_use == "crops" or cell.land_use == "pasture") and cell.owner_deme_id != "" and world_state.demes.has(cell.owner_deme_id): + var owner: EvoTypes.Deme = world_state.demes[cell.owner_deme_id] + return owner.strategy_id.hash() ^ (3 if EvoTypes._is_side_b_deme(world_state, owner) else 4) + return 0 + return 0 + + func _compute_brush_hexes() -> Array[String]: if hovered_hex_id == "": return [] @@ -450,11 +503,9 @@ func _compute_brush_hexes() -> Array[String]: func _update_brush() -> void: - var previous := _brush_hexes _brush_hexes = _compute_brush_hexes() - for hex_id in previous + _brush_hexes: - _column_state_cache.erase(hex_id) - _columns[hex_id].queue_redraw() + _overlay_dirty = true + _overlay_layer.queue_redraw() func _bob_ocean_columns() -> void: @@ -515,211 +566,16 @@ func _cell_seed(q: int, r: int, salt: int = 0) -> int: # BACKDROP & TERRAIN COLUMNS # ============================================================================ -func _draw() -> void: - if world_state == null or camera == null: - return - var vp_size := get_viewport_rect().size - var is_wet := world_state.season == "wet_warm" - draw_rect(Rect2(Vector2.ZERO, vp_size), Color("#0e3352") if is_wet else Color("#0b2840"), true) - - var band_col := Color("#134066") if is_wet else Color("#103352") - var band_h := 48.0 - var num_bands := int(vp_size.y / band_h) + 1 - for b in range(0, num_bands, 2): - draw_rect(Rect2(0.0, float(b) * band_h, vp_size.x, band_h * 0.55), band_col, true) - - var wave_col := Color(0.42, 0.78, 0.96, 0.18) - var foam_col := Color(0.85, 0.96, 1.0, 0.22) - var rows := int(vp_size.y / 96.0) + 1 - var cols := int(vp_size.x / 180.0) + 1 - for ry in range(rows): - for cx in range(cols): - var phase := _anim_time * 1.9 + float(ry) * 0.75 + float(cx) * 0.55 - var wx := float(cx * 180) + sin(phase) * 9.0 + float((ry % 2) * 82) - var wy := float(ry * 96) + cos(phase * 0.8) * 3.0 - draw_rect(Rect2(wx, wy, 16.0, 2.0), wave_col, true) - draw_rect(Rect2(wx + 4.0, wy - 2.0, 8.0, 2.0), foam_col, true) - - func _draw_hex_column(column: Node2D, hex_id: String) -> void: - var cell: EvoTypes.HexCell = world_state.cells[hex_id] - var is_ocean := _is_water(cell) - var eff_biome: String = "ocean" if is_ocean else cell.biome - var pal: Dictionary = BIOME_PALETTE.get(eff_biome, BIOME_PALETTE["savanna"]) - var top_pts: PackedVector2Array = _hex_polys[hex_id] - - if not is_ocean: - var bot_pts := _project_hex(HexMath.axial_to_world(cell.q, cell.r), 0.0) - _draw_cliff_walls(column, cell, pal, top_pts, bot_pts) - - var tint_col := _get_top_tint(cell, is_ocean) - var v_colors := PackedColorArray([tint_col, tint_col, tint_col, tint_col, tint_col, tint_col]) - column.draw_polygon(top_pts, v_colors, _hex_uvs, PixelArtGen.get_texture("hex_tile_" + eff_biome)) - - if not is_ocean: - _draw_hex_borders(column, cell, pal, tint_col, top_pts) - if active_lens == "health": - var k_ratio: float = cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max) - if k_ratio < 0.60: - _draw_dashed_hex_border(column, top_pts, Color("#ef4444"), 2.6) - elif k_ratio < 0.85: - _draw_dashed_hex_border(column, top_pts, Color("#f59e0b"), 2.0) - - var border_pts := top_pts.duplicate() - border_pts.append(top_pts[0]) - if hex_id == selected_hex_id: - column.draw_polyline(border_pts, C_SELECTION, 3.4) - elif _brush_hexes.has(hex_id): - var brush_col := Color("#38bdf8") if active_tool == "inspect" else Color("#fb923c") - column.draw_polyline(border_pts, brush_col, 2.6) - - -func _draw_cliff_walls( - column: Node2D, - cell: EvoTypes.HexCell, - pal: Dictionary, - top_pts: PackedVector2Array, - bot_pts: PackedVector2Array -) -> void: - var tier: int = clampi(int(ceilf(cell.altitude * 10.0)), 1, 10) - var strata_count: int = mini(tier + 1, 6) - var cliff_top_col: Color = pal["cliff_top"] - var cliff_bot_col: Color = pal["cliff_bot"] - var center_y: float = _hex_centers[cell.id].y - var strata := PackedVector2Array() - var cracks := PackedVector2Array() - var vines := PackedVector2Array() - var outlines := PackedVector2Array() - - for i in range(6): - var next_i: int = (i + 1) % 6 - var p0 := top_pts[i] - var p1 := top_pts[next_i] - if (p0.y + p1.y) * 0.5 - center_y <= 0.5: - continue - var b1 := bot_pts[next_i] - var b0 := bot_pts[i] - - var shade := 1.0 - float(i % 3) * 0.13 - var wall_col := cliff_top_col.lerp(cliff_bot_col, 0.45) * Color(shade, shade, shade, 1.0) - column.draw_colored_polygon(PackedVector2Array([p0, p1, b1, b0]), wall_col) - column.draw_colored_polygon(PackedVector2Array([p0, p1, p1.lerp(b1, 0.18), p0.lerp(b0, 0.18)]), cliff_top_col.darkened(0.18 * shade)) - - for s_idx in range(1, strata_count + 1): - var t: float = float(s_idx) / float(strata_count + 1) - strata.append(p0.lerp(b0, t)) - strata.append(p1.lerp(b1, t)) - - for c_idx in range(3): - var u := 0.22 + float(c_idx) * 0.28 + float(_cell_seed(cell.q, cell.r, i * 10 + c_idx) % 9 - 4) * 0.015 - var c_top := p0.lerp(p1, u) - var c_bot := b0.lerp(b1, u) - var t_start := 0.15 + float((c_idx + i) % 2) * 0.18 - cracks.append(c_top.lerp(c_bot, t_start)) - cracks.append(c_top.lerp(c_bot, minf(0.92, t_start + 0.45))) - - for v_idx in range(1, 5): - var vu := float(v_idx) / 5.0 - var v_top := p0.lerp(p1, vu) - var hang_len := 0.16 + float(_cell_seed(cell.q, cell.r, i * 31 + v_idx) % 22) * 0.012 - vines.append(v_top) - vines.append(v_top.lerp(b0.lerp(b1, vu), hang_len)) - - outlines.append_array(PackedVector2Array([p0, p1, p1, b1, b1, b0, b0, p0])) - - if outlines.is_empty(): - return - column.draw_multiline(strata, cliff_bot_col.darkened(0.22), 1.5) - column.draw_multiline(cracks, C_ROCK_CRACK, 1.3) - column.draw_multiline(vines, (pal["overhang"] as Color).darkened(0.08), 2.2) - column.draw_multiline(outlines, C_CLIFF_OUTLINE, 1.2) + EvoHexColumnRenderer.draw_hex_column(self, column, hex_id) func _get_top_tint(cell: EvoTypes.HexCell, is_ocean: bool) -> Color: - var tint_col := Color.WHITE - if is_ocean: - return tint_col - if active_lens == "food": - var ratio_r: float = clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) - if ratio_r < 0.35: - return Color("#8c583a").lerp(Color("#eab308"), ratio_r / 0.35) - return Color("#eab308").lerp(Color("#65f06c"), (ratio_r - 0.35) / 0.65) - if active_lens == "health": - var ratio_k: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) - if ratio_k < 0.60: - return Color("#f87171") - if ratio_k < 0.85: - return Color("#fbbf24") - return Color("#6ee7b7") - var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) - if k_ratio < 0.60: - tint_col = tint_col.lerp(Color("#9c6b4e"), 0.42) - elif k_ratio < 0.85: - tint_col = tint_col.lerp(Color("#c29b61"), 0.22) - return tint_col - - -func _draw_hex_borders(column: Node2D, cell: EvoTypes.HexCell, pal: Dictionary, tint_col: Color, top_pts: PackedVector2Array) -> void: - var coast_dirs: PackedInt32Array = _coast_dirs[cell.id] - var diff_dirs: PackedInt32Array = _diff_biome_dirs[cell.id] - var sand_lines := PackedVector2Array() - var edge_lines := PackedVector2Array() - var tufts := PackedVector2Array() - for dir_idx in range(6): - var c0 := top_pts[dir_idx] - var c1 := top_pts[(dir_idx + 1) % 6] - if coast_dirs.has(dir_idx): - sand_lines.append(c0) - sand_lines.append(c1) - continue - edge_lines.append(c0) - edge_lines.append(c1) - var tuft_steps := 5 if diff_dirs.has(dir_idx) else 3 - for ts in range(1, tuft_steps + 1): - var jitter := Vector2( - float(_cell_seed(cell.q, cell.r, dir_idx * 17 + ts) % 5 - 2), - float(_cell_seed(cell.q, cell.r, dir_idx * 23 + ts) % 5 - 2) - ) * 0.6 - var pt := c0.lerp(c1, float(ts) / float(tuft_steps + 1)) + jitter - tufts.append(pt - Vector2(2.0, 0.0)) - tufts.append(pt + Vector2(2.0, 0.0)) - - if not sand_lines.is_empty(): - column.draw_multiline(sand_lines, C_SAND, 3.2) - if not edge_lines.is_empty(): - column.draw_multiline(edge_lines, (pal["edge"] as Color).darkened(0.18), 1.2) - if not tufts.is_empty(): - column.draw_multiline(tufts, pal["tuft"] * tint_col, 3.8) - - -func _draw_dashed_hex_border(column: Node2D, pts: PackedVector2Array, col: Color, width: float) -> void: - var dashes := PackedVector2Array() - for i in range(6): - var a := pts[i] - var b := pts[(i + 1) % 6] - dashes.append_array(PackedVector2Array([a.lerp(b, 0.14), a.lerp(b, 0.44), a.lerp(b, 0.58), a.lerp(b, 0.88)])) - column.draw_multiline(dashes, col, width) + return EvoHexColumnRenderer.get_top_tint(self, cell, is_ocean) func _draw_water_fx(fx: Node2D, hex_id: String) -> void: - var cell: EvoTypes.HexCell = world_state.cells[hex_id] - var center: Vector2 = _hex_centers[hex_id] - if _is_water(cell) or cell.biome == "coastal_shallows": - var phase := _anim_time * 2.4 + float(cell.q) * 1.1 + float(cell.r) * 0.8 - var wave_offset := Vector2(sin(phase) * 7.5, cos(phase) * 3.8) - fx.draw_line(center + wave_offset + Vector2(-24, -10), center + wave_offset + Vector2(24, -10), C_RIPPLE, 2.0) - fx.draw_line(center - wave_offset + Vector2(-18, 12), center - wave_offset + Vector2(20, 12), C_RIPPLE, 1.6) - - var top_pts: PackedVector2Array = _hex_polys[hex_id] - for dir_idx in _coast_dirs[hex_id]: - var surf_pulse := 0.5 + 0.5 * sin(_anim_time * 3.2 + float(dir_idx + cell.q)) - var inset := 0.06 + surf_pulse * 0.04 - fx.draw_line( - top_pts[dir_idx].lerp(center, inset), - top_pts[(dir_idx + 1) % 6].lerp(center, inset), - C_SURF, - 2.0 + surf_pulse - ) + EvoHexColumnRenderer.draw_water_fx(self, fx, hex_id) # ============================================================================ @@ -727,9 +583,10 @@ func _draw_water_fx(fx: Node2D, hex_id: String) -> void: # ============================================================================ func _draw_network() -> void: - EvoRoadNetworkRenderer.draw_network_roads(self) + _last_network_cull_rect = _visible_world_rect.grow(HexMath.HEX_SIZE * 1.5) if _visible_world_rect.size != Vector2.ZERO else Rect2() for village in _villages: EvoPropFloraRenderer.draw_village_plaza(self, village) + EvoRoadNetworkRenderer.draw_network_roads(self) func _draw_animated() -> void: @@ -742,31 +599,26 @@ func _draw_animated() -> void: var v_pos: Vector2 = village["pos"] if not _visible_world_rect.has_point(v_pos): continue - var sf: float = village["sf"] var beam_col := Color(deme.color) beam_col.a = 0.28 + 0.10 * sin(_anim_time * 4.0) - var beam_w := 44.0 * sf - _anim_layer.draw_rect(Rect2(v_pos.x - beam_w * 0.5, v_pos.y - 180.0 * sf, beam_w, 180.0 * sf), beam_col, true) + var beam_w := 44.0 + _anim_layer.draw_rect(Rect2(v_pos.x - beam_w * 0.5, v_pos.y - 180.0, beam_w, 180.0), beam_col, true) EvoPropFloraRenderer.draw_props(self) func _draw_overlay() -> void: + _overlay_dirty = false EvoWorldOverlayRenderer.draw_overlay(self) -func _get_villager_textures_for_color(tunic_col: Color) -> Array[ImageTexture]: - return EvoPropFloraRenderer.get_villager_textures_for_color(self, tunic_col) - - func _get_village_building_layout( tier: int, - sf: float, - has_culture: bool, - cultural_count: int, - archetype: String + has_culture: bool = false, + cultural_count: int = 0, + archetype: String = "forage" ) -> Array[Dictionary]: - return EvoPropFloraRenderer.get_village_building_layout(tier, sf, has_culture, cultural_count, archetype) + return EvoPropFloraRenderer.get_village_building_layout(tier, has_culture, cultural_count, archetype) func _get_deme_dominant_archetype(deme: EvoTypes.Deme) -> String: @@ -790,12 +642,12 @@ func _pick_deme_at(screen_pos: Vector2) -> Dictionary: func _pick_hex_at(screen_pos: Vector2) -> String: - var local_pos := _world_layer.transform.affine_inverse() * screen_pos - var max_r_sq := HexMath.HEX_SIZE * HexMath.HEX_SIZE * 1.35 + var world_pos := _world_layer.transform.affine_inverse() * screen_pos for i in range(_sorted_hex_ids.size() - 1, -1, -1): var hex_id := _sorted_hex_ids[i] - if local_pos.distance_squared_to(_hex_centers.get(hex_id, local_pos)) > max_r_sq: + var pos: Vector2 = _hex_centers.get(hex_id, world_pos) + if absf(world_pos.x - pos.x) > 210.0 or absf(world_pos.y - pos.y) > 242.0: continue - if Geometry2D.is_point_in_polygon(local_pos, _hex_polys[hex_id]): + if Geometry2D.is_point_in_polygon(world_pos, _hex_polys[hex_id]): return hex_id return "" diff --git a/tests/sim_emergence.gd b/tests/sim_emergence.gd new file mode 100644 index 0000000..3c9078d --- /dev/null +++ b/tests/sim_emergence.gd @@ -0,0 +1,117 @@ +extends SceneTree + +const MATCHUPS: Array[Dictionary] = [ + {"preset": "preset-rift-savanna", "strat_a": "strat-cooperation", "strat_b": "strat-war"}, + {"preset": "preset-finite-island", "strat_a": "strat-trade", "strat_b": "strat-expansion"}, + {"preset": "preset-darwin-archipelago", "strat_a": "strat-innovation", "strat_b": "strat-war"}, + {"preset": "preset-finite-island", "strat_a": "strat-cooperation", "strat_b": "strat-expansion"} +] + + +func _init() -> void: + var gens_env := OS.get_environment("SIM_GENS") + var max_gens := int(gens_env) if gens_env != "" else 30 + var status := EvoOllamaClient._probe_ollama_status_sync() + print("=== EVOLUTION LIVE OLLAMA EMERGENCE HARNESS (%d GENS x %d MATCHUPS) ===" % [max_gens, MATCHUPS.size()]) + print("Ollama status: connected=%s | primary=%s (%s) | secondary=%s (%s) | ctx=%d" % [ + str(status["connected"]), + str(status["primary_tag"]), + str(status["primary_ready"]), + str(status["secondary_tag"]), + str(status["secondary_ready"]), + EvoUserSettings.context_length + ]) + if not bool(status["connected"]): + printerr("ERROR: Ollama is offline. Cannot run live emergence simulation.") + quit(1) + return + + var global_actions: Dictionary = {} + var global_chronicle: Dictionary = {} + var global_turn_events: Dictionary = {} + var actions_by_strat: Dictionary = {} + + for idx in range(MATCHUPS.size()): + var m: Dictionary = MATCHUPS[idx] + var preset_id := str(m["preset"]) + var strat_a := str(m["strat_a"]) + var strat_b := str(m["strat_b"]) + print("\n--------------------------------------------------------------------------------") + print("Matchup %d/%d: %s | %s (A) vs %s (B)" % [idx + 1, MATCHUPS.size(), preset_id, strat_a, strat_b]) + var t0 := Time.get_ticks_msec() + var state := EvoPresets.create_preset_world(preset_id, "en", strat_a, strat_b) + state.rng_seed = 1337 + idx * 101 + state.rng.seed = state.rng_seed + var m_actions: Dictionary = {} + var m_turn_events: Dictionary = {} + + for _g in range(max_gens): + while EvoDecisionModel.has_pending_jobs(): + OS.delay_msec(10) + EvoDecisionModel.apply_completed_live_results(state, "en", -1) + for did in state.demes.keys(): + var d: EvoTypes.Deme = state.demes[did] + var act_type := str(d.chosen_action["type"]) + m_actions[act_type] = int(m_actions.get(act_type, 0)) + 1 + global_actions[act_type] = int(global_actions.get(act_type, 0)) + 1 + if not actions_by_strat.has(d.strategy_id): + actions_by_strat[d.strategy_id] = {} + var s_map: Dictionary = actions_by_strat[d.strategy_id] + s_map[act_type] = int(s_map.get(act_type, 0)) + 1 + + EvoTurnEngine.advance_generation(state, "en") + for ev in state.turn_events: + var et := str(ev["type"]) + m_turn_events[et] = int(m_turn_events.get(et, 0)) + 1 + global_turn_events[et] = int(global_turn_events.get(et, 0)) + 1 + + EvoDecisionModel.wait_for_workers() + var elapsed_s := float(Time.get_ticks_msec() - t0) / 1000.0 + + var m_chronicle: Dictionary = {} + for entry in state.chronicle: + m_chronicle[entry.event_type] = int(m_chronicle.get(entry.event_type, 0)) + 1 + global_chronicle[entry.event_type] = int(global_chronicle.get(entry.event_type, 0)) + 1 + + var pop_a := 0 + var pop_b := 0 + var demes_a := 0 + var demes_b := 0 + var max_tier_a := 0 + var max_tier_b := 0 + for did in state.demes.keys(): + var d: EvoTypes.Deme = state.demes[did] + if d.strategy_id == strat_a: + pop_a += d.population + demes_a += 1 + max_tier_a = maxi(max_tier_a, d.get_village_tier()) + else: + pop_b += d.population + demes_b += 1 + max_tier_b = maxi(max_tier_b, d.get_village_tier()) + + var active_pacts := 0 + var active_feuds := 0 + for pk in state.relations.keys(): + var rel: EvoTypes.Relation = state.relations[pk] + if rel.pact_turns > 0: + active_pacts += 1 + if rel.feud_turns > 0: + active_feuds += 1 + + print("Completed in %.1fs | Final A (%s): %d settlements, %d pop, max tier %d | Final B (%s): %d settlements, %d pop, max tier %d | Active pacts=%d, feuds=%d" % [ + elapsed_s, strat_a, demes_a, pop_a, max_tier_a, strat_b, demes_b, pop_b, max_tier_b, active_pacts, active_feuds + ]) + print(" Actions chosen: %s" % JSON.stringify(m_actions)) + print(" Turn events: %s" % JSON.stringify(m_turn_events)) + print(" Chronicle logs: %s" % JSON.stringify(m_chronicle)) + + print("\n================================================================================") + print("=== AGGREGATE EMERGENCE SUMMARY ===") + print("Global Actions Chosen: %s" % JSON.stringify(global_actions)) + print("Global Turn Events: %s" % JSON.stringify(global_turn_events)) + print("Global Chronicle Logs: %s" % JSON.stringify(global_chronicle)) + print("Actions by Strategy:") + for sid in actions_by_strat.keys(): + print(" %s: %s" % [sid, JSON.stringify(actions_by_strat[sid])]) + quit(0) diff --git a/tests/test_cognitive_ollama.gd b/tests/test_cognitive_ollama.gd new file mode 100644 index 0000000..4bd237d --- /dev/null +++ b/tests/test_cognitive_ollama.gd @@ -0,0 +1,151 @@ +class_name EvoTestCognitiveOllama +extends RefCounted + + +static func run(r) -> void: + _test_decision_model(r) + _test_nonblocking_async_decision_and_smooth_panning(r) + + +static func _test_decision_model(r) -> void: + print("-- Testing Decision Model, Action Menu, Outcome Forecasts & Policy Sampling --") + var ids: Array[String] = ["forage", "intensify", "settle:-1,1"] + var raw := {"forage": 0.52, "intensify": 0.31, "settle:-1,1": 0.17} + var probs := EvoOllamaClient.normalize_probabilities(raw, ids) + var sum_p: float = 0.0 + for k in probs.keys(): + sum_p += float(probs[k]) + r._assert(is_equal_approx(sum_p, 1.0), "Normalized probabilities sum to 1.0000") + r._assert(is_equal_approx(float(probs["forage"]), 0.52), "Normalized probability for forage is 0.52") + + var dm_inst := EvoDecisionModel.new() + r._assert(not dm_inst.has_method("evaluate_deme_heuristic"), "EvoDecisionModel has no evaluate_deme_heuristic fallback") + + var rift := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var west: EvoTypes.Deme = rift.demes["deme-rift-west"] + var east: EvoTypes.Deme = rift.demes["deme-rift-east"] + + var menu := EvoActionMenu.build(west, rift) + r._assert(menu.size() >= 3 and menu.size() <= 14, "EvoActionMenu.build generates bounded feasible menu (%d options)" % menu.size()) + var has_forage := false + var all_forecasts_nonempty := true + for opt in menu: + if str(opt["key"]) == "forage": + has_forage = true + var fc := EvoOutcomeForecast.describe(west, opt, rift) + if fc.strip_edges() == "": + all_forecasts_nonempty = false + r._assert(has_forage, "Action menu always includes baseline forage option") + r._assert(all_forecasts_nonempty, "EvoOutcomeForecast.describe produces quantified payoff text for every feasible option") + + var report := EvoSituationReport.build(west, rift) + r._assert(report.contains("Culture:") and report.contains("food stock") and report.contains("reputation") and report.contains("Neighbors:"), "EvoSituationReport.build includes culture doctrine, food stock, reputation and neighbors") + + EvoOllamaClient.seed_deme_result(west.id, { + "probabilities": {"forage": 0.99, "intensify": 0.01}, + "dominant_decision_id": "forage", + "confidence": 0.92 + }) + EvoOllamaClient.seed_deme_result(east.id, { + "probabilities": {"intensify": 0.99, "forage": 0.01}, + "dominant_decision_id": "intensify", + "confidence": 0.89 + }) + EvoDecisionModel.evaluate_all_demes(rift, "es", false) + r._assert(str(west.chosen_action.get("type", "")) in ["forage", "intensify"], "West deme applies seeded Ollama System 1 action") + r._assert(is_equal_approx(west.last_confidence, 0.92), "West deme records Ollama System 1 confidence") + r._assert(not west.action_forecasts.is_empty(), "West deme stores per-action outcome forecasts") + EvoOllamaClient.clear_cache() + + +static func _test_nonblocking_async_decision_and_smooth_panning(r) -> void: + print("-- Testing Non-Blocking Async Decision Model & Smooth Late-Game Panning --") + var t0_usec := Time.get_ticks_usec() + var status := EvoDecisionModel.check_ollama_status(true) + var status_ms := float(Time.get_ticks_usec() - t0_usec) / 1000.0 + r._assert(status.has("primary_tag") and status.has("secondary_tag"), "check_ollama_status(true) returns valid status dictionary immediately") + r._assert(status_ms < 15.0, "check_ollama_status(true) is non-blocking on main thread (took %.3f ms)" % status_ms) + + var packed: PackedScene = load("res://scenes/main.tscn") + var main: EvoMainController = packed.instantiate() as EvoMainController + main.quit_on_close_enabled = false + r.root.add_child(main) + if main.camera == null: + main._ready() + + main.load_preset("preset-rift-savanna") + main.hud._set_startup_blocked(false) + var state := main.world_state + + var land_hexes: Array[String] = [] + for hid in state.cells.keys(): + var c: EvoTypes.HexCell = state.cells[hid] + if c.biome != "ocean" and c.deme_ids.is_empty(): + land_hexes.append(hid) + + for i in range(14): + var target_hid := land_hexes[i % land_hexes.size()] + var d := EvoTypes.Deme.new() + d.id = "deme-perf-test-%d" % i + d.name = "Clan Perf %d" % i + d.hex_id = target_hid + d.population = 80 + (i * 15) + d.energy_reserves = 60.0 + float(i * 2) + d.lineage_id = "lineage-rift-west" if (i % 2 == 0) else "lineage-rift-east" + d.strategy_id = "strat-cooperation" if (i % 2 == 0) else "strat-war" + d.color = "#f59e0b" if (i % 2 == 0) else "#10b981" + d.biological_trait_ids.assign(["bio-thermal-tolerance", "bio-social-instinct"]) + if i % 3 == 0: + d.cultural_trait_ids.assign(["cul-communal-conservation"]) + d.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(d.id) + state.demes[d.id] = d + (state.cells[target_hid] as EvoTypes.HexCell).deme_ids.append(d.id) + + r._assert(state.demes.size() >= 16, "Late-game stress world populated with %d demes" % state.demes.size()) + + var eval_start_usec := Time.get_ticks_usec() + EvoDecisionModel.evaluate_all_demes(state, "es") + var eval_ms := float(Time.get_ticks_usec() - eval_start_usec) / 1000.0 + r._assert(eval_ms < 25.0, "evaluate_all_demes with 16 demes & live models enabled is non-blocking (took %.3f ms)" % eval_ms) + + main.world_renderer.refresh() + main.world_renderer._process(0.016) + r._assert(not main.world_renderer._column_state_cache.is_empty(), "WorldRenderer column visual state cache populated") + r._assert(not main.world_renderer._cell_flora_cache.is_empty(), "WorldRenderer static flora prop cache populated") + + var max_step_and_frame_ms: float = 0.0 + for _step_i in range(6): + main.camera.pan_offset += Vector2(28.0, -18.0) + main.camera._target_pan = main.camera.pan_offset + var frame_t0 := Time.get_ticks_usec() + main.step_generation() + main.camera._process(0.016) + main.world_renderer._process(0.016) + main._process(0.016) + var frame_ms := float(Time.get_ticks_usec() - frame_t0) / 1000.0 + max_step_and_frame_ms = maxf(max_step_and_frame_ms, frame_ms) + + print("[PERF] check_ollama_status=%.3f ms | evaluate_all_demes(16)=%.3f ms | max_turn_step_while_panning=%.3f ms" % [ + status_ms, eval_ms, max_step_and_frame_ms + ]) + r._assert(max_step_and_frame_ms < 25.0, "Turn step + renderer rebuild while panning with 16+ demes completes without stutter (max %.3f ms)" % max_step_and_frame_ms) + + main.camera.reset_camera() + main.camera.pan_offset = main.camera._target_pan + main.camera.zoom_level = main.camera._target_zoom + var cursor_offset := Vector2(320.0, -180.0) + var world_pt_before := (cursor_offset - main.camera._target_pan) / main.camera._target_zoom + main.camera.zoom_by(0.45, cursor_offset) + var world_pt_after := (cursor_offset - main.camera._target_pan) / main.camera._target_zoom + r._assert(world_pt_before.distance_to(world_pt_after) < 0.01, "Zoom around cursor pivot preserves exact world point under cursor") + + main.camera._target_zoom = EvoCameraController.MAX_ZOOM + main.camera.focus_on_hex(5, 0, 0.5) + r._assert(main.camera._target_pan.length() > 5000.0, "Outer ring hexes are reachable at MAX_ZOOM without premature clamping") + + var max_id_len: int = 0 + for did in state.demes.keys(): + max_id_len = maxi(max_id_len, str(did).length()) + r._assert(max_id_len < 64, "Deme IDs remain compact and bounded across generations (max len %d)" % max_id_len) + + main.free() diff --git a/tests/test_cognitive_ollama.gd.uid b/tests/test_cognitive_ollama.gd.uid new file mode 100644 index 0000000..4a3b779 --- /dev/null +++ b/tests/test_cognitive_ollama.gd.uid @@ -0,0 +1 @@ +uid://b4jpnlelhyj8t diff --git a/tests/test_core_domain.gd b/tests/test_core_domain.gd new file mode 100644 index 0000000..6c72197 --- /dev/null +++ b/tests/test_core_domain.gd @@ -0,0 +1,244 @@ +class_name EvoTestCoreDomain +extends RefCounted + + +static func run(r) -> void: + _test_thermodynamics(r) + _test_catalog_and_presets(r) + _test_strategy_duel_and_forge(r) + _test_serialization(r) + + +static func _test_thermodynamics(r) -> void: + print("-- Testing Thermodynamics --") + var balanced_vec := EvoTypes.BiophysicalVector.new(0.10, 0.25, 0.05, 0.20, 0.10, 0.15) + var m1 := EvoThermodynamics.evaluate_thermodynamics(balanced_vec) + r._assert(is_equal_approx(float(m1["positive_advantages"]), 0.55), "Balanced positive advantages == 0.55") + r._assert(is_equal_approx(float(m1["required_cost"]), 0.0), "No required cost below 0.60 budget") + r._assert(bool(m1["is_balanced"]), "Vector within 0.60 budget is balanced") + + var unbalanced_vec := EvoTypes.BiophysicalVector.new(0.0, 0.80, 0.10, 0.50, 0.50, 0.20) + var m2 := EvoThermodynamics.evaluate_thermodynamics(unbalanced_vec) + # A+ = 1.80 -> C_req = 0.5 * (1.80 - 0.60) = 0.60 + r._assert(is_equal_approx(float(m2["positive_advantages"]), 1.80), "Positive advantages == 1.80") + r._assert(is_equal_approx(float(m2["required_cost"]), 0.60), "Required cost == 0.60") + r._assert(not bool(m2["is_balanced"]), "Uncompensated vector is not balanced") + + var fixed_vec := EvoThermodynamics.validate_and_balance_thermodynamics(unbalanced_vec, false) + var m2_after := EvoThermodynamics.evaluate_thermodynamics(fixed_vec) + r._assert(bool(m2_after["is_balanced"]), "Validated vector is balanced") + r._assert(is_equal_approx(fixed_vec.metabolic_cost, 0.60), "Adjusted metabolic cost == 0.60") + + var sandbox_vec := EvoThermodynamics.validate_and_balance_thermodynamics(unbalanced_vec, true) + r._assert(is_equal_approx(sandbox_vec.metabolic_cost, 0.0), "Free sandbox preserves uncompensated cost") + + +static func _test_catalog_and_presets(r) -> void: + print("-- Testing Catalog & Scientific Presets --") + var rift := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + r._assert(rift.cells.size() == 169, "Rift Savanna radius 7 has 169 cells") + r._assert(rift.demes.size() == 2, "Rift Savanna starts with 2 demes") + r._assert(rift.traits_catalog.size() == 11, "Base catalog has 11 traits (6 bio + 5 cul)") + r._assert(rift.decisions_catalog.size() == 16, "Base catalog has 16 decisions") + + var darwin := EvoCatalog.create_preset_world("preset-darwin-archipelago", "en") + r._assert(darwin.cells.size() == 169, "Darwin Archipelago radius 7 has 169 cells") + r._assert(darwin.demes.size() == 2 and darwin.demes.has("deme-darwin-founder") and darwin.demes.has("deme-darwin-rival"), "Darwin Archipelago starts with 2 rival founder demes (1v1)") + r._assert(darwin.glacial_sea_level_cycle, "Darwin Archipelago has glacial sea level cycle enabled") + + var island := EvoCatalog.create_preset_world("preset-finite-island", "es") + r._assert(island.cells.size() == 169, "Finite Island radius 7 has 169 cells") + r._assert(island.demes.size() == 2 and island.demes.has("deme-island-1") and island.demes.has("deme-island-2"), "Finite Island starts with 2 rival clans (1v1)") + + var west: EvoTypes.Deme = rift.demes["deme-rift-west"] + r._assert(EvoCatalog.deme_has_cultural_capacity(west, rift.traits_catalog), "West deme has cultural capacity via bio-social-instinct") + var injected := EvoCatalog.inject_trait_into_deme(rift, "deme-rift-west", "bio-r-strategy") + r._assert(injected, "Injecting bio-r-strategy succeeds") + r._assert(west.biological_trait_ids.has("bio-r-strategy"), "West deme now has bio-r-strategy") + r._assert(not west.biological_trait_ids.has("bio-k-strategy"), "Mutually exclusive bio-k-strategy was removed") + + +static func _test_strategy_duel_and_forge(r) -> void: + print("-- Testing 1v1 Strategy Duel, Inheritance, Persistence & Forge UI --") + # 1. Base strategies catalog & vector computation + var base_strats := EvoCatalog.create_base_strategies_catalog("es") + var base_traits := EvoCatalog.create_base_traits_catalog("es") + var expected_strat_ids: Array[String] = [ + "strat-cooperation", + "strat-expansion", + "strat-trade", + "strat-war", + "strat-innovation" + ] + r._assert(base_strats.size() == 5, "Base strategies catalog has 5 default strategies") + for sid in expected_strat_ids: + r._assert(base_strats.has(sid), "Base strategies catalog includes %s" % sid) + var s: EvoTypes.StrategyDef = base_strats[sid] + var vec := EvoCatalog.compute_strategy_vector(s, base_traits) + r._assert(vec != null and vec.extraction_efficiency > 0.0 and s.doctrine != "", "Strategy %s computes valid biophysical vector and doctrine" % sid) + + # 2. 1v1 strategy matchup initialization in create_preset_world + var duel_world := EvoCatalog.create_preset_world("preset-rift-savanna", "en", "strat-trade", "strat-innovation") + r._assert(duel_world.strategy_a_id == "strat-trade" and duel_world.strategy_b_id == "strat-innovation", "Preset world records strategy_a_id and strategy_b_id") + var west: EvoTypes.Deme = duel_world.demes["deme-rift-west"] + var east: EvoTypes.Deme = duel_world.demes["deme-rift-east"] + var strat_trade: EvoTypes.StrategyDef = duel_world.strategies_catalog["strat-trade"] + var strat_innov: EvoTypes.StrategyDef = duel_world.strategies_catalog["strat-innovation"] + r._assert(west.strategy_id == "strat-trade" and west.color == strat_trade.color, "Slot A deme receives strat-trade id and color") + r._assert(west.biological_trait_ids == strat_trade.biological_trait_ids and west.cultural_trait_ids == strat_trade.cultural_trait_ids, "Slot A deme receives strat-trade biological and cultural traits") + r._assert(east.strategy_id == "strat-innovation" and east.color == strat_innov.color, "Slot B deme receives strat-innovation id and color") + r._assert(east.biological_trait_ids == strat_innov.biological_trait_ids and east.cultural_trait_ids == strat_innov.cultural_trait_ids, "Slot B deme receives strat-innovation biological and cultural traits") + var lin_w: EvoTypes.LineageNode = duel_world.lineage_tree[west.lineage_id] + var lin_e: EvoTypes.LineageNode = duel_world.lineage_tree[east.lineage_id] + r._assert(lin_w.strategy_id == "strat-trade" and lin_w.color == strat_trade.color and lin_w.biological_trait_ids == strat_trade.biological_trait_ids, "Slot A lineage node synchronized with strat-trade") + r._assert(lin_e.strategy_id == "strat-innovation" and lin_e.color == strat_innov.color and lin_e.biological_trait_ids == strat_innov.biological_trait_ids, "Slot B lineage node synchronized with strat-innovation") + + # 3. Strategy inheritance across colonist budding migration and allopatric speciation + west.population = 140 + west.energy_reserves = 200.0 + var w_coords := HexMath.parse_id(west.hex_id) + var settle_hex := "" + for nid in HexMath.get_neighbor_ids(w_coords.x, w_coords.y, duel_world.cells): + var nc: EvoTypes.HexCell = duel_world.cells[nid] + if nc.deme_ids.is_empty() and EvoActionMenu.is_passable(nc): + settle_hex = nid + break + west.chosen_action = EvoActionMenu.option("settle", settle_hex) + east.chosen_action = EvoActionMenu.option("forage") + EvoBiophysPhase.run_biophysical_phase(duel_world, "en") + var budded_daughter: EvoTypes.Deme = null + for did in duel_world.demes.keys(): + if did != "deme-rift-west" and did != "deme-rift-east": + budded_daughter = duel_world.demes[did] + break + r._assert(budded_daughter != null and budded_daughter.strategy_id == "strat-trade", "Colonist budding migration preserves parent strategy_id") + + var spec_world := EvoCatalog.create_preset_world("preset-darwin-archipelago", "en", "strat-expansion", "strat-war") + spec_world.generation = 12 + var darwin_founder: EvoTypes.Deme = spec_world.demes["deme-darwin-founder"] + (spec_world.cells[darwin_founder.hex_id] as EvoTypes.HexCell).deme_ids.erase(darwin_founder.id) + darwin_founder.hex_id = "0,-2" + (spec_world.cells["0,-2"] as EvoTypes.HexCell).deme_ids.append(darwin_founder.id) + darwin_founder.isolation_turns = 10 + var spec_entries := EvoMilestonePhase.run_milestone_phase(spec_world, "en") + r._assert(not spec_entries.is_empty() and darwin_founder.strategy_id == "strat-expansion", "Allopatric speciation preserves deme strategy_id") + r._assert((spec_world.lineage_tree[darwin_founder.lineage_id] as EvoTypes.LineageNode).strategy_id == "strat-expansion", "Allopatric speciation child lineage preserves strategy_id") + + # 4. JSON persistence of custom user strategies and traits via EvoUserSettings + var tmp_dir := "/tmp/evolution_test_catalog_%d" % Time.get_ticks_usec() + var tmp_settings_path := tmp_dir.path_join("settings.json") + EvoUserSettings.config_path_override = tmp_settings_path + + var custom_tr := EvoTypes.TraitDef.new() + custom_tr.id = "bio-custom-carapace" + custom_tr.name = "Chitinous Carapace" + custom_tr.category = "biological" + custom_tr.origin = "user" + custom_tr.description = "Custom defensive adaptation." + custom_tr.vector = EvoTypes.BiophysicalVector.new(0.15, 0.25, 0.05, 0.10, 0.20, 0.20) + duel_world.traits_catalog[custom_tr.id] = custom_tr + + var custom_st := EvoTypes.StrategyDef.new() + custom_st.id = "strat-custom-fortress" + custom_st.name = "Fortress Doctrine" + custom_st.description = "Custom defensive strategy." + custom_st.doctrine = "Fortify every border and never yield." + custom_st.icon = "🛡️" + custom_st.color = "#14B8A6" + custom_st.origin = "user" + custom_st.biological_trait_ids = ["bio-k-strategy", "bio-social-instinct", "bio-custom-carapace"] + custom_st.cultural_trait_ids = ["cul-communal-conservation"] + duel_world.strategies_catalog[custom_st.id] = custom_st + + EvoUserSettings.save_user_catalog(duel_world) + var catalog_json_path := EvoUserSettings.get_user_catalog_path() + r._assert(FileAccess.file_exists(catalog_json_path), "EvoUserSettings.save_user_catalog writes user_catalog.json") + + var fresh_state := EvoTypes.WorldState.new() + fresh_state.traits_catalog = EvoCatalog.create_base_traits_catalog("en") + fresh_state.decisions_catalog = EvoCatalog.create_base_decisions_catalog("en") + fresh_state.strategies_catalog = EvoCatalog.create_base_strategies_catalog("en") + EvoUserSettings.load_user_catalog_into(fresh_state) + r._assert(fresh_state.traits_catalog.has("bio-custom-carapace"), "load_user_catalog_into restores custom user trait") + r._assert(fresh_state.strategies_catalog.has("strat-custom-fortress"), "load_user_catalog_into restores custom user strategy") + var restored_st: EvoTypes.StrategyDef = fresh_state.strategies_catalog["strat-custom-fortress"] + r._assert(restored_st.name == "Fortress Doctrine" and restored_st.doctrine == "Fortify every border and never yield.", "Restored custom user strategy preserves name, doctrine and metadata") + + # 5. UI integration in EvoMainController, EvoMainHud & EvoLabModal + var packed: PackedScene = load("res://scenes/main.tscn") + var main: EvoMainController = packed.instantiate() as EvoMainController + main.quit_on_close_enabled = false + r.root.add_child(main) + if main.camera == null: + main._ready() + main.hud._set_startup_blocked(false) + + var opt_a := main.hud.strategy_a_option + var opt_b := main.hud.strategy_b_option + r._assert(opt_a != null and opt_b != null and opt_a.item_count >= 5 and opt_b.item_count >= 5, "HUD strategy_a_option and strategy_b_option populated with strategies") + r._assert(str(opt_a.get_item_metadata(opt_a.selected)) == main.world_state.strategy_a_id, "strategy_a_option selection reflects world_state.strategy_a_id") + r._assert(str(opt_b.get_item_metadata(opt_b.selected)) == main.world_state.strategy_b_id, "strategy_b_option selection reflects world_state.strategy_b_id") + + main.hud.open_lab_modal() + var lab := main.hud.lab_modal + lab._switch_tab("strategy") + lab._start_new_item() + var cb_k: CheckBox = lab.strategy_trait_checks["bio-k-strategy"] + var cb_r: CheckBox = lab.strategy_trait_checks["bio-r-strategy"] + cb_k.button_pressed = true + cb_r.button_pressed = true + r._assert(cb_r.button_pressed and not cb_k.button_pressed, "Lab strategy trait checkboxes enforce mutual exclusion between bio-r-strategy and bio-k-strategy") + + lab.id_edit.text = "strat-custom-duelist" + lab.name_edit.text = "Duelist Doctrine" + lab.doctrine_edit.text = "Strike swiftly and claim high ground." + lab._save_current_item() + r._assert(main.world_state.strategies_catalog.has("strat-custom-duelist"), "Saving custom strategy in Lab Modal adds it to strategies_catalog") + r._assert((main.world_state.strategies_catalog["strat-custom-duelist"] as EvoTypes.StrategyDef).doctrine == "Strike swiftly and claim high ground.", "Saving custom strategy in Lab Modal persists doctrine text") + + main.set_matchup_strategies("strat-custom-duelist", "strat-trade") + r._assert(main.world_state.generation == 0 and main.world_state.strategy_a_id == "strat-custom-duelist" and main.world_state.strategy_b_id == "strat-trade", "set_matchup_strategies resets to Generation 0 with selected strategies") + r._assert((main.world_state.demes["deme-rift-west"] as EvoTypes.Deme).strategy_id == "strat-custom-duelist", "Founder deme A updated with custom strategy") + + main.step_generation() + r._assert(main.world_state.generation == 1, "Generation advances before reset_matchup") + main.reset_matchup() + r._assert(main.world_state.generation == 0 and main.world_state.strategies_catalog.has("strat-custom-duelist") and main.world_state.strategy_a_id == "strat-custom-duelist", "reset_matchup resets to Generation 0 and preserves custom user strategies") + + main.hud.select_deme("deme-rift-west") + r._assert(main.hud.deme_strategy_label != null and "Duelist Doctrine" in main.hud.deme_strategy_label.text, "Inspector deme_strategy_label displays selected village strategy") + + main.free() + if FileAccess.file_exists(catalog_json_path): + DirAccess.remove_absolute(catalog_json_path) + if FileAccess.file_exists(tmp_settings_path): + DirAccess.remove_absolute(tmp_settings_path) + if DirAccess.dir_exists_absolute(tmp_dir): + DirAccess.remove_absolute(tmp_dir) + EvoUserSettings.config_path_override = r._suite_cfg_path + + +static func _test_serialization(r) -> void: + print("-- Testing JSON Serialization Round-Trip --") + var state := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + EvoTurnEngine.advance_generation(state, "es") + state.recent_extinction_hexes.assign(["1,-1"]) + state.disused_connections.append({ + "from_hex_id": "-1,0", + "to_hex_id": "1,0", + "from_deme_id": "deme-rift-west", + "to_deme_id": "deme-rift-east", + "flow_type": "exchange", + "age": 2 + }) + state.previous_populations["deme-rift-west"] = 177 + var json_str := JSON.stringify(state.to_dict()) + var parsed: Variant = JSON.parse_string(json_str) + r._assert(typeof(parsed) == TYPE_DICTIONARY, "Serialized state parses back as Dictionary") + var restored := EvoTypes.WorldState.from_dict(parsed as Dictionary) + r._assert(restored.generation == state.generation, "Restored generation matches") + r._assert(restored.cells.size() == state.cells.size(), "Restored cells count matches") + r._assert(restored.demes.size() == state.demes.size(), "Restored demes count matches") + r._assert(restored.recent_extinction_hexes == state.recent_extinction_hexes, "Restored recent_extinction_hexes matches") + r._assert(restored.disused_connections.size() == 1 and int(restored.disused_connections[0]["age"]) == 2, "Restored disused_connections matches") + r._assert(int(restored.previous_populations.get("deme-rift-west", 0)) == 177, "Restored previous_populations matches") diff --git a/tests/test_core_domain.gd.uid b/tests/test_core_domain.gd.uid new file mode 100644 index 0000000..bbadc53 --- /dev/null +++ b/tests/test_core_domain.gd.uid @@ -0,0 +1 @@ +uid://ds1qansestia6 diff --git a/tests/test_engine_biophys.gd b/tests/test_engine_biophys.gd new file mode 100644 index 0000000..be58d70 --- /dev/null +++ b/tests/test_engine_biophys.gd @@ -0,0 +1,548 @@ +class_name EvoTestEngineBiophys +extends RefCounted + + +static func run(r) -> void: + _test_payoffs_and_canonical_scenarios(r) + _test_tragedy_of_the_commons(r) + _test_fortify_innovate_baldwin_and_speciation(r) + _test_diplomacy_trade_aid_pacts_and_tribute(r) + _test_conflict_raids_betrayal_and_conquest(r) + _test_settling_unions_and_rural_hinterlands(r) + _test_player_interventions_and_multi_gen_stability(r) + _test_epidemic_contagion_and_immunity(r) + + +static func _test_payoffs_and_canonical_scenarios(r) -> void: + print("-- Testing EvoPayoffs Monotonicity & Canonical Scenario Forecasts --") + var state := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") + var att: EvoTypes.Deme = state.demes["deme-rift-west"] + var def: EvoTypes.Deme = state.demes["deme-rift-east"] + (state.cells[att.hex_id] as EvoTypes.HexCell).deme_ids.erase(att.id) + (state.cells[def.hex_id] as EvoTypes.HexCell).deme_ids.erase(def.id) + att.hex_id = "0,0" + def.hex_id = "1,0" + (state.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([att.id]) + (state.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([def.id]) + + att.population = 110 + def.population = 70 + def.defense_tier = 0 + def.energy_reserves = 120.0 + var odds_base: Dictionary = EvoPayoffs.raid_odds(att, def, state, false) + var odds_surprise: Dictionary = EvoPayoffs.raid_odds(att, def, state, true) + def.defense_tier = 2 + var odds_walled: Dictionary = EvoPayoffs.raid_odds(att, def, state, false) + def.defense_tier = 3 + var odds_castle: Dictionary = EvoPayoffs.raid_odds(att, def, state, false) + r._assert(float(odds_surprise["p_win"]) > float(odds_base["p_win"]), "Surprise betrayal boosts raid p_win (%.2f > %.2f)" % [float(odds_surprise["p_win"]), float(odds_base["p_win"])]) + r._assert(float(odds_base["p_win"]) > float(odds_walled["p_win"]), "Defender walls reduce raid p_win (%.2f > %.2f)" % [float(odds_base["p_win"]), float(odds_walled["p_win"])]) + r._assert(float(odds_castle["loot"]) < float(odds_base["loot"]) * 0.30, "Tier 3 Castle shields >= 75%% of raid loot") + + def.defense_tier = 0 + var conq_base: Dictionary = EvoPayoffs.conquest_odds(att, def, state) + def.defense_tier = 2 + var conq_walled: Dictionary = EvoPayoffs.conquest_odds(att, def, state) + r._assert(float(conq_base["p_win"]) > float(conq_walled["p_win"]), "Defender walls reduce conquest p_win (%.2f > %.2f)" % [float(conq_base["p_win"]), float(conq_walled["p_win"])]) + + # Canonical Scenario 1: Open virgin frontier -> settle option present with daughter camp forecast + att.population = 120 + att.energy_reserves = 140.0 + var menu_frontier := EvoActionMenu.build(att, state) + var found_settle := false + for opt in menu_frontier: + if str(opt["type"]) == "settle": + found_settle = true + var fc := EvoOutcomeForecast.describe(att, opt, state) + r._assert(fc.contains("Found a new camp") and fc.contains("settlers"), "Settle forecast quantifies new daughter camp and settlers") + break + r._assert(found_settle, "Action menu includes settle option when pop >= 45") + + # Canonical Scenario 2: Complementary biome neighbor -> trade forecast shows positive food and trust gain + (state.cells["0,0"] as EvoTypes.HexCell).biome = "savanna" + (state.cells["1,0"] as EvoTypes.HexCell).biome = "alluvial_plains" + var trade_opt := EvoActionMenu.option("trade", def.id) + var trade_fc := EvoOutcomeForecast.describe(att, trade_opt, state) + r._assert(trade_fc.contains("Trade with") and trade_fc.contains("trust +0.12"), "Trade forecast quantifies mutual trade yield and trust gain") + + # Canonical Scenario 3: Tribute demand from last turn -> pay_tribute option appears and quantifies raid risk + state.generation = 2 + var rel: EvoTypes.Relation = EvoTypes.get_relation(state, att.id, def.id) + rel.proposal_from = att.id + rel.proposal_kind = "tribute" + rel.proposal_amount = 24.0 + rel.proposal_gen = 1 + def.energy_reserves = 90.0 + var menu_def := EvoActionMenu.build(def, state) + var has_pay_trib := false + for opt in menu_def: + if str(opt["type"]) == "pay_tribute" and str(opt["target"]) == att.id: + has_pay_trib = true + var fc := EvoOutcomeForecast.describe(def, opt, state) + r._assert(fc.contains("truce") and fc.contains("immediately raid with"), "pay_tribute forecast highlights truce and raid threat if refused") + r._assert(has_pay_trib, "Defender menu includes pay_tribute when tribute demand from previous turn is active") + + # Canonical Scenario 4: Severe scarcity + rich ally -> betray option appears with surprise loot & distrust warning + rel.proposal_from = "" + rel.proposal_kind = "" + rel.pact_turns = 4 + def.energy_reserves = 180.0 + def.defense_tier = 0 + var menu_betray := EvoActionMenu.build(att, state) + var has_betray := false + for opt in menu_betray: + if str(opt["type"]) == "betray" and str(opt["target"]) == def.id: + has_betray = true + var fc := EvoOutcomeForecast.describe(att, opt, state) + r._assert(fc.contains("surprise attack") and fc.contains("reputation -0.50"), "betray forecast quantifies surprise loot and reputation cost") + r._assert(has_betray, "Allied neighbor unlocks betray option in action menu") + + +static func _test_tragedy_of_the_commons(r) -> void: + print("-- Testing Tragedy of the Commons via Repeated Intensify --") + var island := EvoCatalog.create_preset_world("preset-finite-island", "es") + var tragedy_occurred := false + var tragedy_hex_id := "" + for _g in range(14): + for did in island.demes.keys(): + var d: EvoTypes.Deme = island.demes[did] + d.chosen_action = EvoActionMenu.option("intensify") + EvoTurnEngine.advance_generation(island, "es") + for entry in island.chronicle: + if entry.event_type == "tragedy_of_commons": + tragedy_occurred = true + tragedy_hex_id = entry.hex_id + break + if tragedy_occurred: + break + r._assert(tragedy_occurred, "Repeated intensive extraction on Finite Island triggers Tragedy of the Commons") + var degraded_cell: EvoTypes.HexCell = island.cells[tragedy_hex_id] + r._assert(degraded_cell.carrying_capacity < 0.60 * degraded_cell.carrying_capacity_max, "Overexploited cell K degraded below 60% of Kmax") + + +static func _test_fortify_innovate_baldwin_and_speciation(r) -> void: + print("-- Testing Fortify, Innovate, Baldwin Assimilation & Allopatric Speciation --") + # 1. Multi-tier fortification (0 -> 1 -> 2 -> 3) + var state_f := EvoPresets.create_preset_world("preset-rift-savanna", "en") + var d_f: EvoTypes.Deme = state_f.demes["deme-rift-west"] + var c_f: EvoTypes.HexCell = state_f.cells[d_f.hex_id] + d_f.population = 130 + d_f.energy_reserves = 300.0 + c_f.current_resources = 500.0 + for target_tier in [1, 2, 3]: + d_f.chosen_action = EvoActionMenu.option("fortify") + EvoEconomyPhase.invest(state_f, "en") + r._assert(d_f.defense_tier == target_tier, "Fortify action upgrades defense_tier to %d" % target_tier) + var fortify_logs := 0 + for e in state_f.chronicle: + if e.event_type == "fortify" and e.deme_id == d_f.id: + fortify_logs += 1 + r._assert(fortify_logs >= 2, "Upgrading to Tier 2 and Tier 3 logs 'fortify' ChronicleEntries") + + # 2. Innovate action & Cultural Innovation milestone + var state_c := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var east_c: EvoTypes.Deme = state_c.demes["deme-rift-east"] + east_c.cultural_trait_ids.clear() + east_c.energy_reserves = 120.0 + east_c.chosen_action = EvoActionMenu.option("innovate") + EvoEconomyPhase.invest(state_c, "es") + r._assert(east_c.innovation_counter > 0.0, "Innovate action advances innovation_counter (%.2f)" % east_c.innovation_counter) + + east_c.innovation_counter = 1.15 + east_c.last_energy_delta = 5.0 + var entries_c := EvoMilestonePhase.run_milestone_phase(state_c, "es") + r._assert(entries_c.size() == 1 and entries_c[0].event_type == "cultural_innovation", "Cultural innovation triggers at counter >= 1.0") + r._assert(east_c.cultural_trait_ids.size() == 1, "Deme acquires 1st newly synthesized cultural tradition") + + east_c.innovation_counter = 1.25 + east_c.last_energy_delta = 5.0 + var entries_c2 := EvoMilestonePhase.run_milestone_phase(state_c, "es") + r._assert(entries_c2.size() == 1 and entries_c2[0].event_type == "cultural_innovation" and east_c.cultural_trait_ids.size() == 2, "Deme acquires 2nd emergent cultural trait") + + east_c.innovation_counter = 1.25 + east_c.last_energy_delta = 5.0 + var entries_c3 := EvoMilestonePhase.run_milestone_phase(state_c, "es") + r._assert(entries_c3.is_empty() and east_c.cultural_trait_ids.size() == 2, "Cultural innovation caps at 2 emergent cultural traits") + + # 3. Baldwin assimilation + var state_b := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var east_b: EvoTypes.Deme = state_b.demes["deme-rift-east"] + east_b.baldwin_counters["intensify"] = 8 + east_b.chosen_action = EvoActionMenu.option("intensify") + var entries_b := EvoMilestonePhase.run_milestone_phase(state_b, "es") + r._assert(entries_b.size() == 1 and entries_b[0].event_type == "baldwin_trait", "Baldwin assimilation triggers at counter >= 8") + r._assert(int(east_b.baldwin_counters["intensify"]) == 0, "Baldwin counter resets to 0 after assimilation") + + # 4. Allopatric speciation + var state_s := EvoCatalog.create_preset_world("preset-darwin-archipelago", "es") + state_s.generation = 12 + var founder: EvoTypes.Deme = state_s.demes["deme-darwin-founder"] + (state_s.cells[founder.hex_id] as EvoTypes.HexCell).deme_ids.erase(founder.id) + founder.hex_id = "0,-2" + (state_s.cells["0,-2"] as EvoTypes.HexCell).deme_ids.append(founder.id) + founder.isolation_turns = 10 + var old_lineage := founder.lineage_id + var old_color := founder.color + var entries_s := EvoMilestonePhase.run_milestone_phase(state_s, "es") + r._assert(entries_s.size() == 1 and entries_s[0].event_type == "allopatric_speciation", "Allopatric speciation triggers at isolation >= 10 in distinct biome") + r._assert(founder.lineage_id != old_lineage and founder.color != old_color, "Speciated deme receives new lineage ID and distinct color") + r._assert(state_s.lineage_tree.size() == 3, "Lineage tree now has 3 nodes") + + +static func _test_diplomacy_trade_aid_pacts_and_tribute(r) -> void: + print("-- Testing Diplomacy: Reciprocal Trade, Aid, Pacts & Tribute Coercion --") + var state := EvoPresets.create_preset_world("preset-finite-island", "en", "strat-cooperation", "strat-trade") + var d1: EvoTypes.Deme = state.demes["deme-island-1"] + var d2: EvoTypes.Deme = state.demes["deme-island-2"] + (state.cells[d1.hex_id] as EvoTypes.HexCell).deme_ids.erase(d1.id) + (state.cells[d2.hex_id] as EvoTypes.HexCell).deme_ids.erase(d2.id) + d1.hex_id = "0,0" + d2.hex_id = "1,0" + (state.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([d1.id]) + (state.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([d2.id]) + + # 1. Reciprocal Trade + Horizontal Cultural Diffusion + d1.cultural_trait_ids.assign(["cul-fire-processing"]) + d2.cultural_trait_ids.clear() + d1.chosen_action = EvoActionMenu.option("trade", d2.id) + d2.chosen_action = EvoActionMenu.option("trade", d1.id) + EvoBiophysPhase.run_biophysical_phase(state, "en") + r._assert(d2.cultural_trait_ids.has("cul-fire-processing"), "Reciprocal trade diffuses cul-fire-processing to partner") + r._assert(EvoTypes.get_relation(state, d1.id, d2.id).trust > 0.0, "Reciprocal trade increases bilateral trust") + + # 2. Aid transfer + d1.energy_reserves = 140.0 + d2.energy_reserves = 20.0 + var rep_before := d1.reputation + d1.chosen_action = EvoActionMenu.option("aid", d2.id) + d2.chosen_action = EvoActionMenu.option("forage") + EvoDiplomacyPhase.resolve(state, "en") + r._assert(d2.energy_reserves > 20.0 and d1.reputation > rep_before, "Aid transfers food reserves to neighbor and raises sender reputation") + + # 3. Offer Pact -> Accept Pact on next turn (or mutual offer_pact on same turn) + d1.chosen_action = EvoActionMenu.option("offer_pact", d2.id) + d2.chosen_action = EvoActionMenu.option("offer_pact", d1.id) + EvoDiplomacyPhase.resolve(state, "en") + r._assert(EvoTypes.is_pact_active(state, d1.id, d2.id), "Mutual offer_pact forms active alliance") + var has_pact_chronicle := false + for e in state.chronicle: + if e.event_type == "pact": + has_pact_chronicle = true + break + r._assert(has_pact_chronicle, "Pact formation logs 'pact' ChronicleEntry") + r._assert(EvoPayoffs.pact_multiplier(d1, state) > 1.0, "Active pact provides harvest multiplier bonus") + + # 4. Demand Tribute -> Pay Tribute vs Refusal + var rel: EvoTypes.Relation = EvoTypes.get_relation(state, d1.id, d2.id) + rel.pact_turns = 0 + d1.population = 130 + d2.population = 55 + d1.energy_reserves = 40.0 + d2.energy_reserves = 100.0 + d1.chosen_action = EvoActionMenu.option("demand_tribute", d2.id) + d2.chosen_action = EvoActionMenu.option("forage") + EvoDiplomacyPhase.resolve(state, "en") + r._assert(rel.proposal_kind == "tribute" and rel.proposal_from == d1.id, "demand_tribute posts tribute proposal for next turn") + + # Next turn: d2 pays tribute + state.generation += 1 + var d1_res_before := d1.energy_reserves + d1.chosen_action = EvoActionMenu.option("forage") + d2.chosen_action = EvoActionMenu.option("pay_tribute", d1.id) + EvoDiplomacyPhase.resolve(state, "en") + r._assert(d1.energy_reserves > d1_res_before and rel.truce_turns == EvoPayoffs.TRUCE_AFTER_TRIBUTE, "pay_tribute transfers food and grants truce") + var has_trib_chronicle := false + for e in state.chronicle: + if e.event_type == "tribute": + has_trib_chronicle = true + break + r._assert(has_trib_chronicle, "pay_tribute logs 'tribute' ChronicleEntry") + + # Demand tribute again and refuse -> feud_turns > 0 + rel.truce_turns = 0 + d1.chosen_action = EvoActionMenu.option("demand_tribute", d2.id) + d2.chosen_action = EvoActionMenu.option("forage") + EvoDiplomacyPhase.resolve(state, "en") + state.generation += 1 + d1.chosen_action = EvoActionMenu.option("forage") + d2.chosen_action = EvoActionMenu.option("forage") + EvoDiplomacyPhase.resolve(state, "en") + EvoRelations.tick(state) + r._assert(EvoTypes.is_vendetta_active(state, d1.id, d2.id), "Ignoring a tribute demand triggers active feud/vendetta") + + +static func _test_conflict_raids_betrayal_and_conquest(r) -> void: + print("-- Testing Conflict: Raids, Hinterland Pillaging, Betrayal & Territorial Conquest --") + # 1. Cross-border Raid + Rural Hinterland Pillaging + var state_r := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") + var r_att: EvoTypes.Deme = state_r.demes["deme-rift-west"] + var r_def: EvoTypes.Deme = state_r.demes["deme-rift-east"] + (state_r.cells[r_att.hex_id] as EvoTypes.HexCell).deme_ids.erase(r_att.id) + (state_r.cells[r_def.hex_id] as EvoTypes.HexCell).deme_ids.erase(r_def.id) + r_att.hex_id = "0,0" + r_def.hex_id = "1,0" + (state_r.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([r_att.id]) + (state_r.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([r_def.id]) + var farm_cell: EvoTypes.HexCell = state_r.cells["1,-1"] + farm_cell.biome = "savanna" + farm_cell.altitude = 0.45 + farm_cell.current_resources = 200.0 + farm_cell.owner_deme_id = r_def.id + farm_cell.land_use = "crops" + farm_cell.has_ruins = false + + r_att.population = 180 + r_att.energy_reserves = 25.0 + r_def.population = 35 + r_def.defense_tier = 0 + r_def.energy_reserves = 150.0 + r_att.chosen_action = EvoActionMenu.option("raid", r_def.id) + r_def.chosen_action = EvoActionMenu.option("forage") + EvoConflictPhase.resolve(state_r, "en") + r._assert(r_att.energy_reserves > 25.0 and r_def.energy_reserves < 150.0, "Raid loots food reserves from defender") + r._assert(farm_cell.has_ruins and farm_cell.land_use == "wild" and farm_cell.owner_deme_id == "", "Successful raid pillages defender rural hinterland into ruins") + r._assert(EvoTypes.is_vendetta_active(state_r, r_att.id, r_def.id), "Raid activates bilateral feud/vendetta") + + # 2. Opportunistic Betrayal + Third-Party Ally Alert + var state_b := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") + var b_att: EvoTypes.Deme = state_b.demes["deme-rift-west"] + var b_vic: EvoTypes.Deme = state_b.demes["deme-rift-east"] + (state_b.cells[b_att.hex_id] as EvoTypes.HexCell).deme_ids.erase(b_att.id) + (state_b.cells[b_vic.hex_id] as EvoTypes.HexCell).deme_ids.erase(b_vic.id) + b_att.hex_id = "0,0" + b_vic.hex_id = "1,0" + (state_b.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([b_att.id]) + (state_b.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([b_vic.id]) + var b_ally := b_vic.duplicate_deme() + b_ally.id = "deme-third-ally" + b_ally.name = "Third Ally" + b_ally.hex_id = "-1,0" + state_b.demes[b_ally.id] = b_ally + (state_b.cells["-1,0"] as EvoTypes.HexCell).deme_ids.assign([b_ally.id]) + EvoTypes.get_relation(state_b, b_att.id, b_vic.id).pact_turns = 4 + EvoTypes.get_relation(state_b, b_vic.id, b_ally.id).pact_turns = 4 + EvoTypes.get_relation(state_b, b_att.id, b_ally.id).trust = 0.50 + + b_att.population = 160 + b_att.energy_reserves = 20.0 + b_vic.population = 40 + b_vic.energy_reserves = 160.0 + b_att.chosen_action = EvoActionMenu.option("betray", b_vic.id) + b_vic.chosen_action = EvoActionMenu.option("forage") + b_ally.chosen_action = EvoActionMenu.option("forage") + EvoConflictPhase.resolve(state_b, "en") + r._assert(not EvoTypes.is_pact_active(state_b, b_att.id, b_vic.id) and EvoTypes.is_vendetta_active(state_b, b_att.id, b_vic.id), "Betrayal shatters pact and sets deep feud") + r._assert(b_att.energy_reserves > 20.0 and b_vic.energy_reserves < 160.0, "Betrayal plunders surprise loot from victim") + r._assert(b_att.reputation < 0.0 and EvoTypes.get_relation(state_b, b_att.id, b_ally.id).trust < 0.50, "Betrayal damages global reputation and third-party ally trust") + r._assert(not state_b.disused_connections.is_empty(), "Betrayal records severed road in state.disused_connections") + + # 3. Territorial Conquest (Hex Occupation & Defender Displacement) + var state_w := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") + var w_att: EvoTypes.Deme = state_w.demes["deme-rift-west"] + var w_def: EvoTypes.Deme = state_w.demes["deme-rift-east"] + (state_w.cells[w_att.hex_id] as EvoTypes.HexCell).deme_ids.erase(w_att.id) + (state_w.cells[w_def.hex_id] as EvoTypes.HexCell).deme_ids.erase(w_def.id) + w_att.hex_id = "0,0" + w_def.hex_id = "1,0" + (state_w.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([w_att.id]) + (state_w.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([w_def.id]) + w_att.population = 220 + w_att.energy_reserves = 140.0 + w_def.population = 35 + w_def.defense_tier = 0 + w_def.energy_reserves = 80.0 + w_att.chosen_action = EvoActionMenu.option("conquer", w_def.id) + w_def.chosen_action = EvoActionMenu.option("forage") + EvoConflictPhase.resolve(state_w, "en") + var target_cell: EvoTypes.HexCell = state_w.cells["1,0"] + r._assert(target_cell.deme_ids.size() == 1 and target_cell.deme_ids[0] != w_def.id, "Conquering warband occupies target hex (1,0) and displaces defender") + var has_war_chronicle := false + for e in state_w.chronicle: + if e.event_type == "war": + has_war_chronicle = true + break + r._assert(has_war_chronicle, "Conquest logs 'war' ChronicleEntry") + + +static func _test_settling_unions_and_rural_hinterlands(r) -> void: + print("-- Testing Settling, Asymmetric & Equal-Tier Unions, and Rural Hinterlands --") + # 1. Asymmetric Union (tier_a != tier_b) absorbs smaller partner and converts vacated hex to rural hinterland + var state_u1 := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") + var u1_a: EvoTypes.Deme = state_u1.demes["deme-rift-west"] + var u1_b: EvoTypes.Deme = state_u1.demes["deme-rift-east"] + (state_u1.cells[u1_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(u1_a.id) + (state_u1.cells[u1_b.hex_id] as EvoTypes.HexCell).deme_ids.erase(u1_b.id) + u1_a.hex_id = "0,0" + u1_b.hex_id = "1,0" + (state_u1.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([u1_a.id]) + (state_u1.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([u1_b.id]) + u1_a.population = 190 + u1_a.defense_tier = 1 + u1_b.population = 70 + u1_b.defense_tier = 0 + var u1_b_color := u1_b.color + EvoTypes.get_relation(state_u1, u1_a.id, u1_b.id).pact_turns = 4 + u1_a.chosen_action = EvoActionMenu.option("unite", u1_b.id) + u1_b.chosen_action = EvoActionMenu.option("unite", u1_a.id) + EvoDiplomacyPhase.resolve(state_u1, "en") + r._assert(state_u1.demes.size() == 1 and u1_a.merged_count == 1 and u1_a.secondary_color == u1_b_color, "Asymmetric union absorbs smaller partner into host city and records secondary_color") + var vacated: EvoTypes.HexCell = state_u1.cells["1,0"] + r._assert(vacated.deme_ids.is_empty() and vacated.owner_deme_id == u1_a.id and vacated.land_use in ["crops", "pasture"], "Vacated union hex becomes rural hinterland owned by host city") + + # 2. Equal-Tier Union (tier_a == tier_b >= 2) creates Multi-Hex Walled Union + var state_u2 := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") + var u2_a: EvoTypes.Deme = state_u2.demes["deme-rift-west"] + var u2_b: EvoTypes.Deme = state_u2.demes["deme-rift-east"] + (state_u2.cells[u2_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(u2_a.id) + (state_u2.cells[u2_b.hex_id] as EvoTypes.HexCell).deme_ids.erase(u2_b.id) + u2_a.hex_id = "0,0" + u2_b.hex_id = "1,0" + (state_u2.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([u2_a.id]) + (state_u2.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([u2_b.id]) + u2_a.population = 135 + u2_b.population = 135 + u2_a.defense_tier = 1 + u2_b.defense_tier = 1 + EvoTypes.get_relation(state_u2, u2_a.id, u2_b.id).pact_turns = 4 + u2_a.chosen_action = EvoActionMenu.option("unite", u2_b.id) + u2_b.chosen_action = EvoActionMenu.option("unite", u2_a.id) + EvoDiplomacyPhase.resolve(state_u2, "en") + r._assert(state_u2.demes.size() == 2 and u2_a.walled_union_id != "" and u2_a.walled_union_id == u2_b.walled_union_id, "Equal-tier union creates shared Multi-Hex Walled Union") + + # 3. Rural Hinterland claiming & harvest yield bonus + var state_rh := EvoPresets.create_preset_world("preset-rift-savanna", "en") + var rh_city: EvoTypes.Deme = state_rh.demes["deme-rift-west"] + (state_rh.cells[rh_city.hex_id] as EvoTypes.HexCell).deme_ids.erase(rh_city.id) + rh_city.hex_id = "0,0" + (state_rh.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([rh_city.id]) + rh_city.population = 210 + rh_city.defense_tier = 1 + EvoRuralHinterland.claim_hinterlands(state_rh) + var owned := EvoRuralHinterland.owned_hexes(rh_city, state_rh) + var yield_est := EvoRuralHinterland.rural_yield_estimate(rh_city, state_rh) + r._assert(owned.size() == 2 and yield_est > 0.0, "Tier 3 city claims 2 rural hinterland hexes with positive food yield (%.1f)" % yield_est) + + +static func _test_player_interventions_and_multi_gen_stability(r) -> void: + print("-- Testing Player Interventions, 25-Gen Stability & Inspector Integration --") + var state := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + state.selective_pressure_points = 100 + var targets: Array[String] = ["0,0"] + + var ok_biome := EvoTurnEngine.apply_set_biome(state, targets, "tundra", "es") + var c0: EvoTypes.HexCell = state.cells["0,0"] + var tundra_defs: Dictionary = EvoTypes.BIOME_CLIMATE_DEFAULTS["tundra"] + r._assert(ok_biome and c0.biome == "tundra", "set_biome changes cell biome to tundra") + r._assert( + is_equal_approx(c0.seasonal_regen_rate, float(tundra_defs["regen"])) and is_equal_approx(c0.carrying_capacity_max, float(tundra_defs["k_max"])), + "set_biome updates seasonal_regen_rate and carrying_capacity_max" + ) + r._assert(state.selective_pressure_points == 90, "set_biome costs 10 points") + r._assert(EvoTurnEngine.apply_adjust_altitude(state, targets, 0.18, "es") and state.selective_pressure_points == 75, "adjust_altitude costs 15 points") + r._assert(EvoTurnEngine.apply_adjust_carrying_capacity(state, targets, 0.25) and state.selective_pressure_points == 65, "adjust_carrying_capacity costs 10 points") + var prev_sea := state.sea_level + r._assert(EvoTurnEngine.apply_adjust_sea_level(state, 0.05, "es") and is_equal_approx(state.sea_level, prev_sea + 0.05) and state.selective_pressure_points == 40, "adjust_sea_level raises sea level and costs 25 points") + + # 25 Generations stability + 1-deme-per-hex invariant across all 3 presets + for preset_id in EvoCatalog.PRESET_IDS: + var p_state := EvoCatalog.create_preset_world(preset_id, "es") + var max_demes_per_cell := 0 + for _g in range(25): + EvoTurnEngine.advance_generation(p_state, "es") + for cid in p_state.cells.keys(): + var c: EvoTypes.HexCell = p_state.cells[cid] + if c.deme_ids.size() > max_demes_per_cell: + max_demes_per_cell = c.deme_ids.size() + r._assert(p_state.generation == 25 and max_demes_per_cell <= 1, "%s reached generation 25 cleanly with <= 1 deme per hex" % preset_id) + + # Inspector formatting of active pacts and relations + var state_pact := EvoPresets.create_preset_world("preset-finite-island", "en") + var p_d1: EvoTypes.Deme = state_pact.demes["deme-island-1"] + var p_d2: EvoTypes.Deme = state_pact.demes["deme-island-2"] + EvoTypes.get_relation(state_pact, p_d1.id, p_d2.id).pact_turns = 5 + var pact_summary := EvoHudInspectorPanel.format_active_pacts_summary(state_pact, "en", p_d1) + r._assert(pact_summary.contains("🤝"), "EvoHudInspectorPanel.format_active_pacts_summary displays active pact partner") + var pact_rel_lines := EvoHudInspectorPanel.build_deme_relations_lines(state_pact, "en", p_d1) + r._assert(not pact_rel_lines.is_empty(), "EvoHudInspectorPanel.build_deme_relations_lines includes pact & relation lines") + + +static func _test_epidemic_contagion_and_immunity(r) -> void: + print("-- Testing Deterministic Epidemic Outbreak, Contagion & Immunity --") + var state := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") + for cid in state.cells.keys(): + (state.cells[cid] as EvoTypes.HexCell).deme_ids.clear() + state.demes.clear() + state.relations.clear() + state.turn_events.clear() + state.chronicle.clear() + + var d_source := EvoTypes.Deme.new() + d_source.id = "deme-epi-source" + d_source.name = "Source Clan" + d_source.hex_id = "0,0" + d_source.population = 150 + d_source.salubrity = 0.25 + d_source.infection_turns = 2 + d_source.immunity_turns = 0 + d_source.lineage_id = "lineage-west" + d_source.strategy_id = "strat-cooperation" + d_source.color = "#10b981" + + var d_susceptible := EvoTypes.Deme.new() + d_susceptible.id = "deme-epi-susceptible" + d_susceptible.name = "Susceptible Clan" + d_susceptible.hex_id = "1,0" + d_susceptible.population = 100 + d_susceptible.salubrity = 0.50 + d_susceptible.infection_turns = 0 + d_susceptible.immunity_turns = 0 + d_susceptible.lineage_id = "lineage-west" + d_susceptible.strategy_id = "strat-cooperation" + d_susceptible.color = "#10b981" + + var d_healthy := EvoTypes.Deme.new() + d_healthy.id = "deme-epi-healthy" + d_healthy.name = "Healthy Clan" + d_healthy.hex_id = "0,1" + d_healthy.population = 100 + d_healthy.salubrity = 0.85 + d_healthy.infection_turns = 0 + d_healthy.immunity_turns = 0 + d_healthy.lineage_id = "lineage-east" + d_healthy.strategy_id = "strat-trade" + d_healthy.color = "#f59e0b" + + state.demes[d_source.id] = d_source + state.demes[d_susceptible.id] = d_susceptible + state.demes[d_healthy.id] = d_healthy + (state.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([d_source.id]) + (state.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([d_susceptible.id]) + (state.cells["0,1"] as EvoTypes.HexCell).deme_ids.assign([d_healthy.id]) + (state.cells["0,0"] as EvoTypes.HexCell).carrying_capacity = 50.0 + (state.cells["0,0"] as EvoTypes.HexCell).humidity = 0.0 + (state.cells["1,0"] as EvoTypes.HexCell).carrying_capacity = 60.0 + (state.cells["1,0"] as EvoTypes.HexCell).humidity = 0.0 + (state.cells["0,1"] as EvoTypes.HexCell).carrying_capacity = 450.0 + (state.cells["0,1"] as EvoTypes.HexCell).humidity = 0.5 + + EvoTypes.get_relation(state, d_source.id, d_susceptible.id).pact_turns = 3 + EvoTypes.get_relation(state, d_source.id, d_healthy.id).pact_turns = 3 + + state.generation = 1 + EvoEpidemicSalubrity._run_epidemic_phase(state, "en", []) + + r._assert(d_susceptible.infection_turns == 2, "Susceptible pact partner (salubrity < 0.70) catches contagion with infection_turns == 2") + r._assert(d_healthy.infection_turns == 0, "Healthy pact partner (salubrity >= 0.70) resists epidemic contagion") + r._assert(d_source.infection_turns == 1, "Source deme decrements infection_turns from 2 to 1 after first turn") + + state.generation = 2 + state.turn_events.clear() + EvoEpidemicSalubrity._run_epidemic_phase(state, "en", []) + r._assert(d_source.infection_turns == 0 and d_source.immunity_turns == 6, "Recovered deme transitions to infection_turns == 0 and immunity_turns == 6") + + d_source.salubrity = 0.15 + d_source.population = 180 + state.generation = 3 + state.turn_events.clear() + EvoEpidemicSalubrity._run_epidemic_phase(state, "en", []) + r._assert(d_source.infection_turns == 0 and d_source.immunity_turns == 5, "Immune deme (immunity_turns > 0) cannot be reinfected even when salubrity < 0.35 and population >= 130") diff --git a/tests/test_engine_biophys.gd.uid b/tests/test_engine_biophys.gd.uid new file mode 100644 index 0000000..08a61f1 --- /dev/null +++ b/tests/test_engine_biophys.gd.uid @@ -0,0 +1 @@ +uid://djba4ek8ffl4q diff --git a/tests/test_runner.gd b/tests/test_runner.gd index e354022..9203124 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -11,22 +11,10 @@ func _init() -> void: _suite_cfg_path = _suite_cfg_dir.path_join("settings.json") EvoUserSettings.config_path_override = _suite_cfg_path print("=== EVOLUTION GODOT 4 CORE SIMULATION TEST SUITE ===") - _test_thermodynamics() - _test_catalog_and_presets() - _test_strategies_duel_and_persistence() - _test_decision_model_and_normalization() - _test_hawk_dove_and_symbiosis() - _test_tragedy_of_the_commons() - _test_baldwin_innovation_and_speciation() - _test_kin_merge_fission_and_macro_outcome() - _test_interventions() - _test_serialization_roundtrip() - _test_25_generations_all_presets() - _test_main_scene_and_ui_integration() - _test_nonblocking_async_decision_and_smooth_panning() - _test_emergent_geopolitics_and_visual_artifacts() - _test_social_memory_defense_tribute_and_synoecism() - _test_bugfix_regressions() + EvoTestCoreDomain.run(self) + EvoTestCognitiveOllama.run(self) + EvoTestEngineBiophys.run(self) + EvoTestUiAndWorld.run(self) EvoDecisionModel.wait_for_workers() var suite_catalog_path := _suite_cfg_dir.path_join("user_catalog.json") @@ -51,1496 +39,3 @@ func _assert(cond: bool, msg: String) -> void: else: _failed += 1 printerr("[FAIL] " + msg) - - -func _test_thermodynamics() -> void: - print("-- Testing Thermodynamics --") - var balanced_vec := EvoTypes.BiophysicalVector.new(0.10, 0.25, 0.05, 0.20, 0.10, 0.15) - var m1 := EvoThermodynamics.evaluate_thermodynamics(balanced_vec) - _assert(is_equal_approx(float(m1["positive_advantages"]), 0.55), "Balanced positive advantages == 0.55") - _assert(is_equal_approx(float(m1["required_cost"]), 0.0), "No required cost below 0.60 budget") - _assert(bool(m1["is_balanced"]), "Vector within 0.60 budget is balanced") - - var unbalanced_vec := EvoTypes.BiophysicalVector.new(0.0, 0.80, 0.10, 0.50, 0.50, 0.20) - var m2 := EvoThermodynamics.evaluate_thermodynamics(unbalanced_vec) - # A+ = 1.80 -> C_req = 0.5 * (1.80 - 0.60) = 0.60 - _assert(is_equal_approx(float(m2["positive_advantages"]), 1.80), "Positive advantages == 1.80") - _assert(is_equal_approx(float(m2["required_cost"]), 0.60), "Required cost == 0.60") - _assert(not bool(m2["is_balanced"]), "Uncompensated vector is not balanced") - - var fixed_vec := EvoThermodynamics.validate_and_balance_thermodynamics(unbalanced_vec, false) - var m2_after := EvoThermodynamics.evaluate_thermodynamics(fixed_vec) - _assert(bool(m2_after["is_balanced"]), "Validated vector is balanced") - _assert(is_equal_approx(fixed_vec.metabolic_cost, 0.60), "Adjusted metabolic cost == 0.60") - - var sandbox_vec := EvoThermodynamics.validate_and_balance_thermodynamics(unbalanced_vec, true) - _assert(is_equal_approx(sandbox_vec.metabolic_cost, 0.0), "Free sandbox preserves uncompensated cost") - - -func _test_catalog_and_presets() -> void: - print("-- Testing Catalog & Scientific Presets --") - var rift := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - _assert(rift.cells.size() == 91, "Rift Savanna radius 5 has 91 cells") - _assert(rift.demes.size() == 2, "Rift Savanna starts with 2 demes") - _assert(rift.traits_catalog.size() == 11, "Base catalog has 11 traits (6 bio + 5 cul)") - _assert(rift.decisions_catalog.size() == 7, "Base catalog has 7 decisions") - - var darwin := EvoCatalog.create_preset_world("preset-darwin-archipelago", "en") - _assert(darwin.cells.size() == 91, "Darwin Archipelago radius 5 has 91 cells") - _assert(darwin.demes.size() == 2 and darwin.demes.has("deme-darwin-founder") and darwin.demes.has("deme-darwin-rival"), "Darwin Archipelago starts with 2 rival founder demes (1v1)") - _assert(darwin.glacial_sea_level_cycle, "Darwin Archipelago has glacial sea level cycle enabled") - - var island := EvoCatalog.create_preset_world("preset-finite-island", "es") - _assert(island.cells.size() == 61, "Finite Island radius 4 has 61 cells") - _assert(island.demes.size() == 2 and island.demes.has("deme-island-1") and island.demes.has("deme-island-2"), "Finite Island starts with 2 rival clans (1v1)") - - var west: EvoTypes.Deme = rift.demes["deme-rift-west"] - _assert(EvoCatalog.deme_has_cultural_capacity(west, rift.traits_catalog), "West deme has cultural capacity via bio-social-instinct") - var injected := EvoCatalog.inject_trait_into_deme(rift, "deme-rift-west", "bio-r-strategy", "es") - _assert(injected, "Injecting bio-r-strategy succeeds") - _assert(west.biological_trait_ids.has("bio-r-strategy"), "West deme now has bio-r-strategy") - _assert(not west.biological_trait_ids.has("bio-k-strategy"), "Mutually exclusive bio-k-strategy was removed") - - -func _test_strategies_duel_and_persistence() -> void: - print("-- Testing 1v1 Strategy Duel, Inheritance, Persistence & Forge UI --") - # 1. Base strategies catalog & vector computation - var base_strats := EvoCatalog.create_base_strategies_catalog("es") - var base_traits := EvoCatalog.create_base_traits_catalog("es") - var expected_strat_ids: Array[String] = [ - "strat-cooperation", - "strat-expansion", - "strat-trade", - "strat-war", - "strat-innovation" - ] - _assert(base_strats.size() == 5, "Base strategies catalog has 5 default strategies") - for sid in expected_strat_ids: - _assert(base_strats.has(sid), "Base strategies catalog includes %s" % sid) - var s: EvoTypes.StrategyDef = base_strats[sid] - var vec := EvoCatalog.compute_strategy_vector(s, base_traits) - _assert(vec != null and vec.extraction_efficiency > 0.0, "Strategy %s computes valid biophysical vector" % sid) - - # 2. 1v1 strategy matchup initialization in create_preset_world - var duel_world := EvoCatalog.create_preset_world("preset-rift-savanna", "en", "strat-trade", "strat-innovation") - _assert(duel_world.strategy_a_id == "strat-trade" and duel_world.strategy_b_id == "strat-innovation", "Preset world records strategy_a_id and strategy_b_id") - var west: EvoTypes.Deme = duel_world.demes["deme-rift-west"] - var east: EvoTypes.Deme = duel_world.demes["deme-rift-east"] - var strat_trade: EvoTypes.StrategyDef = duel_world.strategies_catalog["strat-trade"] - var strat_innov: EvoTypes.StrategyDef = duel_world.strategies_catalog["strat-innovation"] - _assert(west.strategy_id == "strat-trade" and west.color == strat_trade.color, "Slot A deme receives strat-trade id and color") - _assert(west.biological_trait_ids == strat_trade.biological_trait_ids and west.cultural_trait_ids == strat_trade.cultural_trait_ids, "Slot A deme receives strat-trade biological and cultural traits") - _assert(east.strategy_id == "strat-innovation" and east.color == strat_innov.color, "Slot B deme receives strat-innovation id and color") - _assert(east.biological_trait_ids == strat_innov.biological_trait_ids and east.cultural_trait_ids == strat_innov.cultural_trait_ids, "Slot B deme receives strat-innovation biological and cultural traits") - var lin_w: EvoTypes.LineageNode = duel_world.lineage_tree[west.lineage_id] - var lin_e: EvoTypes.LineageNode = duel_world.lineage_tree[east.lineage_id] - _assert(lin_w.strategy_id == "strat-trade" and lin_w.color == strat_trade.color and lin_w.biological_trait_ids == strat_trade.biological_trait_ids, "Slot A lineage node synchronized with strat-trade") - _assert(lin_e.strategy_id == "strat-innovation" and lin_e.color == strat_innov.color and lin_e.biological_trait_ids == strat_innov.biological_trait_ids, "Slot B lineage node synchronized with strat-innovation") - - # 3. Strategy inheritance across colonist budding migration and allopatric speciation - west.population = 140 - west.energy_reserves = 200.0 - west.last_decision_probabilities = {"dec-selective-migration": 0.90, "dec-conservative-forage": 0.10} - west.last_dominant_decision_id = "dec-selective-migration" - var init_pops := {west.id: west.population, east.id: east.population} - var eff_vecs := { - west.id: EvoEcologyInteraction.compute_effective_vector(west, duel_world), - east.id: EvoEcologyInteraction.compute_effective_vector(east, duel_world) - } - var ext_energy := {west.id: 90.0, east.id: 90.0} - EvoDemographyMigration.run_demography_and_migration_phase(duel_world, "en", init_pops, eff_vecs, {}, ext_energy) - var budded_daughter: EvoTypes.Deme = null - for did in duel_world.demes.keys(): - if did != "deme-rift-west" and did != "deme-rift-east": - budded_daughter = duel_world.demes[did] - break - _assert(budded_daughter != null and budded_daughter.strategy_id == "strat-trade", "Colonist budding migration preserves parent strategy_id") - - var spec_world := EvoCatalog.create_preset_world("preset-darwin-archipelago", "en", "strat-expansion", "strat-war") - spec_world.generation = 12 - var darwin_founder: EvoTypes.Deme = spec_world.demes["deme-darwin-founder"] - (spec_world.cells[darwin_founder.hex_id] as EvoTypes.HexCell).deme_ids.erase(darwin_founder.id) - darwin_founder.hex_id = "0,-2" - (spec_world.cells["0,-2"] as EvoTypes.HexCell).deme_ids.append(darwin_founder.id) - darwin_founder.isolation_turns = 10 - var spec_entries := EvoMilestonePhase.run_milestone_phase(spec_world, "en") - _assert(not spec_entries.is_empty() and darwin_founder.strategy_id == "strat-expansion", "Allopatric speciation preserves deme strategy_id") - _assert((spec_world.lineage_tree[darwin_founder.lineage_id] as EvoTypes.LineageNode).strategy_id == "strat-expansion", "Allopatric speciation child lineage preserves strategy_id") - - # 4. JSON persistence of custom user strategies and traits via EvoUserSettings - var tmp_dir := "/tmp/evolution_test_catalog_%d" % Time.get_ticks_usec() - var tmp_settings_path := tmp_dir.path_join("settings.json") - EvoUserSettings.config_path_override = tmp_settings_path - - var custom_tr := EvoTypes.TraitDef.new() - custom_tr.id = "bio-custom-carapace" - custom_tr.name = "Chitinous Carapace" - custom_tr.category = "biological" - custom_tr.origin = "user" - custom_tr.description = "Custom defensive adaptation." - custom_tr.vector = EvoTypes.BiophysicalVector.new(0.15, 0.25, 0.05, 0.10, 0.20, 0.20) - duel_world.traits_catalog[custom_tr.id] = custom_tr - - var custom_st := EvoTypes.StrategyDef.new() - custom_st.id = "strat-custom-fortress" - custom_st.name = "Fortress Doctrine" - custom_st.description = "Custom defensive strategy." - custom_st.icon = "🛡️" - custom_st.color = "#14B8A6" - custom_st.origin = "user" - custom_st.biological_trait_ids = ["bio-k-strategy", "bio-social-instinct", "bio-custom-carapace"] - custom_st.cultural_trait_ids = ["cul-communal-conservation"] - duel_world.strategies_catalog[custom_st.id] = custom_st - - EvoUserSettings.save_user_catalog(duel_world) - var catalog_json_path := EvoUserSettings.get_user_catalog_path() - _assert(FileAccess.file_exists(catalog_json_path), "EvoUserSettings.save_user_catalog writes user_catalog.json") - - var fresh_state := EvoTypes.WorldState.new() - fresh_state.traits_catalog = EvoCatalog.create_base_traits_catalog("en") - fresh_state.decisions_catalog = EvoCatalog.create_base_decisions_catalog("en") - fresh_state.strategies_catalog = EvoCatalog.create_base_strategies_catalog("en") - EvoUserSettings.load_user_catalog_into(fresh_state) - _assert(fresh_state.traits_catalog.has("bio-custom-carapace"), "load_user_catalog_into restores custom user trait") - _assert(fresh_state.strategies_catalog.has("strat-custom-fortress"), "load_user_catalog_into restores custom user strategy") - _assert((fresh_state.strategies_catalog["strat-custom-fortress"] as EvoTypes.StrategyDef).name == "Fortress Doctrine", "Restored custom user strategy preserves name and metadata") - - # 5. UI integration in EvoMainController, EvoMainHud & EvoLabModal - var packed: PackedScene = load("res://scenes/main.tscn") - var main: EvoMainController = packed.instantiate() as EvoMainController - main.quit_on_close_enabled = false - root.add_child(main) - if main.camera == null: - main._ready() - main.hud._set_startup_blocked(false) - - var opt_a := main.hud.strategy_a_option - var opt_b := main.hud.strategy_b_option - _assert(opt_a != null and opt_b != null and opt_a.item_count >= 5 and opt_b.item_count >= 5, "HUD strategy_a_option and strategy_b_option populated with strategies") - _assert(str(opt_a.get_item_metadata(opt_a.selected)) == main.world_state.strategy_a_id, "strategy_a_option selection reflects world_state.strategy_a_id") - _assert(str(opt_b.get_item_metadata(opt_b.selected)) == main.world_state.strategy_b_id, "strategy_b_option selection reflects world_state.strategy_b_id") - - main.hud.open_lab_modal() - var lab := main.hud.lab_modal - lab._switch_tab("strategy") - lab._start_new_item() - var cb_k: CheckBox = lab.strategy_trait_checks["bio-k-strategy"] - var cb_r: CheckBox = lab.strategy_trait_checks["bio-r-strategy"] - cb_k.button_pressed = true - cb_r.button_pressed = true - _assert(cb_r.button_pressed and not cb_k.button_pressed, "Lab strategy trait checkboxes enforce mutual exclusion between bio-r-strategy and bio-k-strategy") - - lab.id_edit.text = "strat-custom-duelist" - lab.name_edit.text = "Duelist Doctrine" - lab._save_current_item() - _assert(main.world_state.strategies_catalog.has("strat-custom-duelist"), "Saving custom strategy in Lab Modal adds it to strategies_catalog") - - main.set_matchup_strategies("strat-custom-duelist", "strat-trade") - _assert(main.world_state.generation == 0 and main.world_state.strategy_a_id == "strat-custom-duelist" and main.world_state.strategy_b_id == "strat-trade", "set_matchup_strategies resets to Generation 0 with selected strategies") - _assert((main.world_state.demes["deme-rift-west"] as EvoTypes.Deme).strategy_id == "strat-custom-duelist", "Founder deme A updated with custom strategy") - - main.step_generation() - _assert(main.world_state.generation == 1, "Generation advances before reset_matchup") - main.reset_matchup() - _assert(main.world_state.generation == 0 and main.world_state.strategies_catalog.has("strat-custom-duelist") and main.world_state.strategy_a_id == "strat-custom-duelist", "reset_matchup resets to Generation 0 and preserves custom user strategies") - - main.hud.select_deme("deme-rift-west") - _assert(main.hud.deme_strategy_label != null and "Duelist Doctrine" in main.hud.deme_strategy_label.text, "Inspector deme_strategy_label displays selected village strategy") - - main.free() - if FileAccess.file_exists(catalog_json_path): - DirAccess.remove_absolute(catalog_json_path) - if FileAccess.file_exists(tmp_settings_path): - DirAccess.remove_absolute(tmp_settings_path) - if DirAccess.dir_exists_absolute(tmp_dir): - DirAccess.remove_absolute(tmp_dir) - EvoUserSettings.config_path_override = _suite_cfg_path - - -func _test_decision_model_and_normalization() -> void: - print("-- Testing Decision Model & Normalization --") - var ids: Array[String] = ["d1", "d2", "d3"] - var raw := {"d1": 0.52, "d2": 0.31, "d3": 0.17} - var norm := EvoDecisionModel.normalize_decision_probabilities(raw, ids) - var probs: Dictionary = norm["probabilities"] - var sum_p: float = 0.0 - for k in probs.keys(): - sum_p += float(probs[k]) - _assert(is_equal_approx(sum_p, 1.0), "Normalized probabilities sum to 1.0000") - _assert(str(norm["dominant_decision_id"]) == "d1", "Dominant decision is d1") - - # Verify deterministic heuristic decision scorer has been completely removed - var dm_inst := EvoDecisionModel.new() - var hs_inst := EvoHeuristicScorer.new() - _assert(not dm_inst.has_method("evaluate_deme_heuristic"), "EvoDecisionModel has no evaluate_deme_heuristic fallback") - _assert(not hs_inst.has_method("evaluate_deme_heuristic"), "EvoHeuristicScorer has no evaluate_deme_heuristic fallback") - - # Verify pure Ollama System 1 model evaluation via signature cache - var rift := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - var west: EvoTypes.Deme = rift.demes["deme-rift-west"] - var east: EvoTypes.Deme = rift.demes["deme-rift-east"] - var west_sig := EvoDecisionModel.compute_deme_evaluation_signature(west, rift) - var east_sig := EvoDecisionModel.compute_deme_evaluation_signature(east, rift) - EvoOllamaClient.seed_signature_cache(west_sig, { - "probabilities": {"dec-conservative-forage": 0.65, "dec-cooperate-store": 0.35}, - "dominant_decision_id": "dec-conservative-forage", - "confidence": 0.88 - }) - EvoOllamaClient.seed_signature_cache(east_sig, { - "probabilities": {"dec-cooperate-store": 0.70, "dec-selective-migration": 0.30}, - "dominant_decision_id": "dec-cooperate-store", - "confidence": 0.91 - }) - EvoDecisionModel.evaluate_all_demes(rift, "es") - _assert(west.last_dominant_decision_id == "dec-conservative-forage", "West forest deme applies Ollama System 1 decision") - _assert(east.last_dominant_decision_id == "dec-cooperate-store", "East savanna deme applies Ollama System 1 decision") - _assert("Ollama System 1" in west.last_justification, "Justification reflects live neural Ollama System 1 evaluation") - EvoOllamaClient.clear_cache() - - -func _test_hawk_dove_and_symbiosis() -> void: - print("-- Testing Hawk-Dove Dispute & Symbiosis --") - var state := EvoCatalog.create_preset_world("preset-finite-island", "es") - var d1: EvoTypes.Deme = state.demes["deme-island-1"] - var d2: EvoTypes.Deme = state.demes["deme-island-2"] - - # Place both rival clans in center cell 0,0 for direct intra-hex interaction - (state.cells[d1.hex_id] as EvoTypes.HexCell).deme_ids.erase(d1.id) - (state.cells[d2.hex_id] as EvoTypes.HexCell).deme_ids.erase(d2.id) - d1.hex_id = "0,0" - d2.hex_id = "0,0" - (state.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([d1.id, d2.id]) - var init_pop_1 := d1.population - var init_pop_2 := d2.population - - # Force Hawk vs Hawk between d1 and d2 - d1.last_decision_probabilities = {"dec-territorial-dispute": 0.60, "dec-conservative-forage": 0.40} - d2.last_decision_probabilities = {"dec-territorial-dispute": 0.60, "dec-conservative-forage": 0.40} - EvoBiophysPhase.run_biophysical_phase(state, "es") - - var has_dispute_flow := false - for fl in state.active_flows: - if fl.flow_type == "dispute": - has_dispute_flow = true - break - _assert(has_dispute_flow, "Hawk vs Hawk generates dispute flow") - _assert(d1.population < init_pop_1 and d2.population < init_pop_2, "Hawks suffer casualties and metabolic penalty") - - # Force Symbiosis and Horizontal Cultural Diffusion between d1 and d2 - state.active_flows.clear() - d1.cultural_trait_ids.assign(["cul-fire-processing"]) - d2.cultural_trait_ids.clear() - d1.last_decision_probabilities = {"dec-exchange-symbiosis": 0.80, "dec-cooperate-store": 0.20} - d2.last_decision_probabilities = {"dec-exchange-symbiosis": 0.80, "dec-cooperate-store": 0.20} - EvoBiophysPhase.run_biophysical_phase(state, "es") - _assert(d2.cultural_trait_ids.has("cul-fire-processing"), "Horizontal cultural diffusion transmitted cul-fire-processing to d2") - - -func _test_tragedy_of_the_commons() -> void: - print("-- Testing Tragedy of the Commons --") - var island := EvoCatalog.create_preset_world("preset-finite-island", "es") - for did in island.demes.keys(): - var d: EvoTypes.Deme = island.demes[did] - d.last_decision_probabilities = {"dec-intensive-overexploit": 0.65, "dec-territorial-dispute": 0.20, "dec-conservative-forage": 0.15} - d.last_dominant_decision_id = "dec-intensive-overexploit" - var tragedy_occurred := false - var tragedy_hex_id := "" - for _g in range(12): - EvoTurnEngine.advance_generation(island, "es") - for entry in island.chronicle: - if entry.event_type == "tragedy_of_commons": - tragedy_occurred = true - tragedy_hex_id = entry.hex_id - break - if tragedy_occurred: - break - _assert(tragedy_occurred, "Finite Island without conservation triggers Tragedy of the Commons") - var degraded_cell: EvoTypes.HexCell = island.cells[tragedy_hex_id] if island.cells.has(tragedy_hex_id) else island.cells["1,0"] - _assert(degraded_cell.carrying_capacity < 0.60 * degraded_cell.carrying_capacity_max, "Overexploited cell K degraded below 60% of Kmax") - - -func _test_baldwin_innovation_and_speciation() -> void: - print("-- Testing Baldwin Effect, Cultural Innovation & Allopatric Speciation --") - # 1. Baldwin assimilation - var state_b := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - var east_b: EvoTypes.Deme = state_b.demes["deme-rift-east"] - east_b.baldwin_counters["dec-cooperate-store"] = 8 - east_b.last_dominant_decision_id = "dec-cooperate-store" - var entries_b := EvoMilestonePhase.run_milestone_phase(state_b, "es") - _assert(entries_b.size() == 1 and entries_b[0].event_type == "baldwin_trait", "Baldwin assimilation triggers at counter >= 8") - _assert(int(east_b.baldwin_counters["dec-cooperate-store"]) == 0, "Baldwin counter resets to 0 after assimilation") - - # 2. Cultural innovation & dec-explore-innovate eligibility - var state_c := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - var east_c: EvoTypes.Deme = state_c.demes["deme-rift-east"] - east_c.cultural_trait_ids.clear() - var innovate_dec: EvoTypes.DecisionDef = state_c.decisions_catalog["dec-explore-innovate"] - _assert(EvoDecisionModel.is_decision_eligible(east_c, innovate_dec, state_c.traits_catalog), "dec-explore-innovate is eligible for deme with cultural capacity") - var non_cultural_deme := east_c.duplicate_deme() - non_cultural_deme.biological_trait_ids.assign(["bio-k-strategy"]) - _assert(not EvoDecisionModel.is_decision_eligible(non_cultural_deme, innovate_dec, state_c.traits_catalog), "dec-explore-innovate requires cultural capacity") - - east_c.innovation_counter = 1.15 - east_c.last_energy_delta = 5.0 - var entries_c := EvoMilestonePhase.run_milestone_phase(state_c, "es") - _assert(entries_c.size() == 1 and entries_c[0].event_type == "cultural_innovation", "Cultural innovation triggers at counter >= 1.0") - _assert(east_c.cultural_trait_ids.size() == 1, "Deme acquires 1st newly synthesized cultural tradition") - - east_c.innovation_counter = 1.25 - east_c.last_energy_delta = 5.0 - var entries_c2 := EvoMilestonePhase.run_milestone_phase(state_c, "es") - _assert(entries_c2.size() == 1 and entries_c2[0].event_type == "cultural_innovation" and east_c.cultural_trait_ids.size() == 2, "Deme can acquire a 2nd emergent cultural trait (emergent_cul_count < 2)") - - east_c.innovation_counter = 1.25 - east_c.last_energy_delta = 5.0 - var entries_c3 := EvoMilestonePhase.run_milestone_phase(state_c, "es") - _assert(entries_c3.is_empty() and east_c.cultural_trait_ids.size() == 2, "Cultural innovation caps at 2 emergent cultural traits") - - # 3. Allopatric speciation - var state_s := EvoCatalog.create_preset_world("preset-darwin-archipelago", "es") - state_s.generation = 12 - var founder: EvoTypes.Deme = state_s.demes["deme-darwin-founder"] - (state_s.cells[founder.hex_id] as EvoTypes.HexCell).deme_ids.erase(founder.id) - founder.hex_id = "0,-2" # North tundra island - (state_s.cells["0,-2"] as EvoTypes.HexCell).deme_ids.append(founder.id) - founder.isolation_turns = 10 - var old_lineage := founder.lineage_id - var old_color := founder.color - var entries_s := EvoMilestonePhase.run_milestone_phase(state_s, "es") - _assert(entries_s.size() == 1 and entries_s[0].event_type == "allopatric_speciation", "Allopatric speciation triggers at isolation >= 10 in distinct biome") - _assert(founder.lineage_id != old_lineage, "Speciated deme receives new lineage ID") - _assert(founder.color != old_color, "Speciated deme receives new distinct color") - _assert(state_s.lineage_tree.size() == 3, "Lineage tree now has 3 nodes (2 initial + 1 speciated)") - - -func _test_kin_merge_fission_and_macro_outcome() -> void: - print("-- Testing Kin Merge Flow Redirection, Fission Bottleneck & Macro-Outcome Trigger --") - # 1. Kin merging redirects from_deme_id and to_deme_id without corrupting inter-hex flow recipients - var state_m := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - var deme_a: EvoTypes.Deme = state_m.demes["deme-rift-west"] - var deme_c: EvoTypes.Deme = state_m.demes["deme-rift-east"] - var deme_b := deme_a.duplicate_deme() - deme_b.id = "deme-rift-west-m1" - deme_b.name = "Clan Kin B" - deme_b.hex_id = deme_a.hex_id - deme_b.population = 45 - state_m.demes[deme_b.id] = deme_b - (state_m.cells[deme_a.hex_id] as EvoTypes.HexCell).deme_ids.append(deme_b.id) - - var inter_flow := EvoTypes.HexEdgeFlow.new() - inter_flow.from_hex_id = deme_b.hex_id - inter_flow.to_hex_id = deme_c.hex_id - inter_flow.flow_type = "exchange" - inter_flow.from_deme_id = deme_b.id - inter_flow.to_deme_id = deme_c.id - state_m.active_flows.append(inter_flow) - - var initial_pops_m := {deme_a.id: deme_a.population, deme_b.id: deme_b.population, deme_c.id: deme_c.population} - var eff_vecs_m := {} - var ext_energy_m := {} - for did in state_m.demes.keys(): - var d: EvoTypes.Deme = state_m.demes[did] - eff_vecs_m[did] = EvoEcologyInteraction.compute_effective_vector(d, state_m) - ext_energy_m[did] = float(d.population) * 0.60 - d.last_decision_probabilities = {"dec-conservative-forage": 1.0} - d.last_dominant_decision_id = "dec-conservative-forage" - - EvoDemographyMigration.run_demography_and_migration_phase(state_m, "es", initial_pops_m, eff_vecs_m, {}, ext_energy_m) - _assert(not state_m.demes.has(deme_b.id), "Kin deme_b merged into deme_a") - var preserved_recipient := false - for fl in state_m.active_flows: - if fl.flow_type == "exchange" and fl.from_deme_id == deme_a.id and fl.to_deme_id == deme_c.id: - preserved_recipient = true - break - _assert(preserved_recipient, "Kin merge redirects from_deme_id to deme_a without overwriting inter-hex recipient deme_c") - - # 2. Fission migration does not falsely trigger cultural_loss bottleneck on a healthy parent deme - var state_f := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - var parent_d: EvoTypes.Deme = state_f.demes["deme-rift-west"] - parent_d.population = 60 - parent_d.energy_reserves = 120.0 - parent_d.cultural_trait_ids.assign(["cul-fire-processing"]) - parent_d.last_decision_probabilities = {"dec-selective-migration": 0.90, "dec-conservative-forage": 0.10} - parent_d.last_dominant_decision_id = "dec-selective-migration" - var init_pops_f := {parent_d.id: 60, "deme-rift-east": 140} - var eff_vecs_f := {} - var ext_energy_f := {} - for did in state_f.demes.keys(): - var d: EvoTypes.Deme = state_f.demes[did] - eff_vecs_f[did] = EvoEcologyInteraction.compute_effective_vector(d, state_f) - ext_energy_f[did] = float(d.population) * 0.65 - EvoDemographyMigration.run_demography_and_migration_phase(state_f, "es", init_pops_f, eff_vecs_f, {}, ext_energy_f) - var had_cultural_loss := false - for entry in state_f.chronicle: - if entry.event_type == "cultural_loss" and entry.deme_id == parent_d.id: - had_cultural_loss = true - break - _assert(parent_d.population < 50 and parent_d.cultural_trait_ids.has("cul-fire-processing") and not had_cultural_loss, "Healthy parent deme dropping below 50 pop solely due to fission migration does not suffer false cultural_loss") - - # 3. has_macro_outcome only triggers on the turn the macro-outcome is logged, not on subsequent turns - var state_mo := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - state_mo.quiet_turns = 11 - for did in state_mo.demes.keys(): - var d: EvoTypes.Deme = state_mo.demes[did] - state_mo.previous_populations[did] = d.population - var sig := EvoDecisionModel.compute_deme_evaluation_signature(d, state_mo) - EvoOllamaClient.seed_signature_cache(sig, { - "probabilities": {"dec-conservative-forage": 1.0}, - "dominant_decision_id": "dec-conservative-forage", - "confidence": 0.90 - }) - var res_mo1 := EvoTurnEngine.advance_generation(state_mo, "es") - # Force ecological_equilibrium on a predictable quiet state if population shifted by >5% - if not bool(res_mo1["has_macro_outcome"]): - state_mo.macro_outcome_type = "ecological_equilibrium" - var eq_entry := EvoTypes.ChronicleEntry.new() - eq_entry.id = "chronicle-equilibrium-test" - eq_entry.generation = state_mo.generation - eq_entry.event_type = "ecological_equilibrium" - state_mo.chronicle.append(eq_entry) - var res_mo2 := EvoTurnEngine.advance_generation(state_mo, "es") - _assert(state_mo.macro_outcome_type != "" and not bool(res_mo2["has_macro_outcome"]), "has_macro_outcome is false on subsequent turns after macro-outcome was already logged") - - -func _test_interventions() -> void: - print("-- Testing Player Interventions (Brushes) --") - var state := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - state.selective_pressure_points = 100 - var targets: Array[String] = ["0,0"] - - var ok_biome := EvoTurnEngine.apply_set_biome(state, targets, "tundra", "es") - var c0: EvoTypes.HexCell = state.cells["0,0"] - var tundra_defs: Dictionary = EvoTypes.BIOME_CLIMATE_DEFAULTS["tundra"] - _assert(ok_biome and c0.biome == "tundra", "set_biome changes cell biome to tundra") - _assert( - is_equal_approx(c0.seasonal_regen_rate, float(tundra_defs["regen"])) and is_equal_approx(c0.carrying_capacity_max, float(tundra_defs["k_max"])), - "set_biome updates seasonal_regen_rate and carrying_capacity_max from BIOME_CLIMATE_DEFAULTS" - ) - _assert(state.selective_pressure_points == 90, "set_biome costs 10 points") - - var ok_alt := EvoTurnEngine.apply_adjust_altitude(state, targets, 0.18, "es") - _assert(ok_alt and state.selective_pressure_points == 75, "adjust_altitude costs 15 points") - - var ok_cap := EvoTurnEngine.apply_adjust_carrying_capacity(state, targets, 0.25) - _assert(ok_cap and state.selective_pressure_points == 65, "adjust_carrying_capacity costs 10 points") - - var prev_sea := state.sea_level - var ok_sea := EvoTurnEngine.apply_adjust_sea_level(state, 0.05, "es") - _assert(ok_sea and is_equal_approx(state.sea_level, prev_sea + 0.05), "adjust_sea_level raises sea level by 0.05") - _assert(state.selective_pressure_points == 40, "adjust_sea_level costs 25 points") - - -func _test_serialization_roundtrip() -> void: - print("-- Testing JSON Serialization Round-Trip --") - var state := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - EvoTurnEngine.advance_generation(state, "es") - state.recent_extinction_hexes.assign(["1,-1"]) - state.disused_connections.append({ - "from_hex_id": "-1,0", - "to_hex_id": "1,0", - "from_deme_id": "deme-rift-west", - "to_deme_id": "deme-rift-east", - "flow_type": "exchange", - "age": 2 - }) - state.previous_populations["deme-rift-west"] = 177 - var json_str := JSON.stringify(state.to_dict()) - var parsed: Variant = JSON.parse_string(json_str) - _assert(typeof(parsed) == TYPE_DICTIONARY, "Serialized state parses back as Dictionary") - var restored := EvoTypes.WorldState.from_dict(parsed as Dictionary) - _assert(restored.generation == state.generation, "Restored generation matches") - _assert(restored.cells.size() == state.cells.size(), "Restored cells count matches") - _assert(restored.demes.size() == state.demes.size(), "Restored demes count matches") - _assert(restored.recent_extinction_hexes == state.recent_extinction_hexes, "Restored recent_extinction_hexes matches") - _assert(restored.disused_connections.size() == 1 and int(restored.disused_connections[0]["age"]) == 2, "Restored disused_connections matches") - _assert(int(restored.previous_populations.get("deme-rift-west", 0)) == 177, "Restored previous_populations matches") - - -func _test_25_generations_all_presets() -> void: - print("-- Testing 25 Generations Across All 3 Presets --") - var mock_model_entry := { - "probabilities": { - "dec-conservative-forage": 0.50, - "dec-cooperate-store": 0.30, - "dec-selective-migration": 0.20 - }, - "dominant_decision_id": "dec-conservative-forage", - "confidence": 0.85 - } - for preset_id in EvoCatalog.PRESET_IDS: - var state := EvoCatalog.create_preset_world(preset_id, "es") - for _g in range(25): - for did in state.demes.keys(): - var d: EvoTypes.Deme = state.demes[did] - var sig := EvoDecisionModel.compute_deme_evaluation_signature(d, state) - EvoOllamaClient.seed_signature_cache(sig, mock_model_entry) - d.last_decision_probabilities = (mock_model_entry["probabilities"] as Dictionary).duplicate() - d.last_dominant_decision_id = str(mock_model_entry["dominant_decision_id"]) - EvoTurnEngine.advance_generation(state, "es") - _assert(state.generation == 25, "%s reached generation 25 cleanly" % preset_id) - for did in state.demes.keys(): - var deme: EvoTypes.Deme = state.demes[did] - var sum_p: float = 0.0 - for p in deme.last_decision_probabilities.values(): - sum_p += float(p) - _assert(is_equal_approx(sum_p, 1.0), "%s deme %s probabilities sum to 1.0 at G25" % [preset_id, did]) - - -func _test_main_scene_and_ui_integration() -> void: - print("-- Testing Main Scene, WorldRenderer & UI Integration --") - var packed: PackedScene = load("res://scenes/main.tscn") - _assert(packed != null, "res://scenes/main.tscn loads as PackedScene") - var main: EvoMainController = packed.instantiate() as EvoMainController - main.quit_on_close_enabled = false - root.add_child(main) - if main.camera == null: - main._ready() - - _assert(main.camera != null, "main.camera exists and is configured") - _assert(main.world_renderer != null, "main.world_renderer exists and is configured") - _assert(main.hud != null, "main.hud exists and is configured") - _assert(main.world_state != null, "main.world_state initialized on startup") - - # Verify all 52 PNG files in res://assets/sprites/ and ImageTexture generation - EvoPixelArtGen.ensure_all_assets() - var total_sprites: int = 0 - for s_name in EvoPixelArtGen.TERRAIN_SPRITES: - var path := "res://assets/sprites/terrain/%s.png" % s_name - _assert(FileAccess.file_exists(ProjectSettings.globalize_path(path)), "Terrain PNG exists: %s" % path) - var tex := EvoPixelArtGen.get_texture(s_name) - _assert(tex != null and tex.get_width() > 0, "Valid ImageTexture for %s" % s_name) - total_sprites += 1 - for s_name in EvoPixelArtGen.VILLAGE_SPRITES: - var path := "res://assets/sprites/villages/%s.png" % s_name - _assert(FileAccess.file_exists(ProjectSettings.globalize_path(path)), "Village PNG exists: %s" % path) - var tex := EvoPixelArtGen.get_texture(s_name) - _assert(tex != null and tex.get_width() > 0, "Valid ImageTexture for %s" % s_name) - total_sprites += 1 - for s_name in EvoPixelArtGen.UI_SPRITES: - var path := "res://assets/sprites/ui/%s.png" % s_name - _assert(FileAccess.file_exists(ProjectSettings.globalize_path(path)), "UI PNG exists: %s" % path) - var tex := EvoPixelArtGen.get_texture(s_name) - _assert(tex != null and tex.get_width() > 0, "Valid ImageTexture for %s" % s_name) - total_sprites += 1 - var expected_sprites := EvoPixelArtGen.TERRAIN_SPRITES.size() + EvoPixelArtGen.VILLAGE_SPRITES.size() + EvoPixelArtGen.UI_SPRITES.size() - _assert(total_sprites == expected_sprites and total_sprites >= 60, "All %d pixel-art sprites verified" % total_sprites) - - # Verify Dual System 1 Decision Models (d3:4b + tev1:4b), Settings Persistence & Dynamic UI Text - EvoUserSettings.config_path_override = "" - _assert(EvoUserSettings.get_config_dir().ends_with("/.config/Evolution"), "Default config dir points to ~/.config/Evolution") - EvoUserSettings.config_path_override = _suite_cfg_path - _assert(EvoUserSettings.DEFAULT_CONTEXT_LENGTH == 8192, "Default context window size is 8192") - _assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-rift-west") == "d3:4b", "deme-rift-west assigned to d3:4b") - _assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-rift-east") == "tev1:4b", "deme-rift-east assigned to tev1:4b") - _assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-island-1") == "d3:4b", "deme-island-1 assigned to d3:4b") - _assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-island-2") == "tev1:4b", "deme-island-2 assigned to tev1:4b") - var ollama_status := EvoDecisionModel.check_ollama_status() - _assert(ollama_status.has("primary_tag") and ollama_status["primary_tag"] == "d3:4b", "Ollama status reports primary System 1 model d3:4b") - _assert(ollama_status.has("secondary_tag") and ollama_status["secondary_tag"] == "tev1:4b", "Ollama status reports secondary System 1 model tev1:4b") - - var mock_tags := [ - {"name": "d3:4b", "capabilities": ["decision", "vision"]}, - {"name": "tev1:4b", "capabilities": ["decision"]}, - {"name": "embeddinggemma:latest", "capabilities": ["embedding"]} - ] - var filtered_dec := EvoOllamaClient.filter_decision_models_from_tags(mock_tags) - _assert(filtered_dec.size() == 2 and filtered_dec.has("d3:4b") and filtered_dec.has("tev1:4b"), "filter_decision_models_from_tags returns only models with 'decision' capability") - - var test_cfg_path := ProjectSettings.globalize_path("user://test_evolution_settings.json") - EvoUserSettings.config_path_override = test_cfg_path - EvoUserSettings.primary_model = "custom-dec-a:4b" - EvoUserSettings.secondary_model = "custom-dec-b:4b" - main.hud._commit_context_window_text("16384") - _assert(FileAccess.file_exists(test_cfg_path), "Settings file saved on change") - EvoUserSettings.primary_model = "d3:4b" - EvoUserSettings.secondary_model = "tev1:4b" - EvoUserSettings.context_length = 8192 - EvoUserSettings.load_settings() - _assert(EvoUserSettings.primary_model == "custom-dec-a:4b" and EvoUserSettings.secondary_model == "custom-dec-b:4b" and EvoUserSettings.context_length == 16384, "Settings round-trip loads custom models and free-text context window 16384") - main.hud._apply_decision_models_update() - _assert("custom-dec-a:4b" in main.hud._models_diag_lbl.text and "custom-dec-b:4b" in main.hud._models_diag_lbl.text and "16384" in main.hud._models_diag_lbl.text, "HUD diagnostic card updates dynamically with active models and context window") - _assert("custom-dec-a:4b" in main.hud._settings_btn.tooltip_text and "custom-dec-b:4b" in main.hud._settings_btn.tooltip_text, "Settings button tooltip updates dynamically when models change") - var quit_signals: Array[int] = [0] - main.hud.quit_requested.connect(func() -> void: quit_signals[0] += 1) - - # Verify Ollama Offline blocking popup prevents starting the game and closes app when dismissed - EvoHudBannersToasts.show_ollama_offline_popup(main.hud) - _assert(main.hud._missing_models_modal.visible and main.hud._ollama_startup_blocked, "Ollama offline popup is visible and blocks startup") - _assert(main.hud._play_btn.disabled and main.hud._step_btn.disabled, "Play and Step buttons are disabled when Ollama is offline") - _assert(not main.hud._missing_models_download_btn.visible, "Download button is hidden when Ollama is offline") - var blocked_gen := main.world_state.generation - main.hud._on_play_pressed() - main.step_generation() - _assert(not main.is_playing and main.world_state.generation == blocked_gen, "Game cannot start or advance generation while Ollama is offline") - main.hud._on_missing_models_skip_pressed() - _assert(not main.hud._missing_models_modal.visible and quit_signals[0] == 1, "Closing Ollama offline popup emits quit_requested to close the application") - - # Verify Missing Models popup also blocks startup and closes app if dismissed without downloading - EvoHudBannersToasts.show_missing_models_popup(main.hud, ["missing-dec:4b"]) - _assert(main.hud._missing_models_modal.visible and "missing-dec:4b" in main.hud._missing_models_desc_lbl.text, "Missing models popup displays missing model tags at startup") - _assert(main.hud._ollama_startup_blocked and main.hud._play_btn.disabled and main.hud._step_btn.disabled, "Missing models popup blocks game startup until downloaded") - main.hud._on_missing_models_skip_pressed() - _assert(not main.hud._missing_models_modal.visible and quit_signals[0] == 2, "Closing missing models popup emits quit_requested") - - # Unblock startup for subsequent UI interaction tests - main.hud._set_startup_blocked(false) - DirAccess.remove_absolute(test_cfg_path) - EvoUserSettings.config_path_override = _suite_cfg_path - EvoUserSettings.primary_model = EvoUserSettings.DEFAULT_PRIMARY_MODEL - EvoUserSettings.secondary_model = EvoUserSettings.DEFAULT_SECONDARY_MODEL - EvoUserSettings.context_length = EvoUserSettings.DEFAULT_CONTEXT_LENGTH - main.hud._apply_decision_models_update() - - # Advance generations via main.step_generation() - var gen_before := main.world_state.generation - main.step_generation() - main.step_generation() - _assert(main.world_state.generation == gen_before + 2, "main.step_generation() advances world_state generation") - _assert(main.world_renderer.world_state == main.world_state, "world_renderer tracks updated world_state") - - # Preset switching - main.load_preset("preset-darwin-archipelago") - _assert(main.world_state.preset_id == "preset-darwin-archipelago", "Switched to preset-darwin-archipelago") - _assert(main.world_state.cells.size() == 91, "Darwin preset has 91 cells in main") - main.step_generation() - - main.load_preset("preset-finite-island") - _assert(main.world_state.preset_id == "preset-finite-island", "Switched to preset-finite-island") - _assert(main.world_state.cells.size() == 61, "Finite Island preset has 61 cells in main") - main.step_generation() - - # Language switching - main._on_language_changed("en") - _assert(main.lang == "en", "Language switched to en") - main._on_language_changed("es") - _assert(main.lang == "es", "Language switched back to es") - - # Map lenses switching - for lens_id in ["food", "health", "biome"]: - main.hud._on_lens_pressed(lens_id) - _assert(main.world_renderer.active_lens == lens_id, "Lens switched to %s" % lens_id) - - # Modals opening and closing - main.hud.open_notebook_modal() - _assert(main.hud.notebook_modal.visible, "Notebook modal opens cleanly") - main.hud.notebook_modal.close_modal() - _assert(not main.hud.notebook_modal.visible, "Notebook modal closes cleanly") - - main.hud.open_tree_modal() - _assert(main.hud.tree_modal.visible, "Tree modal opens cleanly") - main.hud.tree_modal.close_modal() - _assert(not main.hud.tree_modal.visible, "Tree modal closes cleanly") - - main.hud.open_lab_modal() - _assert(main.hud.lab_modal.visible, "Lab modal opens cleanly") - main.hud.lab_modal.close_modal() - _assert(not main.hud.lab_modal.visible, "Lab modal closes cleanly") - - # Environmental tools via HUD and MainController - main.world_state.selective_pressure_points = 100 - main.hud._on_tool_button_pressed("set_biome") - main.hud._on_sub_mode_selected("tundra") - main._on_world_hex_clicked("0,0", MOUSE_BUTTON_LEFT) - _assert((main.world_state.cells["0,0"] as EvoTypes.HexCell).biome == "tundra", "Environmental tool set_biome applied via main controller") - - main.hud._on_tool_button_pressed("adjust_altitude") - main.hud._on_sub_mode_selected("raise") - var alt_before := (main.world_state.cells["0,0"] as EvoTypes.HexCell).altitude - main._on_world_hex_clicked("0,0", MOUSE_BUTTON_LEFT) - _assert((main.world_state.cells["0,0"] as EvoTypes.HexCell).altitude > alt_before, "Environmental tool adjust_altitude applied via main controller") - - main.hud._on_tool_button_pressed("adjust_carrying_capacity") - main.hud._on_sub_mode_selected("enrich") - main._on_world_hex_clicked("0,0", MOUSE_BUTTON_LEFT) - - var sea_before := main.world_state.sea_level - main._on_sea_level_change_requested(0.05) - _assert(is_equal_approx(main.world_state.sea_level, sea_before + 0.05), "Sea level tool applied via main controller") - - # Verify Expanded Hex Size (>= 220.0), >= 2x Village/Villager Scales & Lifecycle Anti-Overlap - _assert(HexMath.HEX_SIZE >= 220.0, "HexMath.HEX_SIZE expanded to >= 220.0 (is %.1f)" % HexMath.HEX_SIZE) - var b_layout := main.world_renderer._get_village_building_layout(2, 1.0, true, 2, "cooperate") - _assert(b_layout.size() >= 3 and float(b_layout[0]["scale"]) >= 2.0, "Village building scale is >= 2.0x baseline") - main.load_preset("preset-rift-savanna") - main.world_renderer._rebuild() - var moving_props := main.world_renderer._collect_moving_props() - _assert(not moving_props.is_empty() and moving_props[0].rect.size.y >= 28.0, "Inhabitant/campfire moving props are >= 2x baseline size") - - # Verify disused_connections lifecycle aging and HUD inspector zero-information-loss - var life_state := EvoCatalog.create_preset_world("preset-finite-island", "es") - var ld1: EvoTypes.Deme = life_state.demes["deme-island-1"] - var ld2: EvoTypes.Deme = life_state.demes["deme-island-2"] - (life_state.cells[ld1.hex_id] as EvoTypes.HexCell).deme_ids.erase(ld1.id) - (life_state.cells[ld2.hex_id] as EvoTypes.HexCell).deme_ids.erase(ld2.id) - ld1.hex_id = "0,0" - ld2.hex_id = "0,0" - (life_state.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([ld1.id, ld2.id]) - var coop_fl := EvoTypes.HexEdgeFlow.new() - coop_fl.from_hex_id = "0,0" - coop_fl.to_hex_id = "0,0" - coop_fl.flow_type = "cooperate" - coop_fl.from_deme_id = "deme-island-1" - coop_fl.to_deme_id = "deme-island-2" - life_state.active_flows.append(coop_fl) - EvoEnvPhase.run_environmental_phase(life_state, "es") - _assert(life_state.disused_connections.size() == 1 and int(life_state.disused_connections[0].get("age", -1)) == 0, "Active friendly flow recorded into disused_connections with age 0") - # Force dispute so the friendly flow is severed - ld1.last_decision_probabilities = {"dec-territorial-dispute": 0.90, "dec-conservative-forage": 0.10} - ld2.last_decision_probabilities = {"dec-territorial-dispute": 0.90, "dec-conservative-forage": 0.10} - EvoBiophysPhase.run_biophysical_phase(life_state, "es") - _assert(life_state.disused_connections.size() == 1 and int(life_state.disused_connections[0].get("age", 0)) == 1, "Severed friendly flow ages to 1 in biophys_phase") - main.hud.set_world_state(life_state) - var rel_lines := main.hud._build_deme_relations_lines(ld1) - _assert(rel_lines.size() >= 2, "HUD inspector preserves both active/severed paths and cohabiting village relations without information loss") - - # Verify Right Inspector Village Selector & Cycling - _assert(main.hud._village_selector_opt != null and main.hud._village_selector_opt.item_count == life_state.demes.size(), "Right Inspector village selector populated with all demes") - var prev_sel_deme := main.hud._selected_deme_id - main.hud._on_village_cycle_pressed(1) - _assert(main.hud._selected_deme_id != prev_sel_deme, "Right Inspector village cycle button advances selected village") - - # Verify clicking an empty hex in inspect mode clears selected deme and switches inspector tab to habitat - main.load_preset("preset-rift-savanna") - main.hud._on_tool_button_pressed("inspect") - var empty_hex_id := "" - for hid in main.world_state.cells.keys(): - if (main.world_state.cells[hid] as EvoTypes.HexCell).deme_ids.is_empty(): - empty_hex_id = str(hid) - break - main._on_world_hex_clicked(empty_hex_id, MOUSE_BUTTON_LEFT) - _assert(main.world_renderer.selected_deme_id == "" and main.hud._selected_deme_id == "" and main.hud._inspector_tab == "habitat", "Clicking empty hex in inspect mode clears selected deme and switches inspector tab to habitat") - - main.free() - - -func _test_nonblocking_async_decision_and_smooth_panning() -> void: - print("-- Testing Non-Blocking Async Decision Model & Smooth Late-Game Panning --") - var t0_usec := Time.get_ticks_usec() - var status := EvoDecisionModel.check_ollama_status(true) - var status_ms := float(Time.get_ticks_usec() - t0_usec) / 1000.0 - _assert(status.has("primary_tag") and status.has("secondary_tag"), "check_ollama_status(true) returns valid status dictionary immediately") - _assert(status_ms < 15.0, "check_ollama_status(true) is non-blocking on main thread (took %.3f ms)" % status_ms) - - # Build a late-game world with 16 diverse demes across different hexes and live models enabled - var packed: PackedScene = load("res://scenes/main.tscn") - var main: EvoMainController = packed.instantiate() as EvoMainController - main.quit_on_close_enabled = false - root.add_child(main) - if main.camera == null: - main._ready() - - main.load_preset("preset-rift-savanna") - main.hud._set_startup_blocked(false) - var state := main.world_state - - var land_hexes: Array[String] = [] - for hid in state.cells.keys(): - var c: EvoTypes.HexCell = state.cells[hid] - if c.biome != "ocean" and c.deme_ids.is_empty(): - land_hexes.append(hid) - - for i in range(14): - var target_hid := land_hexes[i % land_hexes.size()] - var d := EvoTypes.Deme.new() - d.id = "deme-perf-test-%d" % i - d.name = "Clan Perf %d" % i - d.hex_id = target_hid - d.population = 80 + (i * 15) - d.energy_reserves = 60.0 + float(i * 2) - d.lineage_id = "lineage-west" if (i % 2 == 0) else "lineage-east" - d.color = "#f59e0b" if (i % 2 == 0) else "#10b981" - d.biological_trait_ids.assign(["bio-thermal-tolerance", "bio-social-instinct"]) - if i % 3 == 0: - d.cultural_trait_ids.assign(["cul-communal-conservation"]) - d.decision_model_tag = EvoDecisionModel.resolve_deme_decision_model_tag(d.id) - state.demes[d.id] = d - (state.cells[target_hid] as EvoTypes.HexCell).deme_ids.append(d.id) - - _assert(state.demes.size() >= 16, "Late-game stress world populated with %d demes" % state.demes.size()) - - var eval_start_usec := Time.get_ticks_usec() - EvoDecisionModel.evaluate_all_demes(state, "es") - var eval_ms := float(Time.get_ticks_usec() - eval_start_usec) / 1000.0 - _assert(eval_ms < 15.0, "evaluate_all_demes with 16 demes & live models enabled is non-blocking (took %.3f ms)" % eval_ms) - - # Simulate 6 generations of continuous camera panning + turn progression + rendering rebuilds - main.world_renderer._process(0.016) - _assert(not main.world_renderer._column_state_cache.is_empty(), "WorldRenderer column visual state cache populated") - _assert(not main.world_renderer._cell_flora_cache.is_empty(), "WorldRenderer static flora prop cache populated") - - var max_step_and_frame_ms: float = 0.0 - for _step_i in range(6): - main.camera.pan_offset += Vector2(28.0, -18.0) - main.camera._target_pan = main.camera.pan_offset - var frame_t0 := Time.get_ticks_usec() - main.step_generation() - main.camera._process(0.016) - main.world_renderer._process(0.016) - main._process(0.016) - var frame_ms := float(Time.get_ticks_usec() - frame_t0) / 1000.0 - max_step_and_frame_ms = maxf(max_step_and_frame_ms, frame_ms) - - print("[PERF] check_ollama_status=%.3f ms | evaluate_all_demes(16)=%.3f ms | max_turn_step_while_panning=%.3f ms" % [ - status_ms, eval_ms, max_step_and_frame_ms - ]) - _assert(max_step_and_frame_ms < 25.0, "Turn step + renderer rebuild while panning with 16+ demes completes without stutter (max %.3f ms)" % max_step_and_frame_ms) - - # Verify zoom pivot invariance around cursor offset and outer-ring pan reachability at MAX_ZOOM - main.camera.reset_camera() - main.camera.pan_offset = main.camera._target_pan - main.camera.zoom_level = main.camera._target_zoom - var cursor_offset := Vector2(320.0, -180.0) - var world_pt_before := (cursor_offset - main.camera._target_pan) / main.camera._target_zoom - main.camera.zoom_by(0.45, cursor_offset) - var world_pt_after := (cursor_offset - main.camera._target_pan) / main.camera._target_zoom - _assert(world_pt_before.distance_to(world_pt_after) < 0.01, "Zoom around cursor pivot preserves exact world point under cursor") - - main.camera._target_zoom = EvoCameraController.MAX_ZOOM - main.camera.focus_on_hex(5, 0, 0.5) - _assert(main.camera._target_pan.length() > 5000.0, "Outer ring hexes are reachable at MAX_ZOOM without premature clamping") - - # Verify bounded deme.id length across migrations - var max_id_len: int = 0 - for did in state.demes.keys(): - max_id_len = maxi(max_id_len, str(did).length()) - _assert(max_id_len < 64, "Deme IDs remain compact and bounded across generations (max len %d)" % max_id_len) - - main.free() - - -func _test_emergent_geopolitics_and_visual_artifacts() -> void: - print("-- Testing Emergent Biophysical Geopolitics, Pacts, Raids, Betrayals, War & Visual Artifacts --") - # 1. Strategy baseline probability differentiation & environmental weight modulation - var state_base := EvoPresets.create_preset_world("preset-rift-savanna", "en") - var sample_deme: EvoTypes.Deme = state_base.demes["deme-rift-west"] - - EvoCatalog.apply_strategy_to_deme(state_base, sample_deme, "strat-war", "en") - _assert(sample_deme.last_dominant_decision_id == "dec-territorial-dispute", "strat-war baseline dominant decision is dec-territorial-dispute") - var war_base_disp := float(sample_deme.last_decision_probabilities.get("dec-territorial-dispute", 0.0)) - - EvoCatalog.apply_strategy_to_deme(state_base, sample_deme, "strat-cooperation", "en") - _assert(sample_deme.last_dominant_decision_id == "dec-cooperate-store", "strat-cooperation baseline dominant decision is dec-cooperate-store") - - EvoCatalog.apply_strategy_to_deme(state_base, sample_deme, "strat-trade", "en") - _assert(sample_deme.last_dominant_decision_id == "dec-exchange-symbiosis", "strat-trade baseline dominant decision is dec-exchange-symbiosis") - - EvoCatalog.apply_strategy_to_deme(state_base, sample_deme, "strat-expansion", "en") - _assert(sample_deme.last_dominant_decision_id == "dec-selective-migration", "strat-expansion baseline dominant decision is dec-selective-migration") - - EvoCatalog.apply_strategy_to_deme(state_base, sample_deme, "strat-innovation", "en") - _assert(sample_deme.last_dominant_decision_id == "dec-explore-innovate", "strat-innovation baseline dominant decision is dec-explore-innovate") - _assert(war_base_disp > float(sample_deme.last_decision_probabilities.get("dec-territorial-dispute", 0.0)), "strat-war has higher baseline dispute weight than strat-innovation") - - # 2. Non-forcing of conflict in ecological abundance vs scarcity modulation - var state_ab := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") - state_ab.season = "wet_warm" - var ab_w: EvoTypes.Deme = state_ab.demes["deme-rift-west"] - var ab_e: EvoTypes.Deme = state_ab.demes["deme-rift-east"] - (state_ab.cells[ab_w.hex_id] as EvoTypes.HexCell).deme_ids.erase(ab_w.id) - (state_ab.cells[ab_e.hex_id] as EvoTypes.HexCell).deme_ids.erase(ab_e.id) - ab_w.hex_id = "0,0" - ab_e.hex_id = "1,0" - (state_ab.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(ab_w.id) - (state_ab.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(ab_e.id) - for cid in ["0,0", "1,0"]: - var c_ab: EvoTypes.HexCell = state_ab.cells[cid] - c_ab.carrying_capacity = c_ab.carrying_capacity_max - c_ab.current_resources = c_ab.carrying_capacity_max - ab_w.population = 35 - ab_e.population = 35 - ab_w.energy_reserves = 90.0 - ab_e.energy_reserves = 90.0 - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(ab_w, state_ab.cells["0,0"], state_ab) - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(ab_e, state_ab.cells["1,0"], state_ab) - var ab_disp_w := float(ab_w.last_decision_probabilities.get("dec-territorial-dispute", 0.0)) - _assert(ab_w.last_dominant_decision_id != "dec-territorial-dispute", "In ecological abundance, strat-war does not default to territorial dispute") - _assert(ab_e.last_dominant_decision_id in ["dec-conservative-forage", "dec-cooperate-store"], "In ecological abundance, strat-cooperation favors peaceful foraging/storage") - EvoBiophysPhase.run_biophysical_phase(state_ab, "en") - var ab_conflicts := 0 - for fl in state_ab.active_flows: - if fl.flow_type in ["war", "raid", "betrayal"]: - ab_conflicts += 1 - _assert(ab_conflicts == 0, "Zero wars, raids, or betrayals occur during ecological abundance") - - # Induce scarcity and verify dispute weight rises for martial deme - state_ab.season = "dry_cold" - (state_ab.cells["0,0"] as EvoTypes.HexCell).current_resources = 20.0 - ab_w.energy_reserves = 20.0 - ab_w.population = 75 - EvoCatalog.compute_strategy_baseline_probabilities(ab_w, state_ab.traits_catalog) - EvoHeuristicScorer.modulate_deme_probabilities_for_turn(ab_w, state_ab.cells["0,0"], state_ab) - var sc_disp_w := float(ab_w.last_decision_probabilities.get("dec-territorial-dispute", 0.0)) - _assert(sc_disp_w > ab_disp_w, "Environmental scarcity + surplus neighbor increases martial dispute probability (%.3f > %.3f)" % [sc_disp_w, ab_disp_w]) - - # 3. Active Pact (pact) formation, caloric risk-pooling transfer & collective defense bonus - var state_pact := EvoPresets.create_preset_world("preset-finite-island", "en", "strat-cooperation", "strat-trade") - var p_d1: EvoTypes.Deme = state_pact.demes["deme-island-1"] - var p_d2: EvoTypes.Deme = state_pact.demes["deme-island-2"] - (state_pact.cells[p_d2.hex_id] as EvoTypes.HexCell).deme_ids.erase(p_d2.id) - p_d2.hex_id = "0,-1" - (state_pact.cells["0,-1"] as EvoTypes.HexCell).deme_ids.append(p_d2.id) - for turn_i in range(2): - p_d1.last_decision_probabilities = {"dec-cooperate-store": 0.70, "dec-conservative-forage": 0.30} - p_d2.last_decision_probabilities = {"dec-exchange-symbiosis": 0.70, "dec-conservative-forage": 0.30} - EvoBiophysPhase.run_biophysical_phase(state_pact, "en") - _assert(EvoTypes.is_pact_active(state_pact, p_d1.id, p_d2.id), "Active pact forms after 2 consecutive cooperative/exchange turns") - var has_pact_chronicle := false - for e in state_pact.chronicle: - if e.event_type == "pact": - has_pact_chronicle = true - break - _assert(has_pact_chronicle, "Chronicle logs formal pact event on turn 2") - var has_pact_flow := false - for fl in state_pact.active_flows: - if fl.flow_type == "pact": - has_pact_flow = true - break - _assert(has_pact_flow, "Active pact emits 'pact' HexEdgeFlow") - var def_bonus := EvoEcologyInteraction.compute_pact_defense_bonus(p_d1, state_pact) - _assert(def_bonus > 0.15, "Active pact provides mutual defense bonus (%.2f)" % def_bonus) - - # Caloric risk-pooling during scarcity - state_pact.season = "dry_cold" - p_d1.energy_reserves = 20.0 - p_d2.energy_reserves = 130.0 - (state_pact.cells[p_d1.hex_id] as EvoTypes.HexCell).current_resources = 30.0 - (state_pact.cells[p_d2.hex_id] as EvoTypes.HexCell).current_resources = 500.0 - p_d1.last_decision_probabilities = {"dec-cooperate-store": 0.75, "dec-conservative-forage": 0.25} - p_d2.last_decision_probabilities = {"dec-exchange-symbiosis": 0.75, "dec-conservative-forage": 0.25} - var pact_vecs := { - p_d1.id: EvoEcologyInteraction.compute_effective_vector(p_d1, state_pact), - p_d2.id: EvoEcologyInteraction.compute_effective_vector(p_d2, state_pact) - } - var pact_hawks := {p_d1.id: 1.0, p_d2.id: 1.0} - var pact_ext := {p_d1.id: 0.0, p_d2.id: 0.0} - var pact_geo_flows: Array[EvoTypes.HexEdgeFlow] = [] - EvoEcologyInteraction.run_resource_and_symbiosis_phase(state_pact, "en", pact_vecs, pact_hawks, pact_ext, pact_geo_flows) - _assert(p_d1.energy_reserves > 20.0 and p_d2.energy_reserves < 130.0, "Pact risk-pooling transfers reserves from wealthy ally to distressed ally (%.1f, %.1f)" % [p_d1.energy_reserves, p_d2.energy_reserves]) - _assert(not pact_geo_flows.is_empty(), "Ecology phase populates turn_geopolitical_flows") - - # 4. Opportunistic Betrayal (betrayal) under severe environmental stress - var state_bet := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") - var b_d1: EvoTypes.Deme = state_bet.demes["deme-rift-west"] - var b_d2: EvoTypes.Deme = state_bet.demes["deme-rift-east"] - (state_bet.cells[b_d1.hex_id] as EvoTypes.HexCell).deme_ids.erase(b_d1.id) - (state_bet.cells[b_d2.hex_id] as EvoTypes.HexCell).deme_ids.erase(b_d2.id) - b_d1.hex_id = "0,0" - b_d2.hex_id = "1,0" - (state_bet.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(b_d1.id) - (state_bet.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(b_d2.id) - var bet_pk := EvoTypes.make_pair_key(b_d1.id, b_d2.id) - state_bet.active_pacts[bet_pk] = 3 - state_bet.season = "dry_cold" - (state_bet.cells["0,0"] as EvoTypes.HexCell).current_resources = 12.0 - (state_bet.cells["0,0"] as EvoTypes.HexCell).carrying_capacity = 75.0 - b_d1.energy_reserves = 16.0 - b_d2.energy_reserves = 135.0 - var b2_pop_before := b_d2.population - b_d1.cultural_trait_ids.assign(["cul-warrior-expansion", "cul-extraction-tools"]) - b_d1.last_decision_probabilities = { - "dec-territorial-dispute": 0.50, - "dec-intensive-overexploit": 0.35, - "dec-conservative-forage": 0.15 - } - b_d2.last_decision_probabilities = {"dec-cooperate-store": 0.70, "dec-conservative-forage": 0.30} - var bet_vecs := { - b_d1.id: EvoEcologyInteraction.compute_effective_vector(b_d1, state_bet), - b_d2.id: EvoEcologyInteraction.compute_effective_vector(b_d2, state_bet) - } - var bet_hawks := {b_d1.id: 1.0, b_d2.id: 1.0} - var bet_ext := {b_d1.id: 0.0, b_d2.id: 0.0} - var bet_geo_flows: Array[EvoTypes.HexEdgeFlow] = [] - EvoEcologyInteraction.run_resource_and_symbiosis_phase(state_bet, "en", bet_vecs, bet_hawks, bet_ext, bet_geo_flows) - _assert(not EvoTypes.is_pact_active(state_bet, b_d1.id, b_d2.id), "Betrayal erases active pact from state.active_pacts") - _assert(b_d1.energy_reserves > 16.0 and b_d2.energy_reserves < 135.0, "Betrayal plunders surprise loot from victim reserves to betrayer") - _assert(b_d2.population < b2_pop_before, "Betrayal inflicts surprise casualties on victim") - _assert(not state_bet.disused_connections.is_empty() and int(state_bet.disused_connections[0].get("age", 0)) >= 1, "Betrayal records severed road in state.disused_connections") - var has_betrayal_flow := false - for fl in state_bet.active_flows: - if fl.flow_type == "betrayal": - has_betrayal_flow = true - break - _assert(has_betrayal_flow, "Betrayal emits 'betrayal' HexEdgeFlow") - var has_betrayal_chronicle := false - for e in state_bet.chronicle: - if e.event_type == "betrayal": - has_betrayal_chronicle = true - break - _assert(has_betrayal_chronicle, "Betrayal logs 'betrayal' ChronicleEntry") - - # 5. Cross-border Raid (raid) under resource scarcity against a wealthy neighbor - var state_raid := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") - var r_att: EvoTypes.Deme = state_raid.demes["deme-rift-west"] - var r_vic: EvoTypes.Deme = state_raid.demes["deme-rift-east"] - (state_raid.cells[r_att.hex_id] as EvoTypes.HexCell).deme_ids.erase(r_att.id) - (state_raid.cells[r_vic.hex_id] as EvoTypes.HexCell).deme_ids.erase(r_vic.id) - r_att.hex_id = "0,0" - r_vic.hex_id = "1,0" - (state_raid.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(r_att.id) - (state_raid.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(r_vic.id) - state_raid.season = "dry_cold" - (state_raid.cells["0,0"] as EvoTypes.HexCell).current_resources = 20.0 - (state_raid.cells["1,0"] as EvoTypes.HexCell).current_resources = 260.0 - r_att.population = 75 - r_att.energy_reserves = 24.0 - r_vic.population = 45 - r_vic.energy_reserves = 115.0 - r_att.last_decision_probabilities = {} - r_vic.last_decision_probabilities = {} - EvoBiophysPhase.run_biophysical_phase(state_raid, "en") - var has_raid_flow := false - for fl in state_raid.active_flows: - if fl.flow_type == "raid": - has_raid_flow = true - break - _assert(has_raid_flow, "Cross-border raid emits 'raid' HexEdgeFlow") - _assert(r_att.energy_reserves > 24.0 and r_vic.energy_reserves < 115.0, "Raid steals reserves from wealthy neighbor (raider %.1f, victim %.1f)" % [r_att.energy_reserves, r_vic.energy_reserves]) - var has_raid_chronicle := false - for e in state_raid.chronicle: - if e.event_type == "raid": - has_raid_chronicle = true - break - _assert(has_raid_chronicle, "Raid logs 'raid' ChronicleEntry") - - # 6. Conquering Horde (strat-war) real territorial Invasion (war) across hexes in multi-generation simulation - var state_war := EvoPresets.create_preset_world("preset-finite-island", "en", "strat-war", "strat-cooperation") - var war_events_count := 0 - var war_flows_seen := 0 - var warband_created := false - for g in range(18): - EvoTurnEngine.advance_generation(state_war, "en") - for fl in state_war.active_flows: - if fl.flow_type == "war": - war_flows_seen += 1 - for did in state_war.demes.keys(): - if str(did).contains("-w"): - warband_created = true - for e in state_war.chronicle: - if e.event_type == "war": - war_events_count += 1 - _assert(war_events_count >= 1 and war_flows_seen >= 1, "strat-war marches and triggers real territorial invasions ('war' events=%d, flows=%d) in multi-turn simulation" % [war_events_count, war_flows_seen]) - _assert(warband_created, "Territorial invasion spawns conquering warband demes ('-w') in target hexes") - - # 7. All 8 new procedural pixel-art sprites & UI/renderer integration - EvoPixelArtGen.ensure_all_assets(true) - var new_sprite_keys: Array[String] = [ - "prop_war_banner", - "prop_raid_loot", - "prop_pact_totem", - "prop_betrayal_ruin", - "icon_war", - "icon_raid", - "icon_pact", - "icon_betrayal" - ] - for skey in new_sprite_keys: - var subdir := "villages" if skey.begins_with("prop_") else "ui" - var file_path := "res://assets/sprites/%s/%s.png" % [subdir, skey] - _assert(FileAccess.file_exists(file_path), "Generated sprite PNG exists on disk: %s" % file_path) - var tex := EvoPixelArtGen.get_texture(skey) - _assert(tex != null and tex.get_width() >= 16 and tex.get_height() >= 16, "Loaded sprite Texture2D is valid and non-empty: %s" % skey) - - for icon_key in ["war", "raid", "pact", "betrayal"]: - var ui_tex := EvoUiStyle.get_pixel_icon(icon_key) - _assert(ui_tex != null, "EvoUiStyle.get_pixel_icon('%s') returns valid Texture2D" % icon_key) - - var packed: PackedScene = load("res://scenes/main.tscn") - var main: EvoMainController = packed.instantiate() as EvoMainController - main.quit_on_close_enabled = false - root.add_child(main) - if main.camera == null: - main._ready() - - main.world_state = state_pact - main.world_renderer.set_world_state(state_pact) - main.hud.set_world_state(state_pact) - main.world_renderer._rebuild() - main.world_renderer._process(0.016) - - var pact_summary := EvoHudInspectorPanel.format_active_pacts_summary(state_pact, "en", p_d1) - _assert(pact_summary.contains("🤝"), "EvoHudInspectorPanel.format_active_pacts_summary displays active pact partner") - var pact_rel_lines := EvoHudInspectorPanel.build_deme_relations_lines(state_pact, "en", p_d1) - _assert(not pact_rel_lines.is_empty(), "EvoHudInspectorPanel.build_deme_relations_lines includes pact & relation lines") - _assert(not ("\n".join(pact_rel_lines)).contains("{partner}"), "EvoHudInspectorPanel.build_deme_relations_lines substitutes {partner} placeholder in English") - var pact_rel_lines_es := EvoHudInspectorPanel.build_deme_relations_lines(state_pact, "es", p_d1) - _assert(not ("\n".join(pact_rel_lines_es)).contains("{partner}") and ("\n".join(pact_rel_lines_es)).contains("Pacto activo con "), "EvoHudInspectorPanel.build_deme_relations_lines substitutes {partner} placeholder in Spanish") - - main.hud.open_notebook_modal() - for f_key in ["war", "raid", "pact", "betrayal"]: - _assert(main.hud.notebook_modal._filter_buttons.has(f_key), "EvoNotebookModal has filter button for '%s'" % f_key) - main.hud.notebook_modal._on_filter_pressed(f_key) - _assert(main.hud.notebook_modal._active_filter == f_key, "EvoNotebookModal switches active filter to '%s'" % f_key) - main.hud.notebook_modal.close_modal() - - main.free() - - -func _test_bugfix_regressions() -> void: - print("-- Testing Bugfix Regressions --") - # 1. _strip_migration_suffix preserves root deme IDs and strips migration/warband suffixes - _assert(EvoDemographyMigration._strip_migration_suffix("deme-rift-west") == "deme-rift-west", "_strip_migration_suffix preserves 'deme-rift-west'") - _assert(EvoDemographyMigration._strip_migration_suffix("deme-rift-west-m3-0_0") == "deme-rift-west", "_strip_migration_suffix strips '-m3-0_0' suffix") - _assert(EvoDemographyMigration._strip_migration_suffix("deme-rift-west-w2") == "deme-rift-west", "_strip_migration_suffix strips '-w2' suffix") - - # 2. resolve_deme_decision_model_tag assigns deme-darwin-rival to secondary model - _assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-darwin-rival", "d3:4b", "tev1:4b") == "tev1:4b", "resolve_deme_decision_model_tag assigns 'deme-darwin-rival' to secondary model 'tev1:4b'") - - # 3. is_hex_passable_for_deme rejects altitude > 0.85 (0.88 mountain barrier) - var mountain_cell := EvoTypes.HexCell.new() - mountain_cell.id = "0,1" - mountain_cell.biome = "mountain" - mountain_cell.altitude = 0.88 - mountain_cell.carrying_capacity = 100.0 - _assert(not EvoDemographyMigration.is_hex_passable_for_deme(mountain_cell, 1.0), "is_hex_passable_for_deme rejects mountain barrier with altitude 0.88 (> 0.85)") - - # 4. validate_and_balance_thermodynamics preserves positive ecological_impact minus excess when target_metabolic > 1.0 - var high_vec := EvoTypes.BiophysicalVector.new(0.0, 1.0, 0.30, 1.0, 1.0, 0.0) - # A+ = 3.0 -> required_cost = 0.5 * (3.0 - 0.60) = 1.20 -> target_metabolic = 1.20 -> excess = 0.20 -> ecological_impact = 0.30 - 0.20 = 0.10 - var balanced_high := EvoThermodynamics.validate_and_balance_thermodynamics(high_vec, false) - _assert(is_equal_approx(balanced_high.metabolic_cost, 1.0), "validate_and_balance_thermodynamics caps metabolic_cost at 1.0 when target_metabolic > 1.0") - _assert(is_equal_approx(balanced_high.ecological_impact, 0.10), "validate_and_balance_thermodynamics preserves positive ecological_impact minus excess (0.30 - 0.20 = 0.10)") - - # 5. EvoLabModal.refresh() preserves checked strategy traits in strategy_trait_checks - var state := EvoCatalog.create_preset_world("preset-rift-savanna", "es") - var lab := EvoLabModal.new() - root.add_child(lab) - if lab._title_label == null: - lab._ready() - lab.open_modal(state, "es") - lab._switch_tab("strategies") - lab._start_new_item() - (lab.strategy_trait_checks["bio-thermal-tolerance"] as CheckBox).button_pressed = true - (lab.strategy_trait_checks["bio-social-instinct"] as CheckBox).button_pressed = true - lab.refresh(state) - _assert((lab.strategy_trait_checks["bio-thermal-tolerance"] as CheckBox).button_pressed and (lab.strategy_trait_checks["bio-social-instinct"] as CheckBox).button_pressed, "EvoLabModal.refresh() preserves checked strategy traits in strategy_trait_checks") - lab.free() - - -func _test_social_memory_defense_tribute_and_synoecism() -> void: - print("-- Testing Social Memory, Multi-Tier Defense, Vendettas, Tribute, Synoecism & New Sprites --") - # 1. LLM Historical Memory & Rich Neighbor Context - var state_mem := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-war") - state_mem.generation = 5 - var d_focus: EvoTypes.Deme = state_mem.demes["deme-rift-west"] - var d_rival: EvoTypes.Deme = state_mem.demes["deme-rift-east"] - (state_mem.cells[d_focus.hex_id] as EvoTypes.HexCell).deme_ids.erase(d_focus.id) - (state_mem.cells[d_rival.hex_id] as EvoTypes.HexCell).deme_ids.erase(d_rival.id) - d_focus.hex_id = "0,0" - d_rival.hex_id = "1,0" - (state_mem.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(d_focus.id) - (state_mem.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(d_rival.id) - d_focus.population = 80 - d_focus.defense_tier = 2 - d_focus.merged_count = 1 - d_rival.population = 120 - d_rival.defense_tier = 0 - d_rival.last_dominant_decision_id = "dec-territorial-dispute" - - var d_kin := d_focus.duplicate_deme() - d_kin.id = "deme-kin-test" - d_kin.name = "Clan Kin" - d_kin.hex_id = "0,-1" - d_kin.population = 45 - d_kin.defense_tier = 1 - d_kin.last_dominant_decision_id = "dec-conservative-forage" - state_mem.demes[d_kin.id] = d_kin - (state_mem.cells["0,-1"] as EvoTypes.HexCell).deme_ids.append(d_kin.id) - - var d_ally := d_focus.duplicate_deme() - d_ally.id = "deme-ally-test" - d_ally.name = "Clan Ally" - d_ally.lineage_id = "lineage-ally-distinct" - d_ally.hex_id = "-1,0" - d_ally.population = 85 - d_ally.defense_tier = 2 - d_ally.last_dominant_decision_id = "dec-cooperate-store" - state_mem.demes[d_ally.id] = d_ally - (state_mem.cells["-1,0"] as EvoTypes.HexCell).deme_ids.append(d_ally.id) - state_mem.active_pacts[EvoTypes.make_pair_key(d_focus.id, d_ally.id)] = 3 - - _assert( - EvoHeuristicScorer._format_neighbor_occupant_label(d_focus, d_rival, state_mem) == "rival:martial:stronger:def0", - "Neighbor occupant label formats rival martial stronger deme with defense tier" - ) - _assert( - EvoHeuristicScorer._format_neighbor_occupant_label(d_focus, d_kin, state_mem) == "kin:forage:weaker:def1", - "Neighbor occupant label formats kin forage weaker deme with defense tier" - ) - _assert( - EvoHeuristicScorer._format_neighbor_occupant_label(d_focus, d_ally, state_mem) == "ally:cooperate:equal:def2", - "Neighbor occupant label formats allied cooperate equal deme with defense tier" - ) - - state_mem.active_pacts[EvoTypes.make_pair_key(d_focus.id, d_rival.id)] = -3 - var mem_raid_entry := EvoTypes.ChronicleEntry.new() - mem_raid_entry.id = "chronicle-raid-4-%s-%s" % [d_rival.id, d_focus.id] - mem_raid_entry.generation = 4 - mem_raid_entry.event_type = "raid" - mem_raid_entry.hex_id = d_focus.hex_id - mem_raid_entry.deme_id = d_rival.id - state_mem.chronicle.append(mem_raid_entry) - EvoHeuristicScorer.invalidate_state_caches(state_mem) - - _assert( - EvoHeuristicScorer._format_neighbor_occupant_label(d_focus, d_rival, state_mem) == "vendetta:martial:stronger:def0", - "Neighbor occupant label reflects active vendetta against rival" - ) - var geo_sum := EvoHeuristicScorer.build_geopolitics_summary(d_focus, state_mem.cells["0,0"], state_mem) - _assert(int(geo_sum.get("active_pacts", -1)) == 1, "build_geopolitics_summary reports active_pacts == 1") - _assert(int(geo_sum.get("active_vendettas", -1)) == 1, "build_geopolitics_summary reports active_vendettas == 1") - _assert(int(geo_sum.get("defense_tier", -1)) == 2, "build_geopolitics_summary reports defense_tier == 2") - _assert(bool(geo_sum.get("horde_threat", false)), "build_geopolitics_summary detects nearby horde_threat") - var mem_str := str(geo_sum.get("memory", "")) - _assert( - mem_str.contains("raided@G4") and mem_str.contains("vendettas=1") and mem_str.contains("horde_nearby"), - "build_geopolitics_summary memory digest includes raid history, vendettas and horde threat (%s)" % mem_str - ) - - var compact_state := EvoHeuristicScorer.build_compact_system_one_state(d_focus, state_mem) - _assert(compact_state.contains("def=Lv2, merged=1"), "build_compact_system_one_state includes def=Lv2 and merged=1") - _assert(compact_state.contains("memory=") and compact_state.contains("raided@G4"), "build_compact_system_one_state includes historical memory") - _assert(compact_state.contains(":def"), "build_compact_system_one_state Neighbors list includes relational labels and defense tier") - _assert(compact_state.contains("\"active_vendettas\":1") and compact_state.contains("\"defense_tier\":2"), "build_compact_system_one_state geopolitics JSON includes active_vendettas and defense_tier") - - # 2. Multi-Tier Defense (0 -> 1 -> 2 -> 3) & Castle Raid Immunity - var state_def := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-war") - var d_def: EvoTypes.Deme = state_def.demes["deme-rift-west"] - var d_att: EvoTypes.Deme = state_def.demes["deme-rift-east"] - (state_def.cells[d_def.hex_id] as EvoTypes.HexCell).deme_ids.erase(d_def.id) - (state_def.cells[d_att.hex_id] as EvoTypes.HexCell).deme_ids.erase(d_att.id) - d_def.hex_id = "0,0" - d_att.hex_id = "1,0" - (state_def.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(d_def.id) - (state_def.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(d_att.id) - var def_cell: EvoTypes.HexCell = state_def.cells["0,0"] - def_cell.current_resources = 320.0 - d_def.population = 120 - d_def.energy_reserves = 260.0 - var def_arch_probs := { - d_def.id: [0.55, 0.25, 0.05, 0.05], - d_att.id: [0.05, 0.05, 0.60, 0.20] - } - _assert(d_def.defense_tier == 0, "Deme starts at defense_tier 0") - - var res_before_t1 := d_def.energy_reserves - var cell_before_t1 := def_cell.current_resources - EvoEcologyInteraction._run_defense_fortification_phase(state_def, "en", def_arch_probs) - _assert(d_def.defense_tier == 1, "_run_defense_fortification_phase upgrades defense_tier 0 -> 1 (Palisade)") - _assert(d_def.energy_reserves < res_before_t1 and def_cell.current_resources < cell_before_t1, "Tier 1 fortification consumes deme reserves and hex resources") - - EvoEcologyInteraction._run_defense_fortification_phase(state_def, "en", def_arch_probs) - _assert(d_def.defense_tier == 2, "_run_defense_fortification_phase upgrades defense_tier 1 -> 2 (Stone Wall)") - - EvoEcologyInteraction._run_defense_fortification_phase(state_def, "en", def_arch_probs) - _assert(d_def.defense_tier == 3, "_run_defense_fortification_phase upgrades defense_tier 2 -> 3 (Castle)") - var fortify_chronicles := 0 - for e in state_def.chronicle: - if e.event_type == "fortify" and e.deme_id == d_def.id: - fortify_chronicles += 1 - _assert(fortify_chronicles >= 2, "Upgrading to Tier 2 and Tier 3 logs 'fortify' ChronicleEntries") - - state_def.season = "dry_cold" - d_att.population = 110 - d_att.energy_reserves = 20.0 - (state_def.cells["1,0"] as EvoTypes.HexCell).current_resources = 15.0 - d_att.last_decision_probabilities = {"dec-territorial-dispute": 0.60, "dec-intensive-overexploit": 0.25, "dec-conservative-forage": 0.15} - var vec_att := EvoEcologyInteraction.compute_effective_vector(d_att, state_def) - var raid_ext_map := {d_att.id: 10.0, d_def.id: 85.0} - _assert( - not EvoEcologyInteraction._can_attempt_raid(d_att, d_def, state_def.cells["1,0"], state_def, vec_att, raid_ext_map, [0.05, 0.05, 0.60, 0.25]), - "Tier 3 Castle is immune to _can_attempt_raid" - ) - d_def.defense_tier = 0 - _assert( - EvoEcologyInteraction._can_attempt_raid(d_att, d_def, state_def.cells["1,0"], state_def, vec_att, raid_ext_map, [0.05, 0.05, 0.60, 0.25]), - "Undefended deme (Tier 0) is vulnerable to _can_attempt_raid under same conditions" - ) - var forces_t0 := EvoDemographyMigration._compute_combat_forces(d_att, 55, d_def, state_def) - d_def.defense_tier = 3 - var forces_t3 := EvoDemographyMigration._compute_combat_forces(d_att, 55, d_def, state_def) - _assert(forces_t3.y > forces_t0.y * 1.50, "Tier 3 Castle significantly boosts defender force in _compute_combat_forces (%.1f vs %.1f)" % [forces_t3.y, forces_t0.y]) - - # 3. Vendettas & Tribute Appeasement - var state_trib := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-trade") - var t_raider: EvoTypes.Deme = state_trib.demes["deme-rift-west"] - var t_trader: EvoTypes.Deme = state_trib.demes["deme-rift-east"] - (state_trib.cells[t_raider.hex_id] as EvoTypes.HexCell).deme_ids.erase(t_raider.id) - (state_trib.cells[t_trader.hex_id] as EvoTypes.HexCell).deme_ids.erase(t_trader.id) - t_raider.hex_id = "0,0" - t_trader.hex_id = "1,0" - (state_trib.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(t_raider.id) - (state_trib.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(t_trader.id) - state_trib.season = "dry_cold" - (state_trib.cells["0,0"] as EvoTypes.HexCell).current_resources = 18.0 - (state_trib.cells["1,0"] as EvoTypes.HexCell).current_resources = 220.0 - t_raider.population = 85 - t_raider.energy_reserves = 22.0 - t_trader.population = 45 - t_trader.energy_reserves = 120.0 - t_trader.defense_tier = 0 - t_raider.last_decision_probabilities = {"dec-territorial-dispute": 0.55, "dec-intensive-overexploit": 0.30, "dec-conservative-forage": 0.15} - t_trader.last_decision_probabilities = {"dec-exchange-symbiosis": 0.55, "dec-cooperate-store": 0.30, "dec-conservative-forage": 0.15} - var trib_vecs := { - t_raider.id: EvoEcologyInteraction.compute_effective_vector(t_raider, state_trib), - t_trader.id: EvoEcologyInteraction.compute_effective_vector(t_trader, state_trib) - } - var trib_hawks := {t_raider.id: 1.0, t_trader.id: 1.0} - var trib_ext := {t_raider.id: 0.0, t_trader.id: 0.0} - var trib_flows: Array[EvoTypes.HexEdgeFlow] = [] - EvoEcologyInteraction.run_resource_and_symbiosis_phase(state_trib, "en", trib_vecs, trib_hawks, trib_ext, trib_flows) - _assert(t_raider.population == 85 and t_trader.population == 45, "Paying tribute avoids raid casualties on both sides") - _assert(t_raider.energy_reserves > 22.0, "Raider receives tribute energy reserves (%.1f)" % t_raider.energy_reserves) - var trib_pk := EvoTypes.make_pair_key(t_raider.id, t_trader.id) - _assert(int(state_trib.active_pacts.get(trib_pk, 0)) == 1, "Tribute establishes temporary truce (active_pacts == 1)") - var has_tribute_flow := false - for fl in state_trib.active_flows: - if fl.flow_type == "tribute": - has_tribute_flow = true - break - _assert(has_tribute_flow, "Tribute payment emits 'tribute' HexEdgeFlow") - var has_tribute_chronicle := false - for e in state_trib.chronicle: - if e.event_type == "tribute": - has_tribute_chronicle = true - break - _assert(has_tribute_chronicle, "Tribute payment logs 'tribute' ChronicleEntry") - - # Verify raid/betrayal sets negative active_pacts and EvoTypes.is_vendetta_active == true - state_trib.active_pacts[trib_pk] = 0 - t_trader.last_decision_probabilities = {"dec-conservative-forage": 0.85, "dec-territorial-dispute": 0.15} - t_trader.defense_tier = 0 - t_trader.energy_reserves = 110.0 - state_trib.chronicle.clear() - EvoEcologyInteraction._execute_raid_if_successful( - t_raider, - t_trader, - state_trib.cells["0,0"], - state_trib.cells["1,0"], - state_trib, - "en", - trib_vecs[t_raider.id], - trib_vecs[t_trader.id], - {t_raider.id: 15.0, t_trader.id: 60.0}, - trib_flows, - [0.0, 0.0, 0.55, 0.30], - [0.0, 0.0, 0.15, 0.0] - ) - _assert(int(state_trib.active_pacts.get(trib_pk, 0)) <= -2 and EvoTypes.is_vendetta_active(state_trib, t_raider.id, t_trader.id), "Raid sets negative active_pacts and activates vendetta") - EvoEcologyInteraction._execute_betrayal( - t_raider, - t_trader, - state_trib.cells["0,0"], - state_trib.cells["1,0"], - trib_pk, - state_trib, - "en", - {t_raider.id: 15.0, t_trader.id: 60.0}, - trib_flows - ) - _assert(int(state_trib.active_pacts.get(trib_pk, 0)) == -4 and EvoTypes.is_vendetta_active(state_trib, t_raider.id, t_trader.id), "Betrayal sets active_pacts to -4 (deep vendetta)") - - # 4. City Unification (Synoecism) & LLM Request Reduction (Same Hex & Adjacent Hexes) - # 4a. Same-hex synoecism with active_pacts >= 3 - var state_syn1 := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") - var s1_a: EvoTypes.Deme = state_syn1.demes["deme-rift-west"] - var s1_b: EvoTypes.Deme = state_syn1.demes["deme-rift-east"] - (state_syn1.cells[s1_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(s1_a.id) - (state_syn1.cells[s1_b.hex_id] as EvoTypes.HexCell).deme_ids.erase(s1_b.id) - s1_a.hex_id = "0,0" - s1_b.hex_id = "0,0" - (state_syn1.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([s1_a.id, s1_b.id]) - s1_a.population = 125 - s1_b.population = 115 - s1_a.energy_reserves = 120.0 - s1_b.energy_reserves = 110.0 - s1_a.last_decision_probabilities = {"dec-cooperate-store": 0.70, "dec-conservative-forage": 0.30} - s1_b.last_decision_probabilities = {"dec-exchange-symbiosis": 0.70, "dec-conservative-forage": 0.30} - var s1_b_color := s1_b.color - state_syn1.active_pacts[EvoTypes.make_pair_key(s1_a.id, s1_b.id)] = 3 - var s1_init_pops := {s1_a.id: s1_a.population, s1_b.id: s1_b.population} - var s1_vecs := { - s1_a.id: EvoEcologyInteraction.compute_effective_vector(s1_a, state_syn1), - s1_b.id: EvoEcologyInteraction.compute_effective_vector(s1_b, state_syn1) - } - var s1_ext := {s1_a.id: 80.0, s1_b.id: 75.0} - EvoDemographyMigration.run_demography_and_migration_phase(state_syn1, "en", s1_init_pops, s1_vecs, {}, s1_ext) - _assert(state_syn1.demes.size() == 1, "Same-hex synoecism (active_pacts >= 3) merges 2 allied demes into 1 unified city") - _assert(s1_a.merged_count >= 1, "Unified city increments merged_count (%d)" % s1_a.merged_count) - _assert(s1_a.population > 200, "Unified city combines populations (%d)" % s1_a.population) - _assert(s1_a.secondary_color == s1_b_color and s1_a.secondary_color != "", "Unified city records partner secondary_color (%s)" % s1_a.secondary_color) - _assert(s1_a.get_village_tier() == 5 and s1_a.get_village_roman() == "V", "Unified metropolis reaches Tier 5 ('V')") - _assert( - EvoUiStyle.get_village_tier_label(s1_a.population, "es", s1_a.merged_count) == "V · Metrópolis" and EvoUiStyle.get_village_tier_label(s1_a.population, "en", s1_a.merged_count) == "V · Metropolis", - "EvoUiStyle.get_village_tier_label returns Tier V Metropolis label in ES and EN" - ) - - # 4b. Adjacent-hex synoecism with active_pacts >= 4 - var state_syn2 := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-innovation") - var s2_a: EvoTypes.Deme = state_syn2.demes["deme-rift-west"] - var s2_b: EvoTypes.Deme = state_syn2.demes["deme-rift-east"] - (state_syn2.cells[s2_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(s2_a.id) - (state_syn2.cells[s2_b.hex_id] as EvoTypes.HexCell).deme_ids.erase(s2_b.id) - s2_a.hex_id = "0,0" - s2_b.hex_id = "1,0" - (state_syn2.cells["0,0"] as EvoTypes.HexCell).deme_ids.append(s2_a.id) - (state_syn2.cells["1,0"] as EvoTypes.HexCell).deme_ids.append(s2_b.id) - s2_a.population = 105 - s2_b.population = 85 - s2_a.energy_reserves = 110.0 - s2_b.energy_reserves = 95.0 - s2_a.last_decision_probabilities = {"dec-cooperate-store": 0.65, "dec-exchange-symbiosis": 0.25, "dec-conservative-forage": 0.10} - s2_b.last_decision_probabilities = {"dec-exchange-symbiosis": 0.55, "dec-cooperate-store": 0.30, "dec-explore-innovate": 0.15} - var s2_b_color := s2_b.color - state_syn2.active_pacts[EvoTypes.make_pair_key(s2_a.id, s2_b.id)] = 4 - var s2_init_pops := {s2_a.id: s2_a.population, s2_b.id: s2_b.population} - var s2_vecs := { - s2_a.id: EvoEcologyInteraction.compute_effective_vector(s2_a, state_syn2), - s2_b.id: EvoEcologyInteraction.compute_effective_vector(s2_b, state_syn2) - } - var s2_ext := {s2_a.id: 70.0, s2_b.id: 60.0} - EvoDemographyMigration.run_demography_and_migration_phase(state_syn2, "en", s2_init_pops, s2_vecs, {}, s2_ext) - _assert(state_syn2.demes.size() == 1, "Adjacent-hex synoecism (active_pacts >= 4) merges 2 neighboring allied demes into 1 city") - _assert(s2_a.merged_count == 1 and s2_a.secondary_color == s2_b_color, "Adjacent-hex unified city sets merged_count == 1 and secondary_color") - _assert(s2_a.defense_tier >= 2, "Synoecism elevates unified city defense_tier (%d)" % s2_a.defense_tier) - var has_merge_flow := false - for fl in state_syn2.active_flows: - if fl.flow_type == "merge" and fl.from_hex_id == "1,0" and fl.to_hex_id == "0,0": - has_merge_flow = true - break - _assert(has_merge_flow, "Adjacent-hex synoecism emits inter-hex 'merge' HexEdgeFlow") - var has_merge_chronicle := false - for e in state_syn2.chronicle: - if e.event_type == "merge": - has_merge_chronicle = true - break - _assert(has_merge_chronicle, "Synoecism logs 'merge' ChronicleEntry") - EvoMilestonePhase.run_milestone_phase(state_syn2, "en") - _assert(state_syn2.macro_outcome_type != "single_survivor", "Peaceful city unification does not falsely trigger single_survivor extinction outcome") - - # 5. All 11 New Procedural Sprite Assets & UI Icons - EvoPixelArtGen.ensure_all_assets(true) - var phase3_sprites: Array[String] = [ - "tier5_metropolis", - "prop_unity_monument", - "prop_defense_palisade", - "prop_defense_wall", - "prop_defense_fortress", - "prop_tribute_chest", - "prop_siege_engine", - "icon_defense", - "icon_merge", - "icon_tribute", - "icon_siege" - ] - for skey in phase3_sprites: - var subdir := "ui" if skey.begins_with("icon_") else "villages" - var file_path := "res://assets/sprites/%s/%s.png" % [subdir, skey] - _assert(FileAccess.file_exists(file_path), "New Phase 3 sprite PNG exists on disk: %s" % file_path) - var tex := EvoPixelArtGen.get_texture(skey) - _assert(tex != null and tex.get_width() >= 16 and tex.get_height() >= 16, "Loaded Phase 3 sprite Texture2D is valid and non-empty: %s" % skey) - - for icon_key in ["defense", "merge", "tribute", "siege", "fortify"]: - var ui_tex := EvoUiStyle.get_pixel_icon(icon_key) - _assert(ui_tex != null, "EvoUiStyle.get_pixel_icon('%s') returns valid Texture2D" % icon_key) - - diff --git a/tests/test_ui_and_world.gd b/tests/test_ui_and_world.gd new file mode 100644 index 0000000..7576992 --- /dev/null +++ b/tests/test_ui_and_world.gd @@ -0,0 +1,571 @@ +class_name EvoTestUiAndWorld +extends RefCounted + + +static func run(r) -> void: + _test_main_scene_and_ui_integration(r) + _test_bugfix_regressions(r) + _test_turn_events_and_world_stats(r) + _test_ux_clarity_stats_lens_and_fx(r) + + +static func _test_main_scene_and_ui_integration(r) -> void: + print("-- Testing Main Scene, WorldRenderer & UI Integration --") + var packed: PackedScene = load("res://scenes/main.tscn") + r._assert(packed != null, "res://scenes/main.tscn loads as PackedScene") + var main: EvoMainController = packed.instantiate() as EvoMainController + main.quit_on_close_enabled = false + r.root.add_child(main) + if main.camera == null: + main._ready() + + r._assert(main.camera != null, "main.camera exists and is configured") + r._assert(main.world_renderer != null, "main.world_renderer exists and is configured") + r._assert(main.hud != null, "main.hud exists and is configured") + r._assert(main.world_state != null, "main.world_state initialized on startup") + + # Verify all 52 PNG files in res://assets/sprites/ and ImageTexture generation + EvoPixelArtGen.ensure_all_assets() + var total_sprites: int = 0 + for s_name in EvoPixelArtGen.TERRAIN_SPRITES: + var path := "res://assets/sprites/terrain/%s.png" % s_name + r._assert(FileAccess.file_exists(ProjectSettings.globalize_path(path)), "Terrain PNG exists: %s" % path) + var tex := EvoPixelArtGen.get_texture(s_name) + r._assert(tex != null and tex.get_width() > 0, "Valid ImageTexture for %s" % s_name) + total_sprites += 1 + for s_name in EvoPixelArtGen.VILLAGE_SPRITES: + var path := "res://assets/sprites/villages/%s.png" % s_name + r._assert(FileAccess.file_exists(ProjectSettings.globalize_path(path)), "Village PNG exists: %s" % path) + var tex := EvoPixelArtGen.get_texture(s_name) + r._assert(tex != null and tex.get_width() > 0, "Valid ImageTexture for %s" % s_name) + total_sprites += 1 + for s_name in EvoPixelArtGen.UI_SPRITES: + var path := "res://assets/sprites/ui/%s.png" % s_name + r._assert(FileAccess.file_exists(ProjectSettings.globalize_path(path)), "UI PNG exists: %s" % path) + var tex := EvoPixelArtGen.get_texture(s_name) + r._assert(tex != null and tex.get_width() > 0, "Valid ImageTexture for %s" % s_name) + total_sprites += 1 + var expected_sprites := EvoPixelArtGen.TERRAIN_SPRITES.size() + EvoPixelArtGen.VILLAGE_SPRITES.size() + EvoPixelArtGen.UI_SPRITES.size() + r._assert(total_sprites == expected_sprites and total_sprites >= 60, "All %d pixel-art sprites verified" % total_sprites) + + # Verify Dual System 1 Decision Models (d3:4b + tev1:4b), Settings Persistence & Dynamic UI Text + EvoUserSettings.config_path_override = "" + r._assert(EvoUserSettings.get_config_dir().ends_with("/.config/Evolution"), "Default config dir points to ~/.config/Evolution") + EvoUserSettings.config_path_override = r._suite_cfg_path + r._assert(EvoUserSettings.DEFAULT_CONTEXT_LENGTH == 16384, "Default context window size is 16384") + r._assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-rift-west") == "d3:4b", "deme-rift-west assigned to d3:4b") + r._assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-rift-east") == "tev1:4b", "deme-rift-east assigned to tev1:4b") + r._assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-island-1") == "d3:4b", "deme-island-1 assigned to d3:4b") + r._assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-island-2") == "tev1:4b", "deme-island-2 assigned to tev1:4b") + var ollama_status := EvoDecisionModel.check_ollama_status() + r._assert(ollama_status.has("primary_tag") and ollama_status["primary_tag"] == "d3:4b", "Ollama status reports primary System 1 model d3:4b") + r._assert(ollama_status.has("secondary_tag") and ollama_status["secondary_tag"] == "tev1:4b", "Ollama status reports secondary System 1 model tev1:4b") + + var mock_tags := [ + {"name": "d3:4b", "capabilities": ["decision", "vision"]}, + {"name": "tev1:4b", "capabilities": ["decision"]}, + {"name": "embeddinggemma:latest", "capabilities": ["embedding"]} + ] + var filtered_dec := EvoOllamaClient.filter_decision_models_from_tags(mock_tags) + r._assert(filtered_dec.size() == 2 and filtered_dec.has("d3:4b") and filtered_dec.has("tev1:4b"), "filter_decision_models_from_tags returns only models with 'decision' capability") + + var test_cfg_path := ProjectSettings.globalize_path("user://test_evolution_settings.json") + EvoUserSettings.config_path_override = test_cfg_path + EvoUserSettings.primary_model = "custom-dec-a:4b" + EvoUserSettings.secondary_model = "custom-dec-b:4b" + EvoUserSettings.context_length = 8192 + main.hud._commit_context_window_text("16384") + r._assert(FileAccess.file_exists(test_cfg_path), "Settings file saved on change") + EvoUserSettings.primary_model = "d3:4b" + EvoUserSettings.secondary_model = "tev1:4b" + EvoUserSettings.context_length = 8192 + EvoUserSettings.load_settings() + r._assert(EvoUserSettings.primary_model == "custom-dec-a:4b" and EvoUserSettings.secondary_model == "custom-dec-b:4b" and EvoUserSettings.context_length == 16384, "Settings round-trip loads custom models and free-text context window 16384") + main.hud._apply_decision_models_update() + r._assert("custom-dec-a:4b" in main.hud._models_diag_lbl.text and "custom-dec-b:4b" in main.hud._models_diag_lbl.text and "16384" in main.hud._models_diag_lbl.text, "HUD diagnostic card updates dynamically with active models and context window") + r._assert("custom-dec-a:4b" in main.hud._settings_btn.tooltip_text and "custom-dec-b:4b" in main.hud._settings_btn.tooltip_text, "Settings button tooltip updates dynamically when models change") + var quit_signals: Array[int] = [0] + main.hud.quit_requested.connect(func() -> void: quit_signals[0] += 1) + + # Verify Ollama Offline blocking popup prevents starting the game and closes app when dismissed + EvoHudBannersToasts.show_ollama_offline_popup(main.hud) + r._assert(main.hud._missing_models_modal.visible and main.hud._ollama_startup_blocked, "Ollama offline popup is visible and blocks startup") + r._assert(main.hud._play_btn.disabled and main.hud._step_btn.disabled, "Play and Step buttons are disabled when Ollama is offline") + r._assert(not main.hud._missing_models_download_btn.visible, "Download button is hidden when Ollama is offline") + var blocked_gen := main.world_state.generation + main.hud._on_play_pressed() + main.step_generation() + r._assert(not main.is_playing and main.world_state.generation == blocked_gen, "Game cannot start or advance generation while Ollama is offline") + main.hud._on_missing_models_skip_pressed() + r._assert(not main.hud._missing_models_modal.visible and quit_signals[0] == 1, "Closing Ollama offline popup emits quit_requested to close the application") + + # Verify Missing Models popup also blocks startup and closes app if dismissed without downloading + EvoHudBannersToasts.show_missing_models_popup(main.hud, ["missing-dec:4b"]) + r._assert(main.hud._missing_models_modal.visible and "missing-dec:4b" in main.hud._missing_models_desc_lbl.text, "Missing models popup displays missing model tags at startup") + r._assert(main.hud._ollama_startup_blocked and main.hud._play_btn.disabled and main.hud._step_btn.disabled, "Missing models popup blocks game startup until downloaded") + main.hud._on_missing_models_skip_pressed() + r._assert(not main.hud._missing_models_modal.visible and quit_signals[0] == 2, "Closing missing models popup emits quit_requested") + + # Unblock startup for subsequent UI interaction tests + main.hud._set_startup_blocked(false) + DirAccess.remove_absolute(test_cfg_path) + EvoUserSettings.config_path_override = r._suite_cfg_path + EvoUserSettings.primary_model = EvoUserSettings.DEFAULT_PRIMARY_MODEL + EvoUserSettings.secondary_model = EvoUserSettings.DEFAULT_SECONDARY_MODEL + EvoUserSettings.context_length = EvoUserSettings.DEFAULT_CONTEXT_LENGTH + main.hud._apply_decision_models_update() + + # Advance generations via main.step_generation() + var gen_before := main.world_state.generation + main.step_generation() + main.step_generation() + r._assert(main.world_state.generation == gen_before + 2, "main.step_generation() advances world_state generation") + r._assert(main.world_renderer.world_state == main.world_state, "world_renderer tracks updated world_state") + + # Preset switching + main.load_preset("preset-darwin-archipelago") + r._assert(main.world_state.preset_id == "preset-darwin-archipelago", "Switched to preset-darwin-archipelago") + r._assert(main.world_state.cells.size() == 169, "Darwin preset has 169 cells in main") + main.step_generation() + + main.load_preset("preset-finite-island") + r._assert(main.world_state.preset_id == "preset-finite-island", "Switched to preset-finite-island") + r._assert(main.world_state.cells.size() == 169, "Finite Island preset has 169 cells in main") + main.step_generation() + + # Language switching + main._on_language_changed("en") + r._assert(main.lang == "en", "Language switched to en") + main._on_language_changed("es") + r._assert(main.lang == "es", "Language switched back to es") + + # Map lenses switching + for lens_id in ["food", "health", "diplomacy", "territory", "biome"]: + main.hud._on_lens_pressed(lens_id) + r._assert(main.world_renderer.active_lens == lens_id, "Lens switched to %s" % lens_id) + + # Modals opening and closing + main.hud.open_notebook_modal() + r._assert(main.hud.notebook_modal.visible, "Notebook modal opens cleanly") + main.hud.notebook_modal.close_modal() + r._assert(not main.hud.notebook_modal.visible, "Notebook modal closes cleanly") + + main.hud.open_tree_modal() + r._assert(main.hud.tree_modal.visible, "Tree modal opens cleanly") + main.hud.tree_modal.close_modal() + r._assert(not main.hud.tree_modal.visible, "Tree modal closes cleanly") + + main.hud.open_lab_modal() + r._assert(main.hud.lab_modal.visible, "Lab modal opens cleanly") + main.hud.lab_modal.close_modal() + r._assert(not main.hud.lab_modal.visible, "Lab modal closes cleanly") + + # Environmental tools via HUD and MainController + main.world_state.selective_pressure_points = 100 + main.hud._on_tool_button_pressed("set_biome") + main.hud._on_sub_mode_selected("tundra") + main._on_world_hex_clicked("0,0", MOUSE_BUTTON_LEFT) + r._assert((main.world_state.cells["0,0"] as EvoTypes.HexCell).biome == "tundra", "Environmental tool set_biome applied via main controller") + + main.hud._on_tool_button_pressed("adjust_altitude") + main.hud._on_sub_mode_selected("raise") + var alt_before := (main.world_state.cells["0,0"] as EvoTypes.HexCell).altitude + main._on_world_hex_clicked("0,0", MOUSE_BUTTON_LEFT) + r._assert((main.world_state.cells["0,0"] as EvoTypes.HexCell).altitude > alt_before, "Environmental tool adjust_altitude applied via main controller") + + main.hud._on_tool_button_pressed("adjust_carrying_capacity") + main.hud._on_sub_mode_selected("enrich") + main._on_world_hex_clicked("0,0", MOUSE_BUTTON_LEFT) + + var sea_before := main.world_state.sea_level + main._on_sea_level_change_requested(0.05) + r._assert(is_equal_approx(main.world_state.sea_level, sea_before + 0.05), "Sea level tool applied via main controller") + + # Verify Expanded Hex Size (>= 220.0), >= 2x Village/Villager Scales & Lifecycle Anti-Overlap + r._assert(HexMath.HEX_SIZE >= 220.0, "HexMath.HEX_SIZE expanded to >= 220.0 (is %.1f)" % HexMath.HEX_SIZE) + var b_layout := main.world_renderer._get_village_building_layout(2, true, 2, "cooperate") + r._assert(b_layout.size() >= 3 and float(b_layout[0]["scale"]) >= 2.0, "Village building scale is >= 2.0x baseline") + main.load_preset("preset-rift-savanna") + main.world_renderer._rebuild() + var moving_props := main.world_renderer._collect_moving_props() + r._assert(not moving_props.is_empty() and moving_props[0].rect.size.y >= 28.0, "Inhabitant/campfire moving props are >= 2x baseline size") + + # Verify disused_connections lifecycle aging and HUD inspector zero-information-loss + var life_state := EvoCatalog.create_preset_world("preset-finite-island", "es") + var ld1: EvoTypes.Deme = life_state.demes["deme-island-1"] + var ld2: EvoTypes.Deme = life_state.demes["deme-island-2"] + (life_state.cells[ld1.hex_id] as EvoTypes.HexCell).deme_ids.erase(ld1.id) + (life_state.cells[ld2.hex_id] as EvoTypes.HexCell).deme_ids.erase(ld2.id) + ld1.hex_id = "0,0" + ld2.hex_id = "1,0" + (life_state.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([ld1.id]) + (life_state.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([ld2.id]) + var coop_fl := EvoTypes.HexEdgeFlow.new() + coop_fl.from_hex_id = "0,0" + coop_fl.to_hex_id = "1,0" + coop_fl.flow_type = "cooperate" + coop_fl.from_deme_id = "deme-island-1" + coop_fl.to_deme_id = "deme-island-2" + life_state.active_flows.append(coop_fl) + EvoEnvPhase.run_environmental_phase(life_state, "es") + r._assert(life_state.disused_connections.size() == 1 and int(life_state.disused_connections[0].get("age", -1)) == 0, "Active friendly flow recorded into disused_connections with age 0") + # Force raid so the friendly flow is severed + ld1.chosen_action = EvoActionMenu.option("raid", ld2.id) + ld2.chosen_action = EvoActionMenu.option("forage") + EvoBiophysPhase.run_biophysical_phase(life_state, "es") + r._assert(life_state.disused_connections.size() == 1 and int(life_state.disused_connections[0].get("age", 0)) == 1, "Severed friendly flow ages to 1 in biophys_phase") + main.hud.set_world_state(life_state) + var rel_lines := main.hud._build_deme_relations_lines(ld1) + r._assert(rel_lines.size() >= 1, "HUD inspector preserves active/severed inter-hex relations without information loss") + + # Verify Right Inspector Village Selector & Cycling + r._assert(main.hud._village_selector_opt != null and main.hud._village_selector_opt.item_count == life_state.demes.size(), "Right Inspector village selector populated with all demes") + var prev_sel_deme := main.hud._selected_deme_id + main.hud._on_village_cycle_pressed(1) + r._assert(main.hud._selected_deme_id != prev_sel_deme, "Right Inspector village cycle button advances selected village") + + # Verify clicking an empty hex in inspect mode clears selected deme and switches inspector tab to habitat + main.load_preset("preset-rift-savanna") + main.hud._on_tool_button_pressed("inspect") + var empty_hex_id := "" + for hid in main.world_state.cells.keys(): + if (main.world_state.cells[hid] as EvoTypes.HexCell).deme_ids.is_empty(): + empty_hex_id = str(hid) + break + main._on_world_hex_clicked(empty_hex_id, MOUSE_BUTTON_LEFT) + r._assert(main.world_renderer.selected_deme_id == "" and main.hud._selected_deme_id == "" and main.hud._inspector_tab == "habitat", "Clicking empty hex in inspect mode clears selected deme and switches inspector tab to habitat") + + main.free() + + +static func _test_bugfix_regressions(r) -> void: + print("-- Testing Bugfix Regressions --") + # 1. strip_migration_suffix preserves root deme IDs and strips migration/warband suffixes + r._assert(EvoSettlementPhase.strip_migration_suffix("deme-rift-west") == "deme-rift-west", "strip_migration_suffix preserves 'deme-rift-west'") + r._assert(EvoSettlementPhase.strip_migration_suffix("deme-rift-west-m3-0_0") == "deme-rift-west", "strip_migration_suffix strips '-m3-0_0' suffix") + r._assert(EvoSettlementPhase.strip_migration_suffix("deme-rift-west-w2") == "deme-rift-west", "strip_migration_suffix strips '-w2' suffix") + + # 2. resolve_deme_decision_model_tag assigns deme-darwin-rival to secondary model + r._assert(EvoDecisionModel.resolve_deme_decision_model_tag("deme-darwin-rival", "d3:4b", "tev1:4b") == "tev1:4b", "resolve_deme_decision_model_tag assigns 'deme-darwin-rival' to secondary model 'tev1:4b'") + + # 3. EvoActionMenu.is_passable rejects altitude > 0.85 (0.88 mountain barrier) + var mountain_cell := EvoTypes.HexCell.new() + mountain_cell.id = "0,1" + mountain_cell.biome = "mountain" + mountain_cell.altitude = 0.88 + mountain_cell.carrying_capacity = 100.0 + r._assert(not EvoActionMenu.is_passable(mountain_cell), "EvoActionMenu.is_passable rejects mountain barrier with altitude 0.88 (> 0.85)") + + # 4. validate_and_balance_thermodynamics preserves positive ecological_impact minus excess when target_metabolic > 1.0 + var high_vec := EvoTypes.BiophysicalVector.new(0.0, 1.0, 0.30, 1.0, 1.0, 0.0) + # A+ = 3.0 -> required_cost = 0.5 * (3.0 - 0.60) = 1.20 -> target_metabolic = 1.20 -> excess = 0.20 -> ecological_impact = 0.30 - 0.20 = 0.10 + var balanced_high := EvoThermodynamics.validate_and_balance_thermodynamics(high_vec, false) + r._assert(is_equal_approx(balanced_high.metabolic_cost, 1.0), "validate_and_balance_thermodynamics caps metabolic_cost at 1.0 when target_metabolic > 1.0") + r._assert(is_equal_approx(balanced_high.ecological_impact, 0.10), "validate_and_balance_thermodynamics preserves positive ecological_impact minus excess (0.30 - 0.20 = 0.10)") + + # 5. EvoLabModal.refresh() preserves checked strategy traits in strategy_trait_checks + var state := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var lab := EvoLabModal.new() + r.root.add_child(lab) + if lab._title_label == null: + lab._ready() + lab.open_modal(state, "es") + lab._switch_tab("strategies") + lab._start_new_item() + (lab.strategy_trait_checks["bio-thermal-tolerance"] as CheckBox).button_pressed = true + (lab.strategy_trait_checks["bio-social-instinct"] as CheckBox).button_pressed = true + lab.refresh(state) + r._assert((lab.strategy_trait_checks["bio-thermal-tolerance"] as CheckBox).button_pressed and (lab.strategy_trait_checks["bio-social-instinct"] as CheckBox).button_pressed, "EvoLabModal.refresh() preserves checked strategy traits in strategy_trait_checks") + lab.free() + + # 6. EvoUiStyle.get_village_tier_label matches Deme.get_village_tier() across all 4 tiers (I..IV) + var tier_cases := [ + {"pop": 65, "merged": 0, "def": 0, "union": "", "tier": 1, "es": "I · Campamento", "en": "I · Camp"}, + {"pop": 90, "merged": 0, "def": 1, "union": "", "tier": 2, "es": "II · Aldea", "en": "II · Village"}, + {"pop": 140, "merged": 0, "def": 0, "union": "", "tier": 2, "es": "II · Aldea", "en": "II · Village"}, + {"pop": 185, "merged": 0, "def": 1, "union": "", "tier": 3, "es": "III · Villa", "en": "III · Town"}, + {"pop": 255, "merged": 0, "def": 0, "union": "", "tier": 3, "es": "III · Villa", "en": "III · Town"}, + {"pop": 295, "merged": 0, "def": 0, "union": "union-a-b", "tier": 4, "es": "IV · Metrópolis", "en": "IV · Metropolis"}, + {"pop": 395, "merged": 0, "def": 0, "union": "", "tier": 4, "es": "IV · Metrópolis", "en": "IV · Metropolis"} + ] + for tc in tier_cases: + var td := EvoTypes.Deme.new() + td.population = int(tc["pop"]) + td.merged_count = int(tc["merged"]) + td.defense_tier = int(tc["def"]) + td.walled_union_id = str(tc["union"]) + var lbl_es := EvoUiStyle.get_village_tier_label(td.population, "es", td.merged_count, td.defense_tier, td.walled_union_id) + var lbl_en := EvoUiStyle.get_village_tier_label(td.population, "en", td.merged_count, td.defense_tier, td.walled_union_id) + r._assert( + td.get_village_tier() == int(tc["tier"]) and lbl_es == str(tc["es"]) and lbl_en == str(tc["en"]) and lbl_es.begins_with(td.get_village_roman() + " · "), + "EvoUiStyle.get_village_tier_label matches Deme.get_village_tier() for tier %d (pop=%d, def=%d, union=%s)" % [int(tc["tier"]), td.population, td.defense_tier, td.walled_union_id] + ) + + # 7. max_rural quota enforcement releases excess rural hexes when a deme drops in tier + var state_quota := EvoPresets.create_preset_world("preset-rift-savanna", "en") + var q_deme: EvoTypes.Deme = state_quota.demes["deme-rift-west"] + var q_other: EvoTypes.Deme = state_quota.demes["deme-rift-east"] + (state_quota.cells[q_deme.hex_id] as EvoTypes.HexCell).deme_ids.erase(q_deme.id) + (state_quota.cells[q_other.hex_id] as EvoTypes.HexCell).deme_ids.erase(q_other.id) + q_deme.hex_id = "0,0" + q_other.hex_id = "0,4" + (state_quota.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([q_deme.id]) + (state_quota.cells["0,4"] as EvoTypes.HexCell).deme_ids.assign([q_other.id]) + q_deme.population = 400 + q_deme.defense_tier = 2 + for nid in HexMath.get_neighbor_ids(0, 0, state_quota.cells): + var nc: EvoTypes.HexCell = state_quota.cells[nid] + nc.biome = "savanna" + nc.altitude = 0.45 + nc.current_resources = 500.0 + EvoRuralHinterland.claim_hinterlands(state_quota) + var count_t4 := 0 + for nid in HexMath.get_neighbor_ids(0, 0, state_quota.cells): + if (state_quota.cells[nid] as EvoTypes.HexCell).owner_deme_id == q_deme.id: + count_t4 += 1 + r._assert(count_t4 == 4, "Tier 4 deme claims 4 rural hinterland hexes") + + q_deme.population = 180 + q_deme.defense_tier = 1 + EvoRuralHinterland.claim_hinterlands(state_quota) + var count_t3 := 0 + for nid in HexMath.get_neighbor_ids(0, 0, state_quota.cells): + if (state_quota.cells[nid] as EvoTypes.HexCell).owner_deme_id == q_deme.id: + count_t3 += 1 + r._assert(q_deme.get_village_tier() == 3 and count_t3 == 2, "Dropping to Tier 3 releases excess rural hexes down to max_rural == 2") + + q_deme.population = 70 + q_deme.defense_tier = 0 + EvoRuralHinterland.claim_hinterlands(state_quota) + var count_t1 := 0 + for nid in HexMath.get_neighbor_ids(0, 0, state_quota.cells): + if (state_quota.cells[nid] as EvoTypes.HexCell).owner_deme_id == q_deme.id: + count_t1 += 1 + r._assert(q_deme.get_village_tier() == 1 and count_t1 == 0, "Dropping to Tier 1 releases all rural hexes (max_rural == 0)") + + # 8. Flood/mountain relocation clears non-adjacent rural hexes and revalidates walled_union_id + var state_reloc := EvoPresets.create_preset_world("preset-rift-savanna", "en") + state_reloc.unlimited_pressure_mode = true + var r_d1: EvoTypes.Deme = state_reloc.demes["deme-rift-west"] + var r_d2: EvoTypes.Deme = state_reloc.demes["deme-rift-east"] + (state_reloc.cells[r_d1.hex_id] as EvoTypes.HexCell).deme_ids.erase(r_d1.id) + (state_reloc.cells[r_d2.hex_id] as EvoTypes.HexCell).deme_ids.erase(r_d2.id) + r_d1.hex_id = "0,0" + r_d2.hex_id = "1,0" + (state_reloc.cells["0,0"] as EvoTypes.HexCell).deme_ids.assign([r_d1.id]) + (state_reloc.cells["1,0"] as EvoTypes.HexCell).deme_ids.assign([r_d2.id]) + r_d1.population = 180 + r_d2.population = 180 + r_d1.defense_tier = 1 + r_d2.defense_tier = 1 + r_d1.walled_union_id = "union-reloc-test" + r_d2.walled_union_id = "union-reloc-test" + EvoTypes.get_relation(state_reloc, r_d1.id, r_d2.id).pact_turns = 4 + for nid in HexMath.get_neighbor_ids(0, 0, state_reloc.cells): + if nid != "1,0": + var nc: EvoTypes.HexCell = state_reloc.cells[nid] + nc.biome = "ocean" + nc.altitude = 0.05 + var escape_cell: EvoTypes.HexCell = state_reloc.cells["-1,0"] + escape_cell.biome = "savanna" + escape_cell.altitude = 0.45 + escape_cell.current_resources = 500.0 + var far_rural_cell: EvoTypes.HexCell = state_reloc.cells["0,1"] + far_rural_cell.biome = "savanna" + far_rural_cell.altitude = 0.45 + far_rural_cell.current_resources = 10.0 + far_rural_cell.owner_deme_id = r_d1.id + far_rural_cell.land_use = "crops" + EvoTurnEngine.apply_set_biome(state_reloc, ["0,0"], "high_mountain", "en") + r._assert(r_d1.hex_id == "-1,0", "Displaced deme relocates from high_mountain (0,0) to richest passable neighbor (-1,0)") + r._assert(far_rural_cell.owner_deme_id == "" and far_rural_cell.land_use == "wild", "Relocation clears rural hex (0,1) that is no longer at hex_distance == 1 from (-1,0)") + r._assert(r_d1.walled_union_id == "" and r_d2.walled_union_id == "", "Relocation out of adjacency dissolves walled_union_id on both partners") + + # 9. Seeded Ollama result application on next generation + var state_pf := EvoPresets.create_preset_world("preset-rift-savanna", "es") + var pf_west: EvoTypes.Deme = state_pf.demes["deme-rift-west"] + EvoOllamaClient.seed_deme_result(pf_west.id, { + "probabilities": {"intensify": 0.99, "forage": 0.01}, + "dominant_decision_id": "intensify", + "confidence": 0.93 + }) + EvoDecisionModel.evaluate_all_demes(state_pf, "es", false) + r._assert(is_equal_approx(pf_west.last_confidence, 0.93) and str(pf_west.chosen_action.get("type", "")) in ["intensify", "forage"], "evaluate_all_demes applies seeded Ollama result cleanly") + EvoOllamaClient.clear_cache() + + +static func _test_turn_events_and_world_stats(r) -> void: + print("-- Testing Turn Events Queue, World Stats History & Serialization --") + var state := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") + var total_events_seen := 0 + var all_events_valid := true + for g_i in range(8): + if g_i == 0 and state.demes.has("deme-rift-west"): + var dw: EvoTypes.Deme = state.demes["deme-rift-west"] + dw.energy_reserves = 200.0 + dw.chosen_action = EvoActionMenu.option("fortify") + EvoTurnEngine.advance_generation(state, "en") + total_events_seen += state.turn_events.size() + for ev in state.turn_events: + var ev_type := str(ev["type"]) + var from_hex := str(ev["from_hex"]) + var to_hex := str(ev["to_hex"]) + if not (ev_type in EvoTypes.EVENT_TYPES) or not state.cells.has(from_hex) or not state.cells.has(to_hex): + all_events_valid = false + break + r._assert(total_events_seen > 0, "Multi-turn simulation populates state.turn_events (total=%d)" % total_events_seen) + r._assert(all_events_valid, "All turn_events entries have canonical type in EvoTypes.EVENT_TYPES and valid hex IDs") + r._assert(state.stats_history.size() == 8, "EvoTurnEngine.advance_generation records per-generation snapshot in state.stats_history") + + # Verify EvoEnvPhase.run_environmental_phase clears turn_events + EvoTypes.push_event(state, "pact", "0,0", "1,0", "deme-rift-west", "deme-rift-east", 2.0, 0) + r._assert(not state.turn_events.is_empty(), "EvoTypes.push_event appends to state.turn_events") + EvoEnvPhase.run_environmental_phase(state, "en") + r._assert(state.turn_events.is_empty(), "EvoEnvPhase.run_environmental_phase clears state.turn_events") + + # Verify STATS_HISTORY_LIMIT cap + for g_idx in range(20, 20 + EvoTypes.STATS_HISTORY_LIMIT + 15): + state.generation = g_idx + EvoTypes.record_turn_stats(state) + r._assert(state.stats_history.size() == EvoTypes.STATS_HISTORY_LIMIT, "EvoTypes.record_turn_stats caps state.stats_history at STATS_HISTORY_LIMIT (%d)" % EvoTypes.STATS_HISTORY_LIMIT) + + # Verify JSON round-trip preserves stats_history and new Deme fields + var sample_deme: EvoTypes.Deme = state.demes.values()[0] + sample_deme.infection_turns = 2 + sample_deme.immunity_turns = 5 + sample_deme.last_food_intake = 42.5 + sample_deme.last_food_need = 36.25 + sample_deme.last_population_delta = -7 + var json_str := JSON.stringify(state.to_dict()) + var parsed: Dictionary = JSON.parse_string(json_str) + var restored := EvoTypes.WorldState.from_dict(parsed) + r._assert(restored.stats_history.size() == state.stats_history.size(), "WorldState JSON round-trip preserves stats_history") + var restored_deme: EvoTypes.Deme = restored.demes[sample_deme.id] + r._assert( + restored_deme.infection_turns == 2 + and restored_deme.immunity_turns == 5 + and is_equal_approx(restored_deme.last_food_intake, 42.5) + and is_equal_approx(restored_deme.last_food_need, 36.25) + and restored_deme.last_population_delta == -7, + "Deme JSON round-trip preserves infection_turns, immunity_turns, last_food_intake, last_food_need, and last_population_delta" + ) + + +static func _test_ux_clarity_stats_lens_and_fx(r) -> void: + print("-- Testing Event Replay FX, Live Chronicle Feed, Stats Strip & HUD Readability --") + var packed: PackedScene = load("res://scenes/main.tscn") + var main: EvoMainController = packed.instantiate() as EvoMainController + main.quit_on_close_enabled = false + r.root.add_child(main) + if main.camera == null: + main._ready() + + main.load_preset("preset-rift-savanna") + main.hud._set_startup_blocked(false) + + r._assert(main.hud._stats_strip_panel != null and main.hud._stats_strip_panel.visible, "HUD A-vs-B stats strip panel exists and is visible") + r._assert(main.hud._event_feed_panel != null and main.hud._event_feed_panel.visible, "HUD live chronicle feed panel exists and is visible") + + var saw_fx_active := false + for g_i in range(6): + if g_i == 0 and main.world_state.demes.has("deme-rift-west"): + var dw: EvoTypes.Deme = main.world_state.demes["deme-rift-west"] + dw.population = 90 + dw.defense_tier = 1 + dw.energy_reserves = 200.0 + dw.chosen_action = EvoActionMenu.option("fortify") + main.step_generation() + main.world_renderer._process(0.016) + if not main.world_state.turn_events.is_empty() and not main.world_renderer._fx_active.is_empty(): + saw_fx_active = true + r._assert(saw_fx_active, "world_renderer._fx_active is populated when turn_events is non-empty") + + # Ensure chronicle has at least one entry and test clicking a live chronicle feed row button + var feed_btn: Button = null + for child in main.hud._event_feed_vbox.get_children(): + if child is Button: + feed_btn = child as Button + break + r._assert(feed_btn != null, "Live chronicle feed vbox contains clickable Button rows") + var latest_entry: EvoTypes.ChronicleEntry = main.world_state.chronicle.back() + main.world_renderer.selected_hex_id = "0,0" + feed_btn.pressed.emit() + r._assert(main.world_renderer.selected_hex_id == latest_entry.hex_id, "Clicking live chronicle feed button jumps world_renderer.selected_hex_id to entry hex (%s)" % latest_entry.hex_id) + + # Verify cycling speeds across 0, 1, 2 + r._assert(main.speed_index == 1 and EvoMainController.STEP_INTERVALS == [4.0, 2.0, 0.6], "Default speed_index is 1 with STEP_INTERVALS = [4.0, 2.0, 0.6]") + main.hud._on_speed_pressed() + r._assert(main.speed_index == 2, "First speed button press advances speed_index to 2 (0.6s)") + main.hud._on_speed_pressed() + r._assert(main.speed_index == 0, "Second speed button press wraps speed_index to 0 (4.0s)") + main.hud._on_speed_pressed() + r._assert(main.speed_index == 1, "Third speed button press returns speed_index to 1 (2.0s)") + + # Verify closing _milestone_banner when macro_outcome_type != "" does not re-show on subsequent refresh + main.world_state.macro_outcome_type = "ecological_equilibrium" + main.world_state.macro_outcome_title = "Equilibrium Reached" + main.world_state.macro_outcome_summary = "Populations stabilized." + main.hud.refresh(main.world_state) + r._assert(main.hud._milestone_banner.visible, "Milestone banner appears when macro_outcome_type is first set") + main.hud._milestone_banner.visible = false + main.hud.refresh(main.world_state) + r._assert(not main.hud._milestone_banner.visible, "Closed macro outcome banner does not re-open on subsequent hud.refresh() for the same macro_outcome_type") + + # Verify full-map camera zoom-out (MIN_ZOOM <= 0.15 and fit_full_map / apply_preset("fit")) and overlay refresh + r._assert(EvoCameraController.MIN_ZOOM <= 0.15, "Camera MIN_ZOOM allows full-map zoom-out (%.2f <= 0.15)" % EvoCameraController.MIN_ZOOM) + main.camera.apply_preset("fit") + r._assert(main.camera._target_zoom < 0.25 and main.camera._target_pan == Vector2.ZERO, "Camera apply_preset('fit') zooms out to fit full map (%.3f) and centers on Vector2.ZERO" % main.camera._target_zoom) + main.world_renderer._dirty = false + main.world_renderer._overlay_dirty = false + main.world_renderer.refresh_overlay(main.world_state) + r._assert(main.world_renderer._overlay_dirty and not main.world_renderer._dirty, "world_renderer.refresh_overlay marks only _overlay_dirty without triggering full column/prop _dirty rebuild") + main.hud.refresh_inspector_only(main.world_state) + + main.free() + + # Verify rural hinterland preservation when settling onto an open frontier + var bugs_state := EvoPresets.create_preset_world("preset-rift-savanna", "es", "strat-cooperation", "strat-war") + for cid in bugs_state.cells.keys(): + var c: EvoTypes.HexCell = bugs_state.cells[cid] + c.deme_ids.clear() + c.owner_deme_id = "" + c.land_use = "wild" + var d_a: EvoTypes.Deme = bugs_state.demes["deme-rift-west"].duplicate_deme() + var d_b_tpl: EvoTypes.Deme = bugs_state.demes["deme-rift-east"].duplicate_deme() + bugs_state.demes.clear() + d_a.id = "deme-a-main" + d_a.hex_id = "-2,1" + d_a.population = 420 + d_a.defense_tier = 2 + d_a.energy_reserves = 210.0 + bugs_state.demes[d_a.id] = d_a + (bugs_state.cells["-2,1"] as EvoTypes.HexCell).deme_ids.assign([d_a.id]) + var crop_coords: Array[String] = ["-2,0", "-3,1", "-3,2", "-2,2"] + for cc in crop_coords: + var ccell: EvoTypes.HexCell = bugs_state.cells[cc] + ccell.biome = "savanna" + ccell.altitude = 0.42 + ccell.owner_deme_id = d_a.id + ccell.land_use = "crops" + var db := d_b_tpl.duplicate_deme() + db.id = "deme-b-1" + db.hex_id = "1,0" + db.population = 120 + db.defense_tier = 1 + db.energy_reserves = 120.0 + bugs_state.demes[db.id] = db + (bugs_state.cells[db.hex_id] as EvoTypes.HexCell).deme_ids.assign([db.id]) + d_a.chosen_action = EvoActionMenu.option("settle", "-1,1") + db.chosen_action = EvoActionMenu.option("forage") + EvoBiophysPhase.run_biophysical_phase(bugs_state, "es") + var budded_safe := false + for did in bugs_state.demes.keys(): + var d: EvoTypes.Deme = bugs_state.demes[did] + if d.lineage_id == d_a.lineage_id and d.id != d_a.id and d.hex_id == "-1,1": + budded_safe = true + var crops_kept := 0 + for cc in crop_coords: + var ccell: EvoTypes.HexCell = bugs_state.cells[cc] + if ccell.owner_deme_id == d_a.id and ccell.land_use == "crops" and ccell.deme_ids.is_empty(): + crops_kept += 1 + r._assert(budded_safe and crops_kept == 4, "City settles new camp on open neighbor (-1,1) while keeping all 4 crops intact") diff --git a/tests/test_ui_and_world.gd.uid b/tests/test_ui_and_world.gd.uid new file mode 100644 index 0000000..dc18538 --- /dev/null +++ b/tests/test_ui_and_world.gd.uid @@ -0,0 +1 @@ +uid://ca6epqrgob4no diff --git a/tests/test_visual_capture.gd b/tests/test_visual_capture.gd index 6a8b9d4..5576f22 100644 --- a/tests/test_visual_capture.gd +++ b/tests/test_visual_capture.gd @@ -84,7 +84,7 @@ func _run_captures() -> void: main.step_generation() if not main.world_state.demes.is_empty(): var first_d: EvoTypes.Deme = main.world_state.demes.values()[0] - first_d.baldwin_counters[first_d.last_dominant_decision_id] = 8 + first_d.baldwin_counters["fortify"] = 8 first_d.innovation_counter = 1.1 EvoMilestonePhase.run_milestone_phase(main.world_state, main.lang) main.hud.refresh(main.world_state) @@ -134,6 +134,31 @@ func _run_captures() -> void: main.world_renderer.selected_hex_id = (main.world_state.demes[best_did] as EvoTypes.Deme).hex_id await _wait_and_save(main, "screenshot_late_simulation.png") + # 8. screenshot_lens_diplomacy.png (diplomacy lens + dynamic legend) + main.hud._on_lens_pressed("diplomacy") + if not main.hud._legend_panel.visible: + main.hud._on_legend_toggle_pressed() + await _wait_and_save(main, "screenshot_lens_diplomacy.png") + + # 9. screenshot_lens_territory.png (territory lens) + main.hud._on_lens_pressed("territory") + await _wait_and_save(main, "screenshot_lens_territory.png") + + # 10. screenshot_lens_food.png (food lens) + main.hud._on_lens_pressed("food") + await _wait_and_save(main, "screenshot_lens_food.png") + + # 11. screenshot_stats_chart.png (biome lens + A-vs-B history chart drawer open) + if main.hud._legend_panel.visible: + main.hud._on_legend_toggle_pressed() + main.hud._on_lens_pressed("biome") + main.hud._stats_chart_panel.visible = true + EvoUiStyle.apply_button_active(main.hud._stats_chart_toggle_btn, true) + EvoHudStatsStrip.refresh_stats_strip(main.hud) + await _wait_and_save(main, "screenshot_stats_chart.png") + main.hud._stats_chart_panel.visible = false + EvoUiStyle.apply_button_active(main.hud._stats_chart_toggle_btn, false) + main.free() EvoDecisionModel.wait_for_workers() var cat_path := _tmp_cfg_dir.path_join("user_catalog.json")
CI @@ -23,7 +23,7 @@ hide:

Key Features

- Authentic evolutionary biology meets local multi-model AI cognition + Authentic evolutionary biology meets local multi-model AI cognition & 2.5D illustrated urban ecology
@@ -31,7 +31,7 @@ hide:

1v1 Evolutionary Strategy Duels

-

Before pressing Play at Generation 0, pit any two evolutionary strategies (Strategy A ⚔️ Strategy B) against each other—or craft custom strategies in the Forge and persist them to ~/.config/Evolution/user_catalog.json.

+

Before pressing Play at Generation 0, pit any two evolutionary strategies (Strategy A ⚔️ Strategy B) against each other across a 169-hex world—or craft custom strategies in the Forge and persist them to ~/.config/Evolution/user_catalog.json.

@@ -50,23 +50,23 @@ hide:
- -

Baldwin Effect & Speciation

-

Sustained behavioral plasticity (8+ turns) assimilates into heritable biological traits derived from the dominant decision, while geographic isolation across ridges or flooded land bridges triggers allopatric speciation.

+ +

4-Tier Urbanism, Hinterlands & Salubrity

+

Single-deme-per-hex settlements progress across 4 urban tiers (I · Camp to IV · Metropolis), cultivating up to 1, 2, or 4 adjacent rural hexes as crops or pasture while balancing urban crowding, salubrity, and epidemics.

- -

Fortifications, Diplomacy & Synoecism

-

Populations build 3-tier fortifications (Palisade, Stone Wall, Castle), forge Alliance Pacts & Defensive Coalitions, harbor Vendettas, pay Tribute, repel Sieges, and unite into Tier 5 Metropolises.

+ +

Perimeter Walls & Dual Synoecism

+

Settlements erect 3-tier hex-edge fortifications, absorb lower-tier allies into rural hinterlands (Asymmetric Category Absorption), or unite same-tier cities into Continuous Multi-Hex Walled Unions (walled_union_id) with culled internal walls.

-

God-Mode Selective Pressure

-

Wield a 10-slot brush dock to sculpt biomes, raise tectonic ridges, flood glacial archipelagos, enrich carrying capacity $K$, or forge and inject custom strategies, traits, and decisions in the Evolutionary Laboratory.

+

2.5D Illustrated Shaders & God-Mode Tools

+

High-DPI 256x224 tiles and 128x128–160x160 sprites with #23150c ink outlines, GPU ocean & foliage shaders, and a 10-slot brush dock to sculpt biomes, altitude, sea levels, and carrying capacity $K$.