_`) or conquers a rival neighbor 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. **Inter-Cell Kin Merge, Refugee Merge & Synoecism (`EvoDemographyMigration._merge_deme_into`)**: + When two adjacent settlements merge across a border (or when a displaced deme merges into an adjacent allied/kin refuge via `evacuate_or_merge_displaced_deme`), the host settlement preserves its primary `deme.strategy_id` and `deme.color` while recording the partner's color in `deme.secondary_color` and incrementing `deme.merged_count`. 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`. @@ -157,52 +158,53 @@ To guarantee that every village on the map always retains its founding strategy --- -## 2.5D Isometric Rendering & Procedural Pixel-Art Pipeline +## High-Definition 2D/2.5D Procedural Vector & GPU Shader World (`src/world`) -### 1. 2.5D Projection & Painter's Algorithm Depth Sorting +### 1. Procedural 2.5D Vector Hex Columns & GPU Shaders (`EvoWorldRenderer`) `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]$: $$\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}$$ +* **GPU `ShaderMaterial` Terrain & Ocean Columns (`OCEAN_SHADER_CODE` & `TERRAIN_SHADER_CODE`)**: + * Rather than stamping disk-cached PNG hex tiles, `EvoWorldRenderer._draw_hex_column` renders multi-bevel vector hex polygons (`top_pts`, `mid_pts`, `inner_pts`) shaded on the GPU: + * **`OCEAN_SHADER_CODE` (`_ocean_shader_mat`)**: Procedural animated wave caustic bands and coastal shoreline foam modulated by `season_warmth`. + * **`TERRAIN_SHADER_CODE` (`_terrain_shader_mat`)**: Procedural FBM micro-relief, radial sun-rim bevel lighting, and seasonal warmth modulation (`season_warmth = 1.0` in `"wet_warm"`, `0.0` in `"dry_cold"`) with full UV coverage across outer (`_hex_uvs`), mid (`_hex_mid_uvs`), and inner (`_hex_inner_uvs`) bevel rings. * **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. - -### 2. Procedural Pixel-Art Generator (`EvoPixelArtGen`) -At startup, `EvoPixelArtGen.ensure_all_assets()` procedurally rasterizes and caches **99 crisp pixel-art sprites** using `EvoPixelCanvas`: - -* **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`). - -### 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**: - -| 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. | -| **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. | -| **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. | -| **Dispute Barricade Front (`dispute`)** | Active `"dispute"` flow or Hawk/Overexploit rivalry between adjacent lineages | Interrupted dirt road stubs meeting at a scorched-earth crater (`#dc2626`), `barricade_spikes`, 4 orbiting battle sparks, and a pulsing `icon_conflict_swords` badge. | -| **Severed / Disused Road (`broken`) & Migration (`expedition`)** | Friendly link in `state.disused_connections` (age `1–2`) or `"migration"` flow | Faded rubble segments split by `soil_crack`, `road_block_sign`, and `icon_broken_link` badge; or curved sky-blue stepping-stone trail (`#0369a1`) with `settler_wagon`. | +* **14-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, occ_deme_id, macro_band]`. +* **In-Memory `UI_SPRITES` Only (`EvoPixelArtGen`)**: `EvoPixelArtGen.ensure_all_assets()` pre-generates only in-memory `UI_SPRITES` (`ImageTexture`s) with **zero disk PNG I/O** (no `res://assets/sprites/terrain` or `res://assets/sprites/villages` file writes). + +### 2. Concentric Morphological Living Settlements (`EvoPropFloraRenderer`) +Every occupied hex cell renders a single sovereign settlement synthesized procedurally by `EvoPropFloraRenderer.build_settlement_morphology` (and inspected via `EvoWorldRenderer.get_settlement_morphology_summary`) across **three concentric architectural rings**: + +| Ring | Architectural Layer | Key Methods & Constants | Visual & Structural Synthesis | +| :--- | :--- | :--- | :--- | +| **Ring 0** | **Core Plaza & Citadel**
(`ring_0_core`) | `draw_village_plaza`,
`_draw_ring_0_citadel`,
`ARCHETYPE_STYLE_MAP` | Earthen terrace pad, cobblestone plaza, strategy mosaic ring (plus inner secondary strategy ring when `is_merged`), and a central citadel scaled across Tiers 1..5 whose silhouette reflects the dominant behavioral archetype: `forage_lodge` (`forage`), `amphitheater_dome` (`cooperate`), `twin_canopy_market` (`exchange`), `horned_war_keep` (`dispute`), `furnace_rig` (`overexploit`), `nomad_pavilion` (`migrate`), or `astronomical_spire` (`innovate`). | +| **Ring 1** | **Trait Crown**
(`ring_1_traits`) | `_draw_ring_1_trait_module`,
`TRAIT_MODULE_MAP` (`TRAIT_RING_CATALOG`) | Up to **6 radial structures** positioned around Ring 0, mapped deterministically from the deme's `biological_trait_ids` and `cultural_trait_ids`: `brood_totem` (`bio-r-strategy`), `terraced_sanctuary` (`bio-k-strategy`), `granary_silo` (`bio-digestive-spec`), `hearth_circle` (`bio-thermal-tolerance`, `cul-fire-processing`), `crystal_obelisk` (`bio-encephalization`), `monolith` (`bio-social-instinct`), `lithic_workshop` (`cul-extraction-tools`), `grove_shrine` (`cul-communal-conservation`), `caravan_depot` (`cul-interdeme-barter`), and `war_brazier` (`cul-warrior-expansion`). | +| **Ring 2** | **Defense Perimeter & Flora**
(`ring_2_defense`) | `_draw_ring_2_defense`,
`draw_prop` | Tiered perimeter fortifications for `defense_tier 0..3` (`none`, `palisade` timber stakes, `stone_wall` crenellated rampart, `fortress_bastion` stone wall with 6 corner watchtowers) surrounded by biome-reactive procedural vector flora (`tree_tropical`, `tree_savanna`, `tree_tundra`, `bush_berry`, `wildflowers`, `coastal_reeds`, degrading to `tree_stump`, `soil_crack`, and `dry_scrub` when $K / K_{\max} < 0.75$). | + +### 3. Priority-Ranked Inter-Cell Border Flows & Fronts (`EvoRoadNetworkRenderer`) +All inter-cell `HexEdgeFlow` interactions are deduplicated per canonical hex/deme pair using `EvoRoadNetworkRenderer.FLOW_PRIORITY`: + +$$\texttt{"merge" (130)} > \texttt{"war" (120)} > \texttt{"siege" (110)} > \texttt{"betrayal" (100)} > \texttt{"raid" (90)} > \texttt{"tribute" (80)} > \texttt{"pact" (70)} > \texttt{"migration" (60)} > \texttt{"collab" (50)} > \texttt{"symbiosis" (40)} > \texttt{"exchange" (30)} > \texttt{"cooperate" (20)} > \texttt{"dispute" (10)}$$ + +In addition to animated directional border flows (`draw_animated_roads`), `EvoRoadNetworkRenderer` draws persistent **shared hex border fronts** (`_get_shared_hex_border_world`) along the exact hex edge separating two neighboring cells: +* **Diplomatic Pact Border Front (`draw_pact_border_front`)**: Golden-emerald diplomatic border line with glowing boundary beacons between allied neighbors. +* **Conflict / Vendetta Border Front (`draw_conflict_border_front`)**: Crimson-iron jagged frontier with crossed lances along hostile or contested hex borders. + +### 4. 3-Tier Semantic Zoom (`Macro`, `Meso`, `Micro`) +`EvoWorldRenderer.get_semantic_zoom_tier()` and `get_zoom_tier_weights()` smoothly blend world detail across three semantic zoom levels: + +| Semantic Tier | Camera Zoom Range | Rendered Visual Layers (`EvoWorldRenderer` & `EvoWorldOverlayRenderer`) | +| :--- | :---: | :--- | +| **`Macro`** | `zoom < 0.68` | Sovereign territory strategy color wash (`0.28 * macro_w`) and bold territory border contour on occupied hexes, high-contrast lineage medallions (`_draw_macro_medallion`: strategy icon + `pop` + vital arc), and inter-cell border fronts (micro props and moving villagers are culled). | +| **`Meso`** | `0.68 <= zoom < 1.35` | Concentric morphological settlements (Ring 0/1/2), Decision Intent Crests, compact village billboards (`draw_village_billboard`), and animated directional border flows. | +| **`Micro`** | `zoom >= 1.35` | Micro diagnostic overlays (`_draw_micro_diagnostics`: `pop / K_eff`, `energy_reserves` delta badge `%+0.1f E`, and `baldwin` / `innovation` / `isolation` progress bars), radial Ring 1 trait labels (`zoom >= 1.55` or selected/hovered), and orbiting villager activity. | --- ## 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, 1-deme-per-hex invariant (`_assert_single_deme_per_cell_invariant`), 4-step micro-phase playback, inter-cell border interaction, concentric settlement morphology, and UI component: ```bash # Run the full automated test suite headlessly @@ -217,20 +219,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 cell counts (`91`, `91`, `61`), balanced 1v1 starting demes (`2` per preset, 1 per hex), 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`. | +| **4** | `_test_decision_model_and_normalization` | Verifies `normalize_decision_probabilities` ($\sum P(d) = 1.0$), $75\%/25\%$ environmental & geopolitical weight blending, `BorderCompetition` state prompt, and cached Ollama System 1 model evaluation. | +| **5** | `_test_hawk_dove_and_symbiosis` | Verifies inter-cell Hawk-Hawk $-15\%$ population loss (floor `10`), Hawk-Dove border resource annexation, and `"dispute"` flow generation, plus inter-cell Symbiosis ($1.20\times$ 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`). | -| **9** | `_test_interventions` | Verifies `apply_set_biome` (`⚡10`), `apply_adjust_altitude` (`⚡15`), `apply_adjust_carrying_capacity` (`⚡10`), and `apply_adjust_sea_level` (`⚡25`). | +| **8** | `_test_kin_merge_fission_and_macro_outcome` | Verifies inter-cell compatible same-lineage kin merging (Jaccard $\le 0.25$), empty-hex population fission migration (`-m `), 3-step displacement cascade (`evacuate_or_merge_displaced_deme`), 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`) with single-deme-per-hex displacement. | | **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. | -| **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). | +| **11** | `_test_25_generations_all_presets` | Advances all 3 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 in-memory `UI_SPRITES`, GPU terrain/ocean shaders, 3-tier semantic zoom (`Macro`, `Meso`, `Micro`), concentric settlement morphology (Ring 0/1/2), 4-step micro-phase playback & 4-pip Turn Phase Bar, dual System 1 model routing (`d3:4b` / `tev1:4b`), and modals. | +| **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 async evaluation and camera panning. | +| **14** | `_test_emergent_geopolitics_and_visual_artifacts` | Verifies inter-cell Alliance Pacts (`>= 2` turns, $+15\%$ bonus, distress aid, shared defense), Opportunistic Betrayals, Plunder Raids, Territorial Wars of Conquest (`-w `), `FLOW_PRIORITY` border flows, 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 inter-cell City Unification / Synoecism (`merge`, Tier 5 Metropolis, LLM request reduction). | | **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..9c48ce2 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..9a0fb42 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..47ed8ac 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..396af55 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_notebook_modal.png b/gh-pages/assets/img/screenshot_notebook_modal.png index 86fb0ea..0410c11 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..b84eae0 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_tree_modal.png b/gh-pages/assets/img/screenshot_tree_modal.png index c079fe1..e06ad1e 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..6ce1c52 100644 --- a/gh-pages/cognitive_engine.md +++ b/gh-pages/cognitive_engine.md @@ -1,8 +1,8 @@ # Cognitive Engine & Multi-Model Ollama AI -**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 Cognitive Architecture** (`EvoDecisionModel`, `EvoOllamaClient`, `EvoHeuristicScorer`) that evaluates each sovereign subpopulation's ecological, energetic, historical, and inter-cell border neighborhood every generation. -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**. +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 adjacent rival/kin borders (`BorderCompetition`) dynamically shape behavior while keeping LLM strategic choices dominant. To guarantee smooth 60 FPS rendering and high cache hit rates across the 4-step micro-phase loop, the cognitive engine combines **deterministic deme-to-model routing**, **coarsened 10-part ecological state signature caching**, **Phase 1 current-season & Phase 4 lookahead signature prefetching**, **6-generation historical memory & rich neighbor relational tags**, and **epoch-guarded thread safety (`_cache_epoch`)**. --- @@ -10,10 +10,10 @@ 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"] + Deme["Deme + Strategy Prior + 6-Gen Memory + 6-Hex Border Neighborhood"] --> Sig["1. Compute Coarsened 10-Part Signature\nmodel | season | biome | adequacy(0..2) | health(0..2) |\nvitality | competition(adjRivals/adjKin/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)"] + CacheCheck -- "Cache Hit" --> Blend["Blend 75% Live Ollama P(d)\n+ 25% Env & Border Geopolitical Weights"] + CacheCheck -- "Cache Miss" --> Queue["Enqueue Signature in _pending_jobs_by_sig\n(Phase 1 Current-Season & Phase 4 Lookahead Prefetch)"] 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)"] ``` @@ -44,7 +44,7 @@ Rather than evaluating every deme with a single monolithic prompt, **Evolution** ## 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): +Subpopulations experiencing equivalent strategic, ecological, and border-geopolitical conditions share a canonical, **coarsened 10-part pipe-delimited signature** (`heuristic_scorer.gd`), maximizing cross-turn and cross-deme LLM cache hit rates while distinguishing genuine strategic regime shifts: $$\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}$$ @@ -53,47 +53,36 @@ $$\text{sig} = \texttt{model} \mid \texttt{season} \mid \texttt{biome} \mid \tex | **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. | +| **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{deme}})}$ | 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`). | +| **7** | `competition_key` | `"%d:%d:%s:%d:p%d:v%d:h%d:d%d:s%d"` encoding `[adjacent_rivals, adjacent_kin, "viable" if pop >= 40 else "small", nearby_rival_flag, pact_count, vendetta_flag, horde_flag, defense_tier, surplus_flag]` | Captures inter-cell border competition (`adjacent_rivals`, `adjacent_kin`), 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. | +| **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 across shared hex borders. | --- -## Live Ollama Inference Protocol, Historical Memory & Environmental Blending (`EvoOllamaClient` & `EvoHeuristicScorer`) +## Live Ollama Inference Protocol, `BorderCompetition` Prompt & Blending (`EvoOllamaClient` & `EvoHeuristicScorer`) -### 1. Sliding 6-Generation Historical Memory & Rich Neighbor Context (`/v1/systemone`) +### 1. Compact `BorderCompetition` State Prompt, 6-Gen Memory & 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`: +* **Inter-Cell `BorderCompetition` Summary (`build_compact_system_one_state` & `build_geopolitics_summary`)**: + * Because each hex cell hosts at most 1 resident deme (`cell.deme_ids.size() <= 1`), line 2 of the compact System 1 state briefing encodes inter-cell border topology directly as `BorderCompetition: adjRivals=%d, adjKin=%d, pacts=%d, vendettas=%d`. * **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**. + * 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 `, `war@G `, `betrayal@G `, `pact@G `, `fortify@G `, `merge@G `, `tribute@G `, `siege@G `), plus `vendettas= ` and `horde_nearby`. * **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). + * In the 4-line state briefing, each of the top 3 passable neighbor hexes formats its resident deme as ` : : :def ` (`rel_tag` $\in \{\texttt{kin}, \texttt{ally}, \texttt{vendetta}, \texttt{rival}\}$, `posture_tag` $\in \{\texttt{martial}, \texttt{forage}, \texttt{cooperate}, \dots\}$, `strength_tag` $\in \{\texttt{stronger}, \texttt{weaker}, \texttt{equal}\}$, and `def<0..3>`). * **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) + Deme "Eastern Savanna Hominids": pop=140 (thriving), reserves=180, delta=+8, def=Lv2, merged=1 | BorderCompetition: adjRivals=1, adjKin=0, pacts=1, vendettas=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`). +* **`geopolitics` (`build_geopolitics_summary`)**: Real-time diplomatic, military, and urban context (`active_pacts`, `active_vendettas`, `adjacent_rivals`, `adjacent_kin`, `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`) @@ -101,13 +90,13 @@ To keep neural evaluations grounded in biophysical reality while preserving high $$\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 +### 3. Dual-Stage Prefetching (`prefetch_for_state`), Non-Blocking Worker Pool & Epoch Guard +* **Phase 1 Current-Season & Phase 4 Lookahead Prefetching (`prefetch_for_state(state, use_current_season)`)**: + * **Phase 1 (`step_phase_1_environment`)**: Immediately after climate and biomass regeneration complete, `prefetch_for_state(state, true)` enqueues any uncached signatures for the **current season** so worker threads begin scoring while the Phase 1 visual beat plays. + * **Phase 4 (`step_phase_4_demography_milestones`)**: At the end of the generation, `prefetch_for_state(state, false)` temporarily flips `state.season` to the upcoming turn's season and pre-warms up to **8 unique representative signatures** for the next generation. * **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. +* **Epoch-Guarded Cache Invalidation (`_cache_epoch`)**: Calling `clear_caches()` increments `_cache_epoch` under `_mutex`; in-flight worker batches verify `job_epoch == _cache_epoch` before storing results in `_live_signature_cache`. +* **Live Intra-Turn Hot-Swap**: Whenever a background batch completes, `_completed_revision` increments and `EvoMainController` calls `EvoDecisionModel.apply_completed_live_results` on the next frame. --- @@ -116,30 +105,17 @@ where $P_{\text{env}}(d)$ modulates the deme's intrinsic `strategy_traits_prior` ### 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). +* **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` eligible only when an adjacent non-pact rival deme borders the cell). * **`dec-exchange-symbiosis` and `dec-explore-innovate`** require cultural capacity (`bio-encephalization` OR `bio-social-instinct`). ### 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)`: - -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. +Raw LLM probability scores returned by Ollama `/v1/systemone` are normalized via `EvoHeuristicScorer.normalize_decision_probabilities(raw_scores, eligible_ids)` so that all eligible probabilities are non-negative, snapped to `0.0001`, and sum to exactly `1.0000`. --- ## Startup Ollama Verification, Blocking Offline Notice & Model Download -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`). - -* **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`. +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 (`EvoOllamaClient.filter_decision_models_from_tags`). + +* **Ollama Offline Blocking Popup (`show_ollama_offline_popup`)**: If Ollama is offline or unreachable, **Evolution** displays a full-screen blocking warning popup (`_missing_models_modal`) that disables simulation playback and exits cleanly when dismissed. +* **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 modal offers automatic background model download (`POST /api/pull`) or application exit. diff --git a/gh-pages/index.md b/gh-pages/index.md index 577cbf5..1468b75 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$) on a high-definition 2.5D procedural vector and GPU-shader hexagonal ecosystem with concentric morphological living settlements and a 3-tier semantic zoom lens—while sovereign local subpopulations (demes, strictly one per hex cell) autonomously adapt, migrate, cooperate, innovate, or diverge into new species across shared hexagonal borders.@@ -44,8 +44,8 @@ hide:
-Dual-Model Cognitive Engine & Memory
-Deterministically distributes rival factions across primary (
+d3:4b) and secondary (tev1:4b) Ollama System 1 models with a 6-generation historical memory digest, rich neighbor relational tags, and $75\%/25\%$ environmental blending.Dual-Model Cognitive Engine & 4-Phase Playback
+Deterministically distributes rival factions across primary (
d3:4b) and secondary (tev1:4b) Ollama System 1 models withBorderCompetitionstate prompts, 6-generation historical memory, $75\%/25\%$ environmental blending, and a 4-pip Turn Phase Bar (1. Env,2. Mind,3. Clash,4. Lineage).@@ -58,15 +58,15 @@ hide:@@ -104,22 +104,21 @@ At Generation 0, every scenario sets up a head-to-head evolutionary experiment b --- -## How the Generational Loop Works +## How the Generational Loop & 4-Step Micro-Phase Playback Work -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$, metabolic expenditure, and demography). At `1x` speed, each generation unfolds sequentially across **4 visual micro-phases** tracked in the top-right **4-pip Turn Phase Bar** (`1. Env`, `2. Mind`, `3. Clash`, `4. Lineage`), while `2x` speed and headless tests execute all 4 steps atomically via `EvoTurnEngine.advance_generation`: ```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)"] - 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"] + Env["1. Env (step_phase_1_environment)\n• Flip Season & Glacial Tides\n• 3-Step Submergence Cascade\n• Regenerate K & Biomass R\n• Phase 1 Ollama Prefetch"] --> Cog["2. Mind (step_phase_2_decisions)\n• BorderCompetition State Prompt\n• 6-Gen Memory & Neighbor Tags\n• System 1 Ollama (d3:4b / tev1:4b)\n• 75% LLM / 25% Env Blend"] + Cog --> Bio["3. Clash (step_phase_3_ecology_borders)\n• Aggregate Effective Vector v_eff\n• Inter-Cell Hawk-Dove & Trade\n• Pacts, Vendettas, Tribute & Raids\n• Ring 2 Defense Fortifications"] + Bio --> Mile["4. Lineage (step_phase_4_demography_milestones)\n• Metabolism, Growth & Invasions\n• Empty-Hex Fission & Synoecism\n• Speciation, Baldwin & Innovation\n• Next-Turn Lookahead Prefetch"] ``` -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$. -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). -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*). +1. **`1. Env` — Environmental Phase (`step_phase_1_environment` / `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, displaces submerged demes via the **3-step displacement cascade** (`evacuate_or_merge_displaced_deme`: empty passable neighbor evacuation $\to$ allied/kin refugee merge $\to$ extinction), regenerates hex carrying capacity $K$ and resources $R$ (visualized as expanding emerald/amber pulse rings), and triggers Phase 1 current-season Ollama signature prefetching (`prefetch_for_state(state, true)`). +2. **`2. Mind` — Cognitive Decision Phase (`step_phase_2_decisions` / `EvoDecisionModel`)**: Evaluates each deme via `/v1/systemone` with `BorderCompetition: adjRivals, adjKin, pacts, vendettas`, its intrinsic strategy traits prior (`strategy_traits_prior`), a **6-generation historical memory digest**, and **rich neighbor relational labels**—routing rival factions deterministically between primary (`d3:4b`) and secondary (`tev1:4b`) Ollama System 1 models, blending neural predictions (**$75\%$**) with environmental and geopolitical weights (**$25\%$**), and displaying Decision Intent Crests with dashed golden intent tethers to occupied neighbor hexes. +3. **`3. Clash` — Ecology & Inter-Cell Borders Phase (`step_phase_3_ecology_borders` / `EvoEcologyInteraction`)**: Computes each deme's effective 6-axis vector $\mathbf{v}_{\text{eff}}$, resolves **inter-cell Hawk-Dove border disputes**, reciprocal border symbiosis and **complementary inter-biome/cross-cultural trade** (`1.20x` to `1.35x` caloric bonus and horizontal cultural diffusion), **Alliance Pacts & Defensive Coalitions**, **Opportunistic Betrayals & Multi-Turn Vendettas**, **Plunder Raids & Tribute Appeasement**, **Ring 2 Multi-Tier Defense Fortifications** (`defense_tier: 0..3`), and habitat degradation (*Tragedy of the Commons* when $K < 0.60 \cdot K_{\max}$), highlighted by pulsing crimson/emerald clash rings at shared hex border midpoints. +4. **`4. Lineage` — Demography, Mergers & Milestones Phase (`step_phase_4_demography_milestones` / `EvoDemographyMigration` & `EvoMilestonePhase`)**: Resolves metabolic expenditure & wall upkeep, demographic growth/starvation (with floating `+Pop / -Pop` badges), **Territorial Wars & Fortified Sieges** across borders, **empty-hex population fission** (or adjacent kin reinforcement), **inter-cell kin merges** (`divergence <= 0.25`) and **City Unification / Synoecism** (`merge` into a **Tier 5 Unified Metropolis**), synthesizes thermodynamically balanced emergent traits (*Allopatric Speciation*, *The Baldwin Effect*, *Cumulative Cultural Innovation*), checks global macro-outcomes, and prefetches next-turn lookahead Ollama signatures (`prefetch_for_state(state, false)`). --- @@ -175,14 +174,14 @@ flowchart LR | 4 | **Desert Dust (`arid_steppe`)** | `10` | Transform target hexes into `arid_steppe` ($K_{\max} = 280$, `regen = 0.12`, $T = 0.65$, $H = 0.20$). | | 5 | **Boreal Crystal (`tundra`)** | `10` | Transform target hexes into `tundra` ($K_{\max} = 240$, `regen = 0.10`, $T = -0.55$, $H = 0.35$). | | 6 | **Peak Chisel (`raise`, `+0.18`)** | `15` | Elevate terrain altitude by $+0.18$; emerges ocean hexes into coastal land bridges or uplifts `high_mountain` ($\ge 0.78$). | -| 7 | **Valley Pickaxe (`lower`, `-0.18`)** | `15` | Depress terrain altitude by $-0.18$; submerges hexes below `sea_level` into `ocean` and displaces resident demes. | +| 7 | **Valley Pickaxe (`lower`, `-0.18`)** | `15` | Depress terrain altitude by $-0.18$; submerges hexes below `sea_level` into `ocean` and displaces resident demes via the 3-step cascade. | | 8 | **Golden Watering Can (`enrich`)** | `10` | Boost hex carrying capacity $K$ by $+25\%$ (up to $K_{\max}$) and replenish available biomass $R$. | | 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`). | +| Space | **Play / Pause** | — | Toggle automatic generational progression (`1x` = 4-step micro-phase playback at `0.375s/phase`, 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. | +| Mouse Wheel / +- | **Zoom Camera** | — | Cursor-pivoted zoom (`0.28x` to `3.00x`) across `Macro` (`< 0.68`), `Meso` (`0.68–1.35`), and `Micro` (`>= 1.35`) semantic zoom tiers. | | Escape | **Close / Reset** | — | Close active modal/popup window or return active brush to `Naturalist's Loupe` (1). | --- @@ -200,7 +199,7 @@ flowchart LR Read →-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.
+Sovereign Hex Borders, Fortifications & Synoecism
+Each hex cell hosts at most 1 resident deme (
cell.deme_ids.size() <= 1). Settlements build Concentric Morphological Settlements (Ring 0 Citadel, Ring 1 Trait Crown, Ring 2 Defense Perimeter), clash or trade across shared hex borders, resolve displacement via a 3-step cascade, and unite into Tier 5 Metropolises.-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.
+3-Tier Semantic Zoom & God-Mode Tools
+Zoom seamlessly across
Macro(`< 0.68`),Meso(`0.68–1.35`), andMicro(`>= 1.35`) semantic tiers while wielding a 10-slot brush dock to sculpt biomes, raise tectonic ridges, flood glacial archipelagos, or enrich carrying capacity $K$.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 axial hex geometry, the 1-deme-per-hex territorial invariant, the 7 biomes, seasonal & glacial cycles, and the 3 scientific scenarios.
@@ -220,7 +219,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 the 4 micro-phases: inter-cell Hawk-Dove border disputes, symbiosis, $K$ degradation, demography, 3-step displacement cascade, and speciation.
@@ -230,7 +229,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 (
@@ -240,7 +239,7 @@ flowchart LR Read →d3:4b&tev1:4b),BorderCompetitionstate prompts, 10-part state signatures, Phase 1 & Phase 4 prefetching, and startup verification.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 4-pip Turn Phase Bar, 3-Tier Semantic Zoom, Concentric Living Settlements, Scroll Inspector, Map Lenses, Naturalist's Notebook, Lineage Tree, and Forge.
@@ -250,7 +249,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.
+Procedural 2.5D GPU-shader hex rendering, Concentric Morphological Settlements (Ring 0/1/2), priority-ranked border flows, i18n, and automated test suite.
diff --git a/gh-pages/thermodynamics_catalog.md b/gh-pages/thermodynamics_catalog.md index ab670c1..26c8e17 100644 --- a/gh-pages/thermodynamics_catalog.md +++ b/gh-pages/thermodynamics_catalog.md @@ -30,8 +30,8 @@ By default, every scenario starts with `DEFAULT_STRATEGY_A_ID = "strat-cooperati | :--- | :--- | :---: | :--- | :--- | :---: | :--- | | `strat-cooperation` | 🤝 **Cooperative Alliance**
*Alianza Cooperativa* | `#10B981` | `bio-k-strategy`
`bio-social-instinct` | `cul-communal-conservation` | `(+0.55, +0.70, +0.75, +0.15, +1.00, 1.00)` | **Ecological Stewardship, Stability & Pacts**: Fosters maximum social cohesion (`+1.00`), communal habitat regeneration (`+0.75` eco), formal Alliance Pacts, and $K$-selected demographic buffering. | | `strat-expansion` | 🧭 **Expansionist Pioneers**
*Pioneros Expansionistas* | `#3B82F6` | `bio-r-strategy`
`bio-thermal-tolerance`
`bio-social-instinct` | `cul-fire-processing` | `(+0.10, +0.95, -0.35, +0.90, +0.70, 1.00)` | **Rapid Dispersal & Barrier Crossing**: Combines density-dependent $r$-selected fecundity ($\times 1.28$ at low crowding) with high mobility (`+0.90`) and cooking efficiency (`+0.10` net cost) to colonize mountains and islands. | -| `strat-trade` | ⚖️ **Merchant League**
*Liga Mercantil* | `#F59E0B` | `bio-k-strategy`
`bio-encephalization` | `cul-interdeme-barter` | `(+1.00, +1.00, +0.30, +0.55, +1.00, 1.00)` | **Commercial Symbiosis & Cultural Hub**: High-cognition diplomatic network that unlocks $+20\%$ reciprocal symbiosis surpluses, Alliance Pacts, sustainable ecology (`+0.30`), and rapid horizontal cultural diffusion. | -| `strat-war` | ⚔️ **Conquering Horde**
*Horda Conquistadora* | `#EF4444` | `bio-r-strategy`
`bio-social-instinct` | `cul-warrior-expansion`
`cul-extraction-tools` | `(+0.75, +1.00, -0.75, +0.90, +1.00, 1.00)` | **Martial Dominance, Raids & Conquest**: Wages territorial wars of conquest, launches cross-border plunder raids, and dominates Hawk-Dove disputes with maximum extraction (`+1.00`) and mobility (`+0.90`), at severe ecological cost (`-0.75`). | +| `strat-trade` | ⚖️ **Merchant League**
*Liga Mercantil* | `#F59E0B` | `bio-k-strategy`
`bio-encephalization` | `cul-interdeme-barter` | `(+1.00, +1.00, +0.30, +0.55, +1.00, 1.00)` | **Commercial Symbiosis & Cultural Hub**: High-cognition diplomatic network that unlocks $+20\%$ to $+35\%$ inter-cell border symbiosis surpluses, Alliance Pacts, sustainable ecology (`+0.30`), and rapid horizontal cultural diffusion. | +| `strat-war` | ⚔️ **Conquering Horde**
*Horda Conquistadora* | `#EF4444` | `bio-r-strategy`
`bio-social-instinct` | `cul-warrior-expansion`
`cul-extraction-tools` | `(+0.75, +1.00, -0.75, +0.90, +1.00, 1.00)` | **Martial Dominance, Raids & Conquest**: Wages territorial wars of conquest, launches cross-border plunder raids, and dominates inter-cell Hawk-Dove border disputes with maximum extraction (`+1.00`) and mobility (`+0.90`), at severe ecological cost (`-0.75`). | | `strat-innovation` | 🔬 **Innovator Technocrats**
*Tecnócratas Innovadores* | `#A855F7` | `bio-encephalization`
`bio-digestive-spec` | `cul-fire-processing`
`cul-extraction-tools` | `(+0.70, +1.00, -0.45, +0.55, +0.80, 1.00)` | **Cognitive & Technological Acceleration**: Pairs neocortical expansion and omnivorous digestion with thermal processing and toolkits to maximize extraction (`+1.00`) and cumulative innovation. | --- @@ -46,8 +46,8 @@ Each vector consists of six floating-point axes rounded to $4$ decimal places (` | **2** | `extraction_efficiency` | `extractionEfficiency` | $[-1.0, +1.0]$ | **Biomass Harvesting Yield**: Scales a deme's caloric demand $D_i = P_i \cdot 0.50 \cdot (1.0 + 0.60 \cdot v_{\text{eff}}.\text{extraction\_efficiency})$ when harvesting hex resources $R$. | | **3** | `ecological_impact` | `ecologicalImpact` | $[-1.0, +1.0]$ | **Habitat $K$ Footprint**: Negative values degrade local carrying capacity $K$ (`K * (1 + 0.18 * sum_eco_impact)`), whereas positive values (`> +0.10`, e.g., `cul-communal-conservation`) actively regenerate $K$ (`+5% * sum_eco_impact`). | | **4** | `spatial_mobility` | `spatialMobility` | $[-1.0, +1.0]$ | **Dispersal & Barrier Traversal**: Determines maximum traversable elevation ($\text{altitude} - 0.50 \le v_{\text{eff}}.\text{spatial\_mobility}$) and buffers extreme thermal metabolic penalties ($|T_{\text{cell}}|$). | -| **5** | `social_cohesion` | `socialCohesion` | $[-1.0, +1.0]$ | **Coordination & Altruism**: Governs Dove resistance against Hawk resource capture, and unlocks horizontal cultural absorption when $v_{\text{eff}}.\text{social\_cohesion} \ge \text{trait.transmission\_threshold}$. | -| **6** | `transmission_threshold` | `transmissionThreshold` | $[0.0, +1.0]$ | **Cognitive & Social Complexity**: Minimum receiver `social_cohesion` required to absorb a cultural trait via Symbiosis, and determines which complex cultural trait is lost first during a demographic bottleneck ($P < 50$ or $-50\%$ pop loss). | +| **5** | `social_cohesion` | `socialCohesion` | $[-1.0, +1.0]$ | **Coordination & Altruism**: Governs Dove resistance against inter-cell Hawk border resource capture, and unlocks horizontal cultural absorption across shared hex borders when $v_{\text{eff}}.\text{social\_cohesion} \ge \text{trait.transmission\_threshold}$. | +| **6** | `transmission_threshold` | `transmissionThreshold` | $[0.0, +1.0]$ | **Cognitive & Social Complexity**: Minimum receiver `social_cohesion` required to absorb a cultural trait via Inter-Cell Symbiosis, and determines which complex cultural trait is lost first during a demographic bottleneck ($P < 50$ or $-50\%$ pop loss). | --- @@ -98,51 +98,51 @@ $$\mathbf{v}_{\text{eff}}(\text{deme}) = \text{clamp}_{[-1.5, 1.5]}\left(\sum_{b --- -## Complete Biological Traits Catalog (`bio-*`) +## Complete Biological Traits Catalog (`bio-*`) & Ring 1 Modules -Biological traits represent genetically heritable life-history strategies, physiological adaptations, and neuroanatomical structures defined in `EvoCatalog.create_base_traits_catalog`: +Biological traits represent genetically heritable life-history strategies, physiological adaptations, and neuroanatomical structures defined in `EvoCatalog.create_base_traits_catalog`, each manifesting visually in the settlement's **Ring 1 Trait Crown** (`EvoPropFloraRenderer.TRAIT_MODULE_MAP`): -| 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-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. | -| `bio-social-instinct` | **Instinctive Sociability (Gregariousness)**
*Sociabilidad Instintiva (Gregarismo)* | `+0.20` | `+0.20` | `+0.05` | `+0.10` | `+0.60` | `0.45` | *None* | **Yes** | Neuroendocrine predisposition toward kin altruism (`+0.60` social cohesion) at moderate energetic cost (`+0.20`); unlocks cultural transmission and symbiosis. | +| Trait ID | Name (EN / ES) | Ring 1 Module | Cost | Extract | Eco | Mobility | Cohesion | Threshold | Exclusive With | Enables Culture | Biophysical & Evolutionary Role | +| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :--- | +| `bio-r-strategy` | **r-Strategy (Prolific)**
*Estrategia r (Prolífica)* | `brood_totem` | `-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)* | `terraced_sanctuary` | `+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-digestive-spec` | **Omnivorous Digestive Specialization**
*Especialización Digestiva Omnívora* | `granary_silo` | `+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* | `hearth_circle` | `+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)* | `crystal_obelisk` | `+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. | +| `bio-social-instinct` | **Instinctive Sociability (Gregariousness)**
*Sociabilidad Instintiva (Gregarismo)* | `monolith` | `+0.20` | `+0.20` | `+0.05` | `+0.10` | `+0.60` | `0.45` | *None* | **Yes** | Neuroendocrine predisposition toward kin altruism (`+0.60` social cohesion) at moderate energetic cost (`+0.20`); unlocks cultural transmission and symbiosis. | !!! warning "Bidirectional Mutual Exclusion (`r` vs. `K` Strategy)" `bio-r-strategy` and `bio-k-strategy` are mutually exclusive life-history poles. Injecting `bio-r-strategy` into a village via `EvoCatalog.inject_trait_into_deme` automatically strips `bio-k-strategy` (and vice versa). Furthermore, if removing a biological trait leaves a deme with neither `bio-encephalization` nor `bio-social-instinct`, all active cultural traditions are cleared because the population loses its cognitive/social substrate (`enables_cultural_transmission`). --- -## Complete Cultural Traditions Catalog (`cul-*`) +## Complete Cultural Traditions Catalog (`cul-*`) & Ring 1 Modules -Cultural traditions require at least one biological trait with `enables_cultural_transmission = true` (`bio-encephalization` or `bio-social-instinct`). Unlike genetic traits, cultural traditions can diffuse **horizontally** between cohabiting or neighboring demes during Symbiosis and can be **lost** during demographic bottlenecks: +Cultural traditions require at least one biological trait with `enables_cultural_transmission = true` (`bio-encephalization` or `bio-social-instinct`). Unlike genetic traits, cultural traditions can diffuse **horizontally** across shared hex borders between trading demes during Symbiosis and can be **lost** during demographic bottlenecks: -| Trait ID | Name (EN / ES) | Cost | Extract | Eco | Mobility | Cohesion | Threshold | Biophysical & Socio-Ecological Role | -| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | -| `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-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. | +| Trait ID | Name (EN / ES) | Ring 1 Module | Cost | Extract | Eco | Mobility | Cohesion | Threshold | Biophysical & Socio-Ecological Role | +| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :--- | +| `cul-fire-processing` | **Fire & Thermal Food Processing**
*Fuego y Procesado Térmico de Alimentos* | `hearth_circle` | `-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* | `lithic_workshop` | `+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* | `grove_shrine` | `+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-interdeme-barter` | **Inter-Deme Barter Networks**
*Redes de Trueque Inter-Deme* | `caravan_depot` | `+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* | `war_brazier` | `+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 inter-cell Hawk-Dove dominance at severe metabolic (`+0.45`) and ecological (`-0.35`) cost. | --- -## Complete Behavioral Decisions Catalog (`dec-*`) +## Complete Behavioral Decisions Catalog (`dec-*`) & Ring 0 Citadel Styles -Every generation, the cognitive engine assigns a normalized probability distribution $P(d)$ ($\sum_{d} P(d) = 1.0$) over the eligible decisions in `EvoCatalog.create_base_decisions_catalog`: +Every generation, the cognitive engine assigns a normalized probability distribution $P(d)$ ($\sum_{d} P(d) = 1.0$) over the eligible decisions in `EvoCatalog.create_base_decisions_catalog`, shaping the settlement's **Ring 0 Citadel** (`EvoPropFloraRenderer.ARCHETYPE_STYLE_MAP`): -| Decision ID | Archetype | Name (EN / ES) | Cost | Extract | Eco | Mobility | Cohesion | Threshold | Required Traits (Any) | Behavioral Profile & Simulation Effect | +| Decision ID | Archetype & Ring 0 Citadel | Name (EN / ES) | Cost | Extract | Eco | Mobility | Cohesion | Threshold | Required Traits (Any) | Behavioral Profile & Simulation Effect | | :--- | :---: | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | :--- | -| `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-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`. | +| `dec-conservative-forage` | `forage`
(`forage_lodge`) | **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`
(`furnace_rig`) | **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`
(`nomad_pavilion`) | **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 empty-hex population fission or martial territorial invasion. | +| `dec-cooperate-store` | `cooperate`
(`amphitheater_dome`) | **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**, **Ring 2 Fortifications** (`defense_tier` 1–3), and **City Unification (Synoecism)**. | +| `dec-exchange-symbiosis` | `exchange`
(`twin_canopy_market`) | **Exchange / Symbiosis**
*Intercambio / Simbiosis* | `+0.35` | `+0.50` | `+0.20` | `+0.20` | `+0.60` | `0.45` | `bio-encephalization` OR `bio-social-instinct` | Unlocks **$1.20\times\text{–}1.35\times$ inter-cell complementary trade surplus** ($+12\%$ inter-biome, $+8\%$ cross-cultural), horizontal cultural trait transmission across borders, **Tribute Appeasement**, and formal **Alliance Pacts** / **Synoecism**. | +| `dec-territorial-dispute` | `dispute`
(`horned_war_keep`) | **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$); annexes adjacent Dove border resources, triggers **Plunder Raids** and **Wars of Conquest**, or causes inter-cell Hawk-Hawk border clashes ($-15\%$ pop, $\times 1.25$ metabolic cost). | +| `dec-explore-innovate` | `innovate`
(`astronomical_spire`) | **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..ada5e11 100644 --- a/gh-pages/turn_iterations_mechanics.md +++ b/gh-pages/turn_iterations_mechanics.md @@ -1,261 +1,200 @@ -# Generational Loop & Biophysical Mechanics +# Generational Loop, 4-Step Micro-Phases & 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`, which advances the simulation by one discrete generation (half-year seasonal step: `year = generation / 2 + 1`). Each generation executes a strictly ordered **4-phase pipeline**—supporting both **sequential 4-step micro-phase playback** (`step_phase_1_environment` $\to$ `step_phase_2_decisions` $\to$ `step_phase_3_ecology_borders` $\to$ `step_phase_4_demography_milestones` at `1x` speed) and **single-call full-generation execution** (`advance_generation` at `2x` speed or headless mode)—translating probabilistic deme intentions into deterministic ecological, border-geopolitical, demographic, and phylogenetic outcomes on a **strictly 1-deme-per-hex** (`cell.deme_ids.size() <= 1`) grid. --- -## High-Level Execution Pipeline +## High-Level Execution Pipeline & Micro-Phase Playback ```mermaid 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 Env as 1. Env (EvoEnvPhase) + participant Cog as 2. Mind (EvoDecisionModel) + participant Eco as 3. Clash (EvoEcologyInteraction) + participant Dem as 4. Lineage (EvoDemographyMigration + EvoMilestonePhase) - Main->>Turn: advance_generation(world_state, lang) + Main->>Turn: step_phase_1_environment(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 - 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 + Note over Env: Increment gen, flip season, glacial sea cycle,
3-step submergence cascade, regen K & R, +15 mana,
prefetch_for_state(state, true) + Main->>Turn: step_phase_2_decisions(state, lang) + Turn->>Cog: evaluate_all_demes(state, lang, false) + Note over Cog: BorderCompetition prompt, 10-part signatures,
6-gen memory & neighbor tags, System 1 (75/25 blend) + Main->>Turn: step_phase_3_ecology_borders(state, lang) + Turn->>Eco: run_ecology_and_borders_step(state, lang) + Note over Eco: Effective vector v_eff, Inter-Cell Hawk-Dove,
Extraction, Inter-Cell Trade (1.20x–1.35x), K Degrade,
Pacts, Coalitions, Vendettas, Tribute, Raids & Fortify + Main->>Turn: step_phase_4_demography_milestones(state, lang) + Turn->>Dem: run_demography_and_migration_step + run_milestone_phase + Note over Dem: Metabolism & Upkeep, r/K Growth, Border Wars & Sieges,
3-Step Displacement, Empty-Hex Fission, Inter-Cell Merges,
Speciation / Baldwin / Innovation, prefetch_for_state(state, false) Turn-->>Main: Return { generation, new_entries, has_milestone, has_macro_outcome } ``` +### In-Flight Micro-Phase Flushing (`complete_in_flight_turn` & `flush_pending_micro_phases`) +When playing at `1x` speed (`0.375s` per micro-phase), the top-right **4-pip Turn Phase Bar** (`1. Env`, `2. Mind`, `3. Clash`, `4. Lineage`) and `EvoWorldOverlayRenderer._draw_micro_phase_highlights` visualize each micro-phase in real time. If the player clicks `+1 Step`, switches to `2x` speed, applies a God-mode brush, or exports JSON while a turn is mid-flight (`_active_micro_phase in 1..3`), `EvoTurnEngine.flush_pending_micro_phases` / `complete_in_flight_turn` deterministically completes the remaining micro-phases of the current generation before proceeding. + --- -## Stage 1 & 2: Environment & Cognitive Scoring +## Micro-Phase 1 & 2: Environment (`1. Env`) & Cognitive Scoring (`2. Mind`) -1. **Environmental Phase (`EvoEnvPhase.run_environmental_phase`)**: +1. **Micro-Phase 1 — Environmental Phase (`step_phase_1_environment` / `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`. + * Applies glacial sea-level cycles (every 8 generations), coastal flooding/emergence, and resolves any deme on a newly submerged ocean hex via the **3-Step Displacement Cascade** (`EvoDemographyMigration.evacuate_or_merge_displaced_deme`: empty passable neighbor evacuation with $-20\%$ reserves $\to$ allied/kin neighbor refugee merge $\to$ drowning extinction). + * Applies passive $K$ recovery ($+5\%$ normal or $+0.5\%$ after a Tragedy of the Commons), seasonal biomass $R$ regeneration (highlighted on the map by expanding emerald/amber resource pulse rings), and `+15` player Selective Pressure ("Mana") points. + * Invalidates state caches and triggers **Phase 1 current-season Ollama signature prefetching** (`EvoDecisionModel.prefetch_for_state(state, true)`). +2. **Micro-Phase 2 — Cognitive Phase (`step_phase_2_decisions` / `EvoDecisionModel.evaluate_all_demes`)**: + * Evaluates every surviving deme via the Dual-Model System 1 Ollama engine (`/v1/systemone`) with its `BorderCompetition: adjRivals, adjKin, pacts, vendettas` prompt, 6-generation historical memory digest, and rich neighbor relational labels, blended (**$75\%$ live LLM / $25\%$ environmental & geopolitical pressures**). + * 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` (highlighted on the map by Decision Intent Crests and dashed golden intent tethers to occupied neighbor hexes). --- -## Stage 3A: Ecology, Extraction, Diplomacy, Raids & Fortifications (`EvoEcologyInteraction`) +## Micro-Phase 3 (`3. Clash`): Ecology, Inter-Cell Border Disputes, Trade, Diplomacy & 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: +Once every deme has its probability distribution $P(d)$ and effective 6-axis vector $\mathbf{v}_{\text{eff}}$, `EvoBiophysPhase.run_ecology_and_borders_step` invokes `EvoEcologyInteraction.run_resource_and_symbiosis_phase` across the 1-deme-per-hex grid 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`: +Each hexagon's single resident deme $i$ (`cell.get_resident_deme_id()`) 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`): +Each resident deme is classified by summing its 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`): +### Step 3: Inter-Cell Hawk-Dove Border Conflict +Because each hex cell hosts at most 1 resident deme (`cell.deme_ids.size() <= 1`), Hawk-Dove resource competition occurs **across shared hex borders between adjacent rival demes** (`d1.lineage_id != d2.lineage_id` and not in an active Alliance Pact): ```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%)"] + Border["Adjacent Rival Demes Across Shared Hex Border\n(d1.lineage_id != d2.lineage_id & not in Pact)"] --> CountHawks{"Border Postures\nP('dispute') >= 0.25?"} + CountHawks -- "Both are Hawks\n(Hawk vs. Hawk)" --> HH["Escalated Border Skirmish\n• Both Hawks lose 15% pop (floor 10)\n• Both Hawks incur ×1.25 metabolic penalty\n• Recompute D & spawn 'dispute' border flow (0.75)"] + CountHawks -- "1 Hawk & 1 Dove\n(Hawk vs. Dove)" --> HD["Cross-Border Resource Annexation\n• M_capture = 0.25 · (1 + max(0, S_hawk − S_dove)) / (1 + 0.35 · def_dove)\n• Hawk annexes B_actual = min(0.28 · R_dove_cell, min(D_dove, D_hawk · M_capture))\n• Deducted from Dove's hex R & demand; added to Hawk's annexed energy\n• Spawn 'dispute' border flow (0.65)"] + CountHawks -- "Both are Doves\n(Dove vs. Dove)" --> DD["Peaceful Border Coexistence\n• Eligible for Inter-Cell Symbiotic Trade (1.20x–1.35x)"] ``` -* **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): +* **Case A — Inter-Cell Hawk vs. Hawk (`d1_hawk` and `d2_hawk`)**: + * Both bordering Hawks suffer an immediate **$15\%$ demographic casualty loss** (with a minimum floor of $10$ individuals): $$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}})$. + * Both Hawks are penalized with a **$\times 1.25$ metabolic expenditure multiplier** (`hawk_penalty_by_deme[hawk.id] = 1.25`) for the remainder of the turn, recompute their caloric demand $D_{\text{hawk}}$, and spawn a `"dispute"` `HexEdgeFlow` (`intensity = 0.75`) across their shared border. +* **Case B — Inter-Cell Hawk vs. Dove (`1` Hawk, `1` Dove across a shared border)**: + * The Hawk intimidates the neighboring Dove across the border and annexes a share of the Dove's border resources, mitigated by the Dove's Ring 2 `defense_tier`: + $$M_{\text{capture}} = \frac{0.25 \times \Big(1.0 + \max\big(0.0,\ v_{\text{eff, hawk}}.\text{social\_cohesion} - v_{\text{eff, dove}}.\text{social\_cohesion}\big)\Big)}{1.0 + 0.35 \cdot \text{dove.defense\_tier}}$$ + $$B_{\text{actual}} = \min\Big(0.28 \cdot R_{\text{dove\_cell}},\ \min(D_{\text{dove}},\ D_{\text{hawk}} \times M_{\text{capture}})\Big)$$ + * $B_{\text{actual}}$ is deducted directly from the Dove's hex `current_resources` ($R_{\text{dove\_cell}}$) and demand $D_{\text{dove}}$, added to the Hawk's `annexed_energy_by_deme`, and spawns a `"dispute"` `HexEdgeFlow` (`intensity = 0.65`). ### 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}}$: +Each resident deme harvests its demand $D_i$ from its own cell's available resources $R_{\text{cell}}$ (plus any cross-border energy annexed from adjacent Doves): -$$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}$$ +$$E_{\text{local}, i} = \min(R_{\text{cell}},\ D_i), \qquad E_i = E_{\text{local}, i} + E_{\text{annexed}, i}$$ -$$R_{\text{cell}} \leftarrow \max\big(0.0,\ \text{snapped}(R_{\text{cell}} - E_{\text{total}},\ 0.01)\big)$$ +$$R_{\text{cell}} \leftarrow \max\big(0.0,\ \text{snapped}(R_{\text{cell}} - E_{\text{local}, i},\ 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`): +### Step 5: Inter-Cell Symbiotic Trade & Horizontal Cultural Diffusion +Symbiotic trade operates across shared hex borders between adjacent passable cells (`altitude <= 0.85`) occupied by non-vendetta demes where both are Exchangers ($P(\text{"exchange"}) \ge 0.20$, or hybrid cooperative trade where one has $P(\text{"exchange"}) \ge 0.20$ and both have $P(\text{"cooperate"}) + P(\text{"exchange"}) \ge 0.32$): -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: +1. **Inter-Cell Caloric Trade Surplus (`1.20x` to `1.35x`)**: + * Border symbiosis boosts extracted energy by a base **$1.20\times$ (`+20%`)**, and rewards ecological and cultural complementarity up to **$1.35\times$ (`+35%`)**: $$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: +2. **Active Trade Road Flow**: Spawns an `"exchange"` `HexEdgeFlow` (`intensity = 0.60`) across the shared hex border, rendered as a cobblestone trade road or wooden bridge with caravan carts. +3. **Horizontal Cultural Diffusion Across Borders**: For every ordered pair `(donor, receiver)` of trading neighbors: * 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$: +After extraction, the resident deme's ecological impact $\Sigma_{\text{eco}} = 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$): + Triggered when extraction exceeds natural seasonal regeneration ($E_{\text{local}} > K_{\text{cell}} \cdot r_{\text{regen}}$ or $R_{\text{cell}} \le 0$ with $D_i > K_{\text{cell}} \cdot r_{\text{regen}}$) **AND** either $\Sigma_{\text{eco}} < -0.15$ or ($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\%$). + * **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: + Conversely, if degradation did not trigger, the resident deme is not overexploiting ($P(\text{"overexploit"}) < 0.30$), and $\Sigma_{\text{eco}} > +0.10$ (e.g., 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`: +### Step 7: Inter-Cell Alliance Pacts, Defensive Coalitions, Opportunistic Betrayals & Vendettas (`_run_pacts_and_betrayals`) +Next, `EvoEcologyInteraction` evaluates all 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. + * Each turn, a negative vendetta counter increments by `+1` toward `0`. While `prev_turns < 0`, the pair cannot form an Alliance Pact and renders a conflict border front along their shared hex edge. * **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: + If a formal pact already exists (`prev_turns >= 2`) and one partner is under severe demographic or ecological stress **AND** exhibits opportunistic or aggressive tendencies—and is not deterred by a heavily fortified partner (`victim.defense_tier >= 2`)—the stressed partner breaks the pact in a surprise cross-border 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$. + * **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`. When either partner faces an adjacent non-allied warrior threat (`_has_adjacent_warrior_threat`), the required cooperation threshold drops to **`0.22`**, 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. + * **$+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$). Surplus allies ($E_{\text{reserves}} > 60.0$) transfer up to `14.0` energy to distressed partners, spawn an emerald `"pact"` highway flow (`intensity = 0.80`) with a diplomatic border front, and provide up to **$+1.10$ shared military defense** (`compute_pact_defense_bonus`) 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**): +Non-allied, non-close-kin adjacent demes (`altitude <= 0.85`) with raiding capability attempt **Cross-Border Plunder Raids** (`_execute_raid_if_successful`) against neighbors 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`). +* **Force Comparison**: The attacker's `raider_score` 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. + If an under-fortified cooperative or mercantile settlement (`defense_tier < 2`, `energy_reserves >= 55.0`) faces a superior raider (`raider_score > defense_score * 0.92`), it deters the raid by transferring $\text{tribute} = \text{clamp}(0.18 \cdot E_{\text{reserves, target}},\ 10.0,\ 28.0)$ with **zero casualties**, setting `state.active_pacts[pair_key] = 1` (temporary truce), spawning a golden `"tribute"` caravan flow (`intensity = 0.75`), and logging a `"tribute"` Chronicle entry. * **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. + Perimeter defenses scale down both plundered calories (`0.75x` at Lv.1 Palisade, `0.40x` at Lv.2 Stone Wall) and defender casualties (`0.75x` at Lv.1, `0.50x` at Lv.2), sets a `-2` vendetta, spawns an orange `"raid"` flow, and logs a `"raid"` Chronicle entry. -### 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: +### Step 9: Ring 2 Multi-Tier Defense Fortification (`_run_defense_fortification_phase`) +Settlements with `defense_tier < 3` experiencing border security pressure can upgrade their concentric **Ring 2 Defense Perimeter** by one tier per turn: -| Tier | Fortification Name | Defense Bonus | Raid Protection | Requirements (`pop` / `reserves` / `hex R`) | Base Cost (`reserves` + `hex R`) | Upkeep / Turn | +| Tier | Ring 2 Fortification | 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. +* **Allied Co-Financing**: Adjacent Pact allies holding `energy_reserves >= 80.0` automatically co-finance up to `30.0` energy toward a partner's wall upgrade. --- -## Stage 3B: Demography, Upkeep, Territorial Wars, Sieges & Synoecism (`EvoDemographyMigration`) +## Micro-Phase 4 (`4. Lineage`): Demography, 3-Step Displacement, Wars, Fission, Mergers & Milestones (`EvoDemographyMigration` & `EvoMilestonePhase`) ### 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`: +Each deme expends energy $C_{\text{metabolic}}$ based on its population $P_i$, effective `metabolic_cost`, ambient thermal extremity $|T_{\text{cell}}|$, 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}$$ +The base per-turn demographic growth rate $g_0$ is bounded between $-45\%$ and $+35\%$, scaled by `bio-r-strategy` ($\times 1.28$ at low crowding $< 0.55$, $\times 1.16$ at $< 0.65$, $\times 1.05$ at $\ge 0.65$) or `bio-k-strategy` ($\times 0.88$), and visualized during Micro-Phase 4 as floating `+Pop / -Pop` delta badges above each settlement. ### 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). +* **Cultural Innovation Counter (`innovation_counter`)**: Advances by $\Delta I = \big(P(\text{"innovate"}) + 0.35 \cdot P(\text{"cooperate"})\big) \times 0.45$ (capped at `2.0`). +### Step 4: Territorial Wars of Conquest, 3-Step Displacement Cascade & Empty-Hex Population Fission 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. + * **Attacker Victory (`att_force >= def_force`)**: The attacking warband seizes $38\%$ of the defender's `energy_reserves`, inflicts combat casualties on both sides, displaces the surviving defender via the **3-Step Displacement Cascade (`evacuate_or_merge_displaced_deme`)**, and establishes a conquered settlement (` -w - _`) as the sole resident of the target hex (`target_cell.set_resident_deme_id(conqueror.id)`), inheriting `strategy_id`, `lineage_id`, and **captured fortifications (`max(0, defender.defense_tier - 1)`)**. + * **Fortified Siege / Invasion Repelled (`att_force < def_force`)**: The attacking warband suffers $(42\% + 6\% \cdot \text{def\_tier})$ siege attrition and retreats, spawning a crimson `"siege"` flow (`intensity = 0.90`). +2. **The 3-Step Displacement Cascade (`evacuate_or_merge_displaced_deme`)**: + Whenever a deme is displaced from its hex by warband conquest (`"invasion"`) or coastal submergence (`"submerge"`, `"submerge_brush"`), it resolves through three deterministic steps that strictly preserve the **1-deme-per-hex invariant**: + * **Step A — Evacuate to Empty Passable Neighbor**: If any adjacent passable hex (`biome != "ocean"`, `altitude <= 0.85`) is uninhabited (`ncell.deme_ids.is_empty()`), the displaced deme relocates into the empty neighbor with the highest `current_resources` (`defense_tier = 0`, $-20\%$ reserves on submergence, spawning a `"migration"` flow). + * **Step B — Refugee Merge into Allied or Kin Neighbor**: If no empty passable neighbor exists, the engine searches adjacent passable occupied hexes for an active Pact ally (`is_pact_active`) or compatible kin (`lineage_id == victim.lineage_id` or `compute_deme_divergence <= 0.35`). The displaced population merges into the largest refuge partner (`ally.population += victim.population`, `ally.energy_reserves = min(280.0, ally.energy_reserves + victim.energy_reserves * 0.80)`, `ally.merged_count += 1`), spawning a `"merge"` flow. + * **Step C — Extinction**: If neither an empty neighbor nor an allied/kin refuge exists, the displaced deme is eliminated (`_log_deme_extinction`). +3. **Peaceful Population Fission into Empty Neighbor Hexes (or Kin Reinforcement)**: + * When a deme undergoes budding migration ($\text{migrants} \ge 10$, leaving $P_i - \text{migrants} \ge 10$), it prioritizes **empty passable neighbor hexes** (`ncell.deme_ids.is_empty()`, scored by `current_resources`), founding a single-resident daughter settlement (` -m - _`). + * If all passable neighbor hexes are occupied, the migrant cohort can reinforce an adjacent compatible same-lineage kin settlement (`compute_deme_divergence <= 0.25` and `other.population < 75`) without violating the 1-deme-per-hex invariant. ### 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`. - ---- +* **Minimum Viable Population Extinction**: Any deme whose population falls below **$10$ individuals** (`deme.population < 10`) goes extinct. +* **Cultural Loss (Tasmanian Effect / Demographic Bottleneck)**: Evaluated against `pre_fission_pop` so healthy parent demes do not lose cultural traits solely from colonist fission; if a deme loses $\ge 50\%$ of its turn-start population or drops below $50$ individuals after net decline, it loses its highest-threshold cultural tradition (`"cultural_loss"`). -## Stage 4: Evolutionary Milestones, Speciation Strategy Inheritance & Macro-Outcomes (`EvoMilestonePhase`) +### Step 6: Inter-Cell Kin Merging, City Unification (Synoecism) & Isolation Tracking +* **Inter-Cell Compatible Kin Merging (`_run_adjacent_kin_mergers`)**: Two adjacent passable settlements of the **same `lineage_id`** (founded at least 3 generations ago) whose Jaccard trait divergence is $\le 0.25$ and combined population is $\le 1.15 \times \max(K_{\max, 1}, K_{\max, 2})$ merge across their shared hex border into the larger/better-fortified settlement, vacating the donor hex (`donor_cell.deme_ids.clear()`) and spawning a `"merge"` flow. +* **Inter-Cell City Unification / Synoecism (`_run_adjacent_synoecism`, Tier 5 Unified Metropolis)**: + 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 across their shared border into a single **Tier 5 Unified Metropolis (`Deme`)**—combining population and reserves (up to `280.0`), merging traits, elevating shared fortifications, expanding host hex $K$ (`+35.0` up to $1.35 \times K_{\max}$), and **directly reducing the number of active LLM inference evaluations per generation**. +* **Geographic Isolation Counter (`isolation_turns`)**: Reset to `0` if the deme is on its lineage's `founder_hex_id`, merged this turn, or is connected within **3 passable hex hops** to `founder_hex_id` or to any hex occupied by a living peer deme of the same `lineage_id`. Otherwise, `deme.isolation_turns += 1`. -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): +## Evolutionary Milestones, Lookahead Prefetch & Macro-Outcomes (`EvoMilestonePhase`) -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. +At the end of Micro-Phase 4, `EvoMilestonePhase.run_milestone_phase` synthesizes at most **one emergent trait per turn globally** in strict priority order (`0: allopatric_speciation` $\to$ `1: baldwin_assimilation` $\to$ `2: cultural_innovation`), evaluates global macro-outcomes (`total_extinction`, `single_survivor`, `ecological_equilibrium`), and triggers **Phase 4 next-turn lookahead Ollama signature prefetching** (`EvoDecisionModel.prefetch_for_state(state, false)`). diff --git a/gh-pages/ui_tools_controls.md b/gh-pages/ui_tools_controls.md index 286984e..4d06a47 100644 --- a/gh-pages/ui_tools_controls.md +++ b/gh-pages/ui_tools_controls.md @@ -1,6 +1,6 @@ # 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 and phylogenetic simulation through a warm, handcrafted naturalist parchment user interface (`EvoMainHud`, `EvoUiStyle`) paired with a high-definition 2D/2.5D procedural vector and GPU-shader hexagonal world (`EvoWorldRenderer`).  @@ -14,8 +14,8 @@ The main interface is organized into four non-intrusive peripheral docks that le 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"] + TopRight["2. Top-Right Season Clock, 4-Pip Turn Phase Bar & Mana\n• Live 72×76 Season & Tide Pixel Portrait\n• Year / Gen Counter + Play / Pause / +1 / Speed\n• 4-Pip Turn Phase Bar: 1. Env | 2. Mind | 3. Clash | 4. Lineage\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 & Border Relations\n• Tab 2: Milestone Meters & Trait Medallions\n• Tab 3: Hex Habitat Climate, 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 ``` @@ -27,74 +27,78 @@ flowchart TD * **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 procedural GPU-shader biome columns (`OCEAN_SHADER_CODE` and `TERRAIN_SHADER_CODE`), 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`)**, 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`)**. -### 2. Top-Right Season Clock, Playback & Settings Popup +### 2. Top-Right Season Clock, 4-Pip Turn Phase Bar, 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. - * **`▶ Play` / `⏸ Pause`** (Space), **`⏭ +1`** (single-generation step), and **`1x` (`1.50s/gen`) / `2x` (`0.30s/gen`)** speed toggle. + * **`▶ Play` / `⏸ Pause`** (Space), **`⏭ +1`** (completes in-flight micro-phases or steps one full generation), and **`1x` (`0.375s` per micro-phase) / `2x` (`0.30s` per full generation)** speed toggle. +* **4-Pip Turn Phase Bar (`_phase_pip_panels`)**: + * Directly beneath the playback buttons, four color-coded pips track the sequential 4-step micro-phases of each generation alongside synchronized world overlay beats (`EvoWorldOverlayRenderer._draw_micro_phase_highlights`): + 1. **`1. Env` / `1. Clima` (`#10b981`)**: Climate, tides, and biomass regeneration (expanding emerald/amber resource pulse rings on terrestrial hexes). + 2. **`2. Mind` / `2. Mente` (`#38bdf8`)**: System 1 neural decision scoring (prominent Decision Intent Crests with dashed golden intent tethers to occupied neighbor hexes). + 3. **`3. Clash` / `3. Bordes` (`#ef4444`)**: Resource extraction and inter-cell border interactions (pulsing crimson conflict or emerald cooperative clash rings at shared hex border midpoints). + 4. **`4. Lineage` / `4. Linaje` (`#f59e0b`)**: Demography, migrations, mergers, and milestones (floating `+Pop / -Pop` delta badges rising above settlements). * **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 `%`. * **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). + * **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`). * **Camera Presets**: One-click pitch presets (`32°`, `48°`, `68°`), yaw orbit buttons (`↺ -18°`, `↻ +18°`), and `🎯 Reset`. --- -## 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`). +## 3-Tier Semantic Zoom, Concentric Living Settlements & Border Fronts + +Every settlement occupies its own sovereign hexagon (`cell.deme_ids.size() <= 1`) and adapts its visual representation across **three semantic zoom tiers** (`EvoWorldRenderer.get_semantic_zoom_tier`): + +1. **Macro Zoom Tier (`zoom < 0.68`)**: + * **Sovereign Territory Color Wash & Contour**: Occupied hexes receive a bold strategy color wash (`0.28 * macro_w`) and perimeter border contour. + * **High-Contrast Lineage Medallions (`_draw_macro_medallion`)**: Replaces detailed billboards with a compact circular strategy medallion displaying the strategy emblem, population count, and vital-status arc (while culling micro flora props and moving villagers). + * **Shared Hex Border Fronts (`draw_pact_border_front` & `draw_conflict_border_front`)**: Highlights allied golden-emerald diplomatic borders and crimson-iron conflict fronts between adjacent territories. +2. **Meso Zoom Tier (`0.68 <= zoom < 1.35`)**: + * **Concentric Morphological Living Settlements (`EvoPropFloraRenderer.build_settlement_morphology`)**: + * **Ring 0 (Core Plaza & Citadel)**: Earthen terrace, cobblestone plaza, strategy mosaic ring (doubled for merged metropolises), and an archetype-driven central citadel (`forage_lodge`, `amphitheater_dome`, `twin_canopy_market`, `horned_war_keep`, `furnace_rig`, `nomad_pavilion`, `astronomical_spire`) scaled across Tiers 1..5. + * **Ring 1 (Trait Crown)**: Up to 6 radial structures mapped deterministically from `biological_trait_ids` and `cultural_trait_ids` (`TRAIT_MODULE_MAP` / `TRAIT_RING_CATALOG`: `monolith`, `hearth_circle`, `brood_totem`, `terraced_sanctuary`, `war_brazier`, `grove_shrine`, `caravan_depot`, `granary_silo`, `lithic_workshop`, `crystal_obelisk`). + * **Ring 2 (Defense Perimeter & Flora)**: Tiered perimeter walls (`defense_tier 0..3`: timber palisades, crenellated stone walls, or 6-towered bastions) surrounded by biome-reactive procedural vector flora. + * **Floating Village Billboards & Geopolitical Side Pills (`draw_village_billboard`)**: Displays the fortified strategy shield, vital dot, population, Decision Intent Crest, and contextual side pills (`Lv1..Lv3`, `merge`, `siege`, `war`, `betrayal`, `raid`, `tribute`, `pact`), plus priority-ranked animated border flows (`FLOW_PRIORITY`). +3. **Micro Zoom Tier (`zoom >= 1.35`)**: + * **Micro Diagnostic Bars (`_draw_micro_diagnostics`)**: Renders an overhead energy delta badge (`%+0.1f E`), a 3-bar evolutionary progress strip (`🧬%d/8 💡%d%% 🏝️%d/10` for Baldwin assimilation, Cultural Innovation, and Allopatric Isolation), radial Ring 1 trait labels (`zoom >= 1.55` or selected/hovered), and orbiting lineage-tinted villagers. --- ## 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 telemetry on any selected village 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. + * **Village Cycler (`◀` / `▶` & Dropdown)**: Lists all living demes sorted by population descending (` [i/N] (q,r)`), 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 projects a pulsing vertical light beam over 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 (`_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` to `V · 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`). * **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 & Alliances (`🤝`) & Inter-Cell 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/unification routes (`🛣️`), inter-cell border disputes (`⚔️`), migratory expeditions (`🧭`), severed/disused roads (`🚧` with turn age), and neighboring villages (`🏘️`). * **Naturalist's Note (`💬`)**: Contextual behavioral justification generated by the cognitive engine. === "Tab 2: 🧬 Traits (`Rasgos`)" @@ -103,7 +107,7 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi * **Genetic Assimilation (Baldwin)**: Tracks `max(baldwin_counters)` toward the `8 gen` threshold (`#0d9488`). * **Cultural Innovation**: Tracks `innovation_counter` ($0\text{–}100\%$) toward synthesizing a new cultural tradition (`#7e22ce`). * **Allopatric Isolation**: Tracks `isolation_turns` toward the `10 gen` speciation threshold (`#1d4ed8`). - * **Active Trait & Tradition Medallions**: Interactive badges for every biological (`🧬`) and cultural (`🔥`) trait carried by the deme, with hover tooltips displaying its full 6-axis biophysical vector, plus a direct shortcut to open the **Trait & Decision Forge**. + * **Active Trait & Tradition Medallions**: Interactive badges for every biological (`🧬`) and cultural (`🔥`) trait carried by the deme (and reflected in its Ring 1 Trait Crown), with hover tooltips displaying its full 6-axis biophysical vector, plus a direct shortcut to open the **Trait & Decision Forge**. === "Tab 3: 🌿 Habitat (`Hábitat`)" @@ -130,7 +134,7 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi * **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. +* **Map Lineage Highlighting (`✨ Highlight Lineage on Map`)**: Selecting any lineage node populates the right-hand detail sidebar and lets you project pulsing lineage light beams across the 2.5D world map. --- @@ -144,13 +148,13 @@ The collapsible parchment Scroll Inspector on the right side of the screen provi 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. * **`🛡️ 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 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`. + * **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`, 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`)**. * **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`**. --- @@ -166,7 +170,7 @@ The bottom dock provides **10 hotbar slots** (1–9, | **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 resident demes via the 3-step cascade); 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..9b42d83 100644 --- a/gh-pages/worlds_biomes.md +++ b/gh-pages/worlds_biomes.md @@ -6,11 +6,11 @@ Rather than relying on random unstructured noise, the simulator ships with three --- -## Hexagonal Grid Geometry (`HexMath`) +## Hexagonal Grid Geometry & Sovereign Cell Invariant (`HexMath` & `EvoTypes.HexCell`) 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, Spiral Generation & Single-Resident Deme Invariant * **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: @@ -23,10 +23,8 @@ Every world is a pointy-top hexagonal spiral centered at axial coordinate $(q, r $$\big[(+1, 0),\ (+1, -1),\ (0, -1),\ (-1, 0),\ (-1, +1),\ (0, +1)\big]$$ * **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$. +* **Strictly 1 Resident Deme Per HexCell (`cell.deme_ids.size() <= 1`)**: + Each terrestrial `HexCell` hosts **at most 1 resident deme** (`cell.get_resident_deme_id() -> String`, `cell.set_resident_deme_id(deme_id: String)`). Every occupied cell renders a single sovereign **Concentric Morphological Settlement** centered at $(0, 0)$ on the hex plateau (`Ring 0` Core Plaza & Citadel, `Ring 1` Trait Crown, and `Ring 2` Defense Perimeter & Flora), and all inter-deme interactions occur across shared hex borders (`HexEdgeFlow`). --- @@ -36,7 +34,7 @@ Each `HexCell` stores its active `biome`, `temperature` ($[-1.0, +1.0]$), `humid 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 & Procedural Vector Flora | | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :--- | | `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. | @@ -44,7 +42,7 @@ The baseline biophysical parameters defined in `EvoTypes.BIOME_CLIMATE_DEFAULTS` | `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. | -| `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. | +| `ocean` | **Ocean** / *Océano* | `+0.10` | `0.90` | `0.02 – 0.08` | `0.02` | `50.0` | Deep marine barrier ($R = 0.0$) shaded by `OCEAN_SHADER_CODE`. Impassable to terrestrial demes; submergence triggers the 3-step displacement cascade. | ### Passability & Barrier Traversal Formula @@ -59,7 +57,7 @@ $$\text{Passable}(\text{ncell}, v_{\text{eff}}) \iff \text{ncell.biome} \neq \te ## Dynamic Environmental Cycles (`EvoEnvPhase`) -At the start of every generation (`EvoEnvPhase.run_environmental_phase`), the physical world evolves through four deterministic environmental mechanisms: +At the start of every generation (`EvoTurnEngine.step_phase_1_environment` / `EvoEnvPhase.run_environmental_phase`), the physical world evolves through four deterministic environmental mechanisms: ```mermaid flowchart TD @@ -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'\n3-Step Displacement Cascade:\nEmpty Neighbor → Ally/Kin Merge → Extinction"] 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)"] ``` @@ -77,7 +75,7 @@ Every generation alternates between **Warm Spring (`wet_warm`)** and **Dry Winte * **Seasonal Biomass Multiplier ($\gamma_{\text{season}}$)**: $$\gamma_{\text{season}} = \begin{cases} 1.20 & \text{if } \text{season} = \text{"wet\_warm"} \\ 0.75 & \text{if } \text{season} = \text{"dry\_cold"} \end{cases}$$ -* **Thermal & Hydric Drift**: Every non-ocean hex shifts by $\Delta T = \pm 0.04$ (clamped to $[-1.0, +1.0]$) and $\Delta H = \pm 0.05$ (clamped to $[0.0, +1.0]$). +* **GPU Shader Warmth & Thermal Drift**: Updates the `season_warmth` uniform on `TERRAIN_SHADER_CODE` (`1.0` in `wet_warm`, `0.0` in `dry_cold`), and shifts every non-ocean hex by $\Delta T = \pm 0.04$ (clamped to $[-1.0, +1.0]$) and $\Delta H = \pm 0.05$ (clamped to $[0.0, +1.0]$). ### 2. Glacial Sea-Level Cycles & Land Bridges When `state.glacial_sea_level_cycle == true` (active by default in `preset-darwin-archipelago`), global sea level oscillates every **8 generations**: @@ -88,12 +86,12 @@ $$\text{sea\_level}(t) = \begin{cases} 0.12 \ (\text{Low Tide — Land Bridges E * **Generations `8–15`**: `sea_level = 0.25` — coastal shallows flood into `ocean`, isolating 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 & 3-Step Displacement Cascade (`evacuate_or_merge_displaced_deme`) +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 deme resolves via `EvoDemographyMigration.evacuate_or_merge_displaced_deme`: -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. **Step 1 — Empty Passable Neighbor Evacuation**: Scans all 6 adjacent neighbor hexes for uninhabited passable refuges (`ncell.biome != "ocean"`, `ncell.deme_ids.is_empty()`, and `ncell.altitude <= 0.85`), relocating to the one with highest `current_resources` ($R$) with `defense_tier = 0`, a **$20\%$ energy reserve penalty** ($E_{\text{reserves}} \leftarrow 0.80 \cdot E_{\text{reserves}}$), and a `"migration"` flow (`intensity = 0.80`). +2. **Step 2 — Allied or Kin Neighbor Refugee Merge**: If all passable neighbor hexes are already occupied, searches adjacent occupied passable hexes for an active Pact ally (`is_pact_active`) or compatible kin (`lineage_id == victim.lineage_id` or `compute_deme_divergence <= 0.35`), merging the displaced refugees into the largest refuge partner (`ally.population += victim.population`, `ally.merged_count += 1`) and spawning a `"merge"` flow. +3. **Step 3 — Submergence Extinction**: If neither an empty neighbor nor an allied/kin refuge exists, the deme drowns—recording an `"extinction"` entry in the Chronicle and leaving behind sunken `ruins`. ### 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$: @@ -110,8 +108,6 @@ $$R_{t+1} = \min\Big(K_{t+1},\ R_t + K_{t+1} \cdot r_{\text{regen}} \cdot \gamma 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`. - ### 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. @@ -132,9 +128,6 @@ When a preset builds its founding subpopulations, `EvoCatalog.apply_strategy_to_ | :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | | **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**. --- @@ -160,16 +153,12 @@ When a preset builds its founding subpopulations, `EvoCatalog.apply_strategy_to_ | :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | | **Strategy A** | `deme-darwin-founder`
**Western Island Founders**
*Fundadores Insulares del Oeste* | `lineage-darwin-a`
**Western Archipelago Lineage**
*Linaje Occidental del Archipiélago* | `-1,0`
(`tropical_forest`) | `95` | `165.0` | `+12.0` | `state.strategy_a_id`
*(Default: `strat-cooperation` 🤝)* | `d3:4b` | | **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`). --- ### 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 occupying neighboring territories on a bounded island ecosystem with slow biomass regeneration ($r_{\text{regen}} = 0.08$) either exhaust their carrying capacity $K$ through intensive overexploitation and inter-cell Hawk-Dove border conflict, or stabilize via Communal Conservation Norms and Inter-Cell Symbiosis.  @@ -185,6 +174,3 @@ When a preset builds its founding subpopulations, `EvoCatalog.apply_strategy_to_ | :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | | **Strategy A** | `deme-island-1`
**Northern Quarry Clan**
*Clan de la Cantera Norte* | `lineage-island-1`
**Northern Quarry Lineage**
*Linaje de la Cantera Norte* | `-1,0`
(`savanna`) | `95` | `140.0` | `+8.0` | `state.strategy_a_id`
*(Default: `strat-cooperation` 🤝)* | `d3:4b` | | **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. diff --git a/src/core/types.gd b/src/core/types.gd index 52810e8..8b8c27f 100644 --- a/src/core/types.gd +++ b/src/core/types.gd @@ -227,8 +227,6 @@ class StrategyDef extends RefCounted: "icon": icon, "color": color, "origin": origin, - "biologicalTraitIds": biological_trait_ids.duplicate(), - "culturalTraitIds": cultural_trait_ids.duplicate(), "biological_trait_ids": biological_trait_ids.duplicate(), "cultural_trait_ids": cultural_trait_ids.duplicate() } @@ -242,11 +240,11 @@ class StrategyDef extends RefCounted: s.color = str(data["color"]) if data.has("color") else "#10B981" s.origin = str(data["origin"]) if data.has("origin") else "base" 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 []) + var raw_bio: Array = data["biological_trait_ids"] if data.has("biological_trait_ids") else (data["biologicalTraitIds"] if data.has("biologicalTraitIds") else []) for item in raw_bio: 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 []) + var raw_cul: Array = data["cultural_trait_ids"] if data.has("cultural_trait_ids") else (data["culturalTraitIds"] if data.has("culturalTraitIds") else []) for item in raw_cul: s.cultural_trait_ids.append(str(item)) return s @@ -272,6 +270,14 @@ class HexCell extends RefCounted: var seasonal_regen_rate: float = 0.18 var deme_ids: Array[String] = [] + func get_resident_deme_id() -> String: + return deme_ids[0] if not deme_ids.is_empty() else "" + + func set_resident_deme_id(deme_id: String) -> void: + deme_ids.clear() + if deme_id != "": + deme_ids.append(deme_id) + func duplicate_cell() -> HexCell: var c := HexCell.new() c.id = id @@ -321,7 +327,8 @@ class HexCell extends RefCounted: c.carrying_capacity_max = float(d["carryingCapacityMax"]) c.seasonal_regen_rate = float(d["seasonalRegenRate"]) c.deme_ids = [] - for item in d["demeIds"]: + var raw_deme_ids: Array = d["deme_ids"] if d.has("deme_ids") else d["demeIds"] + for item in raw_deme_ids: c.deme_ids.append(str(item)) return c @@ -415,7 +422,6 @@ class Deme extends RefCounted: "id": id, "name": name, "lineageId": lineage_id, - "strategyId": strategy_id, "strategy_id": strategy_id, "color": color, "hexId": hex_id, @@ -424,17 +430,17 @@ class Deme extends RefCounted: "lastEnergyDelta": last_energy_delta, "biologicalTraitIds": biological_trait_ids.duplicate(), "culturalTraitIds": cultural_trait_ids.duplicate(), - "baldwinCounters": baldwin_counters.duplicate(), - "innovationCounter": innovation_counter, + "baldwin_counters": baldwin_counters.duplicate(), + "innovation_counter": innovation_counter, "isolationTurns": isolation_turns, "decisionModelTag": decision_model_tag, "lastConfidence": last_confidence, "lastDecisionProbabilities": last_decision_probabilities.duplicate(), "lastDominantDecisionId": last_dominant_decision_id, "lastJustification": last_justification, - "defenseTier": defense_tier, - "mergedCount": merged_count, - "secondaryColor": secondary_color + "defense_tier": defense_tier, + "merged_count": merged_count, + "secondary_color": secondary_color } static func from_dict(dict_data: Dictionary) -> Deme: @@ -442,12 +448,12 @@ 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"): + if dict_data.has("strategy_id"): d.strategy_id = str(dict_data["strategy_id"]) + elif dict_data.has("strategyId"): + d.strategy_id = str(dict_data["strategyId"]) d.color = str(dict_data["color"]) - d.hex_id = str(dict_data["hexId"]) + d.hex_id = str(dict_data["hex_id"]) if dict_data.has("hex_id") else str(dict_data["hexId"]) d.population = int(dict_data["population"]) d.energy_reserves = float(dict_data["energyReserves"]) d.last_energy_delta = float(dict_data["lastEnergyDelta"]) @@ -458,10 +464,10 @@ class Deme extends RefCounted: for item in dict_data["culturalTraitIds"]: d.cultural_trait_ids.append(str(item)) d.baldwin_counters = {} - var raw_bc: Dictionary = dict_data["baldwinCounters"] + var raw_bc: Dictionary = dict_data["baldwin_counters"] if dict_data.has("baldwin_counters") else dict_data["baldwinCounters"] for k in raw_bc.keys(): d.baldwin_counters[str(k)] = int(raw_bc[k]) - d.innovation_counter = float(dict_data["innovationCounter"]) + d.innovation_counter = float(dict_data["innovation_counter"]) if dict_data.has("innovation_counter") else 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 @@ -474,11 +480,17 @@ class Deme extends RefCounted: 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"): + if dict_data.has("defense_tier"): + d.defense_tier = int(dict_data["defense_tier"]) + elif dict_data.has("defenseTier"): d.defense_tier = int(dict_data["defenseTier"]) - if dict_data.has("mergedCount"): + if dict_data.has("merged_count"): + d.merged_count = int(dict_data["merged_count"]) + elif dict_data.has("mergedCount"): d.merged_count = int(dict_data["mergedCount"]) - if dict_data.has("secondaryColor"): + if dict_data.has("secondary_color"): + d.secondary_color = str(dict_data["secondary_color"]) + elif dict_data.has("secondaryColor"): d.secondary_color = str(dict_data["secondaryColor"]) return d @@ -521,7 +533,6 @@ class LineageNode extends RefCounted: "id": id, "name": name, "parentId": parent_id, - "strategyId": strategy_id, "strategy_id": strategy_id, "color": color, "foundedGeneration": founded_generation, @@ -538,10 +549,10 @@ 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"): + if d.has("strategy_id"): n.strategy_id = str(d["strategy_id"]) + elif d.has("strategyId"): + n.strategy_id = str(d["strategyId"]) 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 @@ -599,11 +610,9 @@ class ChronicleEntry extends RefCounted: return { "id": id, "generation": generation, - "eventType": event_type, - "type": event_type, + "event_type": event_type, "title": title, "causalExplanation": causal_explanation, - "description": causal_explanation, "hexId": hex_id, "demeId": deme_id, "lineageId": lineage_id, @@ -614,7 +623,7 @@ 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"]) if d.has("event_type") else (str(d["eventType"]) if d.has("eventType") else (str(d["type"]) if d.has("type") else "")) 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.hex_id = str(d["hexId"]) @@ -646,8 +655,7 @@ class HexEdgeFlow extends RefCounted: 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 +665,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"]) if d.has("flow_type") else (str(d["type"]) if d.has("type") else (str(d["flowType"]) if d.has("flowType") else "migration")) 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 "" @@ -744,6 +752,8 @@ class WorldState extends RefCounted: return s func to_dict() -> Dictionary: + if has_meta("_in_flight_micro_phase") or has_meta("_step3_ctx") or has_meta("_turn_start_chronicle_count"): + EvoTurnEngine.complete_in_flight_turn(self) var cells_dict := {} for k in cells.keys(): cells_dict[k] = (cells[k] as HexCell).to_dict() @@ -781,23 +791,19 @@ 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(), "recentExtinctionHexes": recent_extinction_hexes.duplicate(), - "disusedConnections": disused_connections.duplicate(true), + "disused_connections": disused_connections.duplicate(true), "quietTurns": quiet_turns, "previousPopulations": previous_populations.duplicate(), "macroOutcomeType": macro_outcome_type, @@ -819,23 +825,69 @@ 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"): + if 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"): + elif d.has("strategyAId"): + s.strategy_a_id = str(d["strategyAId"]) + if d.has("strategy_b_id"): s.strategy_b_id = str(d["strategy_b_id"]) + elif d.has("strategyBId"): + s.strategy_b_id = str(d["strategyBId"]) 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]) + var assigned_demes: Dictionary = {} + var extra_demes: Array[Deme] = [] + for cid in s.cells.keys(): + var cell: HexCell = s.cells[cid] + var kept_id := "" + for raw_did in cell.deme_ids: + if s.demes.has(raw_did) and not assigned_demes.has(raw_did): + var dm: Deme = s.demes[raw_did] + if kept_id == "" and cell.biome != "ocean" and cell.altitude <= 0.85: + kept_id = raw_did + dm.hex_id = cell.id + assigned_demes[raw_did] = true + else: + extra_demes.append(dm) + assigned_demes[raw_did] = true + cell.set_resident_deme_id(kept_id) + for did in s.demes.keys(): + if not assigned_demes.has(did): + var dm: Deme = s.demes[did] + if s.cells.has(dm.hex_id): + var c: HexCell = s.cells[dm.hex_id] + if c.deme_ids.is_empty() and c.biome != "ocean" and c.altitude <= 0.85: + c.set_resident_deme_id(dm.id) + assigned_demes[did] = true + continue + extra_demes.append(dm) + assigned_demes[did] = true + for deme in extra_demes: + var origin_cell: HexCell = s.cells.get(deme.hex_id) + var best_cell: HexCell = null + var best_dist: int = 999999 + var best_res: float = -1.0 + for cid in s.cells.keys(): + var cand: HexCell = s.cells[cid] + if cand.biome == "ocean" or cand.altitude > 0.85 or not cand.deme_ids.is_empty(): + continue + var dist: int = HexMath.hex_distance(origin_cell.q, origin_cell.r, cand.q, cand.r) if origin_cell != null else 0 + if dist < best_dist or (dist == best_dist and cand.current_resources > best_res): + best_dist = dist + best_res = cand.current_resources + best_cell = cand + if best_cell != null: + deme.hex_id = best_cell.id + best_cell.set_resident_deme_id(deme.id) + else: + s.demes.erase(deme.id) 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"] if d.has("strategies_catalog") else (d["strategiesCatalog"] if d.has("strategiesCatalog") else {}) for k in raw_strats.keys(): s.strategies_catalog[str(k)] = StrategyDef.from_dict(raw_strats[k]) for k in d["lineageTree"].keys(): @@ -845,7 +897,7 @@ class WorldState extends RefCounted: 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 {}) + var raw_pacts: Dictionary = d["active_pacts"] if d.has("active_pacts") else (d["activePacts"] if d.has("activePacts") else {}) for k in raw_pacts.keys(): s.active_pacts[str(k)] = int(raw_pacts[k]) s.recent_extinction_hexes = [] @@ -853,10 +905,10 @@ class WorldState extends RefCounted: for item in d["recentExtinctionHexes"]: s.recent_extinction_hexes.append(str(item)) s.disused_connections = [] - if d.has("disusedConnections"): - for item in d["disusedConnections"]: - if item is Dictionary: - s.disused_connections.append((item as Dictionary).duplicate(true)) + var raw_disused: Array = d["disused_connections"] if d.has("disused_connections") else (d["disusedConnections"] if d.has("disusedConnections") else []) + for item in raw_disused: + if item is Dictionary: + s.disused_connections.append((item as Dictionary).duplicate(true)) s.quiet_turns = int(d["quietTurns"]) if d.has("quietTurns") else 0 s.previous_populations = {} if d.has("previousPopulations"): diff --git a/src/engine/biophys/demography_migration.gd b/src/engine/biophys/demography_migration.gd index f0f744b..43415f0 100644 --- a/src/engine/biophys/demography_migration.gd +++ b/src/engine/biophys/demography_migration.gd @@ -13,10 +13,10 @@ static func compute_deme_divergence(deme_a: EvoTypes.Deme, deme_b: EvoTypes.Deme 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): + if deme_b.biological_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): + if deme_b.cultural_trait_ids.has(tid): intersection_count += 1 var union_size := count_a + count_b - intersection_count if union_size <= 0: @@ -103,12 +103,19 @@ static func _compute_combat_forces( 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) + for oid in dcell.deme_ids: + if state.demes.has(oid): + var ally_att: EvoTypes.Deme = state.demes[oid] + if ally_att.id != attacker.id and ally_att.lineage_id == attacker.lineage_id and ally_att.population >= 35: + flank_bonus = minf(0.85, flank_bonus + 0.28) + for nid in nids: + var ncell: EvoTypes.HexCell = state.cells[nid] + if not ncell.deme_ids.is_empty(): + for oid in ncell.deme_ids: + if state.demes.has(oid): + var ally_att: EvoTypes.Deme = state.demes[oid] + if ally_att.id != attacker.id and ally_att.lineage_id == attacker.lineage_id and ally_att.population >= 35: + flank_bonus = minf(0.85, flank_bonus + 0.28) var att_force := ( float(warband_pop) @@ -166,6 +173,183 @@ static func _has_vanguard_closer_to_rival( return false +static func _clean_deme_pacts_and_lineage( + deme: EvoTypes.Deme, + state: EvoTypes.WorldState +) -> void: + var stale_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): + stale_pacts.append(pk) + for pk in stale_pacts: + state.active_pacts.erase(pk) + + 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 + + +static func _log_deme_extinction( + deme: EvoTypes.Deme, + hex_id: String, + state: EvoTypes.WorldState, + lang: String, + reason: String = "invasion" +) -> void: + var is_en := lang == "en" + if not state.recent_extinction_hexes.has(hex_id): + state.recent_extinction_hexes.append(hex_id) + + var entry := EvoTypes.ChronicleEntry.new() + if reason in ["submerge", "submerge_brush"]: + var prefix := "chronicle-submerge-brush" if reason == "submerge_brush" else "chronicle-submerge" + entry.id = "%s-%d-%s" % [prefix, state.generation, deme.id] + 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, hex_id] + if is_en + else "%s se extinguió cuando la celda (%s) quedó sumergida en el océano sin refugio terrestre accesible." % [deme.name, hex_id] + ) + else: + entry.id = "chronicle-extinct-%d-%s" % [state.generation, deme.id] + 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, 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, hex_id] + ) + entry.generation = state.generation + entry.event_type = "extinction" + entry.hex_id = hex_id + entry.deme_id = deme.id + entry.lineage_id = deme.lineage_id + state.chronicle.append(entry) + + +static func evacuate_or_merge_displaced_deme( + deme: EvoTypes.Deme, + origin_cell: EvoTypes.HexCell, + state: EvoTypes.WorldState, + lang: String, + reason: String = "invasion", + pre_fission_pop_by_deme: Dictionary = {}, + effective_vec_by_deme: Dictionary = {} +) -> void: + origin_cell.deme_ids.erase(deme.id) + + var empty_candidates: Array[EvoTypes.HexCell] = [] + var merge_candidates: Array[EvoTypes.Deme] = [] + for nid in HexMath.get_neighbor_ids(origin_cell.q, origin_cell.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if ncell.biome == "ocean" or ncell.altitude > 0.85: + continue + if ncell.deme_ids.is_empty(): + empty_candidates.append(ncell) + else: + var res_id := ncell.get_resident_deme_id() + var ally: EvoTypes.Deme = state.demes.get(res_id) + if ally != null and ally.id != deme.id: + if EvoTypes.is_pact_active(state, deme.id, ally.id) or (ally.lineage_id == deme.lineage_id and compute_deme_divergence(deme, ally) <= 0.35): + merge_candidates.append(ally) + + if not empty_candidates.is_empty(): + empty_candidates.sort_custom(func(a: EvoTypes.HexCell, b: EvoTypes.HexCell) -> bool: + return a.current_resources > b.current_resources + ) + var target_cell := empty_candidates[0] + deme.hex_id = target_cell.id + deme.defense_tier = 0 + target_cell.set_resident_deme_id(deme.id) + if reason in ["submerge", "submerge_brush"]: + deme.energy_reserves = snappedf(deme.energy_reserves * 0.80, 0.01) + + var flow := EvoTypes.HexEdgeFlow.new() + flow.from_hex_id = origin_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) + return + + if not merge_candidates.is_empty(): + merge_candidates.sort_custom(func(a: EvoTypes.Deme, b: EvoTypes.Deme) -> bool: + return a.population > b.population + ) + var ally := merge_candidates[0] + var prev_ally_pop := ally.population + ally.population += deme.population + if not pre_fission_pop_by_deme.is_empty(): + pre_fission_pop_by_deme[ally.id] = int(pre_fission_pop_by_deme.get(ally.id, prev_ally_pop)) + deme.population + ally.energy_reserves = minf(280.0, snappedf(ally.energy_reserves + deme.energy_reserves * 0.80, 0.01)) + var traits_changed := false + if EvoCatalog.deme_has_cultural_capacity(ally, state.traits_catalog): + for tid in deme.cultural_trait_ids: + if EvoEcologyInteraction._can_acquire_trait(ally, tid, state.traits_catalog): + ally.cultural_trait_ids.append(tid) + traits_changed = true + if traits_changed: + if state.lineage_tree.has(ally.lineage_id): + var lin_node: EvoTypes.LineageNode = state.lineage_tree[ally.lineage_id] + for tid in ally.cultural_trait_ids: + if not lin_node.cultural_trait_ids.has(tid): + lin_node.cultural_trait_ids.append(tid) + if not effective_vec_by_deme.is_empty(): + effective_vec_by_deme[ally.id] = EvoEcologyInteraction.compute_effective_vector(ally, state) + ally.merged_count += 1 + if ally.secondary_color == "" and deme.color != "" and deme.color != ally.color: + ally.secondary_color = deme.color + + var kept_flows: Array[EvoTypes.HexEdgeFlow] = [] + for fl in state.active_flows: + if fl.to_deme_id == deme.id: + fl.to_deme_id = ally.id + if fl.from_deme_id == deme.id: + fl.from_deme_id = ally.id + if fl.from_deme_id == fl.to_deme_id and not (fl.flow_type in ["migration", "merge"]): + continue + kept_flows.append(fl) + state.active_flows = kept_flows + + if not state.disused_connections.is_empty(): + var kept_disused: Array[Dictionary] = [] + for dc in state.disused_connections: + if str(dc["from_deme_id"]) == deme.id: + dc["from_deme_id"] = ally.id + if str(dc["to_deme_id"]) == deme.id: + dc["to_deme_id"] = ally.id + if str(dc["from_deme_id"]) == str(dc["to_deme_id"]): + continue + kept_disused.append(dc) + state.disused_connections = kept_disused + + var flow := EvoTypes.HexEdgeFlow.new() + flow.from_hex_id = origin_cell.id + flow.to_hex_id = ally.hex_id + flow.flow_type = "merge" + flow.intensity = 0.75 + flow.from_deme_id = deme.id + flow.to_deme_id = ally.id + state.active_flows.append(flow) + + state.demes.erase(deme.id) + _clean_deme_pacts_and_lineage(deme, state) + return + + state.demes.erase(deme.id) + _clean_deme_pacts_and_lineage(deme, state) + _log_deme_extinction(deme, origin_cell.id, state, lang, reason) + + static func _execute_territorial_invasion( attacker: EvoTypes.Deme, origin_cell: EvoTypes.HexCell, @@ -194,52 +378,26 @@ static func _execute_territorial_invasion( 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 + defender.defense_tier = 0 + target_cell.deme_ids.erase(defender.id) 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 + if defender.population >= 10: + evacuate_or_merge_displaced_deme(defender, target_cell, state, lang, "invasion", pre_fission_pop_by_deme, effective_vec_by_deme) + if state.demes.has(defender.id): + pre_fission_pop_by_deme[defender.id] = defender.population + state.active_pacts[EvoTypes.make_pair_key(attacker.id, defender.id)] = -3 + else: + pre_fission_pop_by_deme.erase(defender.id) + else: + pre_fission_pop_by_deme.erase(defender.id) + state.demes.erase(defender.id) + _clean_deme_pacts_and_lineage(defender, state) + _log_deme_extinction(defender, target_cell.id, state, lang, "invasion") var base_id := _strip_migration_suffix(attacker.id) var warband_id := "%s-w%d" % [base_id, state.generation] @@ -271,10 +429,11 @@ static func _execute_territorial_invasion( 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 + warband.set_meta("_fission_gen", state.generation) state.demes[warband_id] = warband newly_created_demes[warband_id] = true - target_cell.deme_ids.append(warband_id) + target_cell.set_resident_deme_id(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] @@ -312,9 +471,10 @@ static func _execute_territorial_invasion( 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.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[attacker.id] = attacker.population + defender.population = maxi(10, defender.population - def_loss) pre_fission_pop_by_deme[defender.id] = defender.population for pk in state.active_pacts.keys(): @@ -328,16 +488,6 @@ static func _execute_territorial_invasion( 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: @@ -350,32 +500,32 @@ static func _execute_territorial_invasion( 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 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, @@ -394,6 +544,13 @@ static func _execute_territorial_invasion( entry.lineage_id = defender.lineage_id state.chronicle.append(entry) + if attacker.population < 10: + origin_cell.deme_ids.erase(attacker.id) + pre_fission_pop_by_deme.erase(attacker.id) + state.demes.erase(attacker.id) + _clean_deme_pacts_and_lineage(attacker, state) + _log_deme_extinction(attacker, origin_cell.id, state, lang, "invasion") + static func run_demography_and_migration_phase( state: EvoTypes.WorldState, @@ -531,13 +688,15 @@ static func run_demography_and_migration_phase( ) ) + var neighbor_ids := HexMath.get_neighbor_ids(current_cell.q, current_cell.r, state.cells) + 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): + for nid in neighbor_ids: var ncell: EvoTypes.HexCell = state.cells[nid] if ncell.biome == "ocean" or ncell.altitude > 0.85: continue @@ -626,40 +785,31 @@ static func run_demography_and_migration_phase( 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): + for nid in neighbor_ids: 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: + if ncell.deme_ids.is_empty(): + candidates.append(ncell) + else: + var res_id := ncell.get_resident_deme_id() + if state.demes.has(res_id): + var other: EvoTypes.Deme = state.demes[res_id] + if other.lineage_id == deme.lineage_id and compute_deme_divergence(deme, other) <= 0.25 and other.population < 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(): + if candidates.is_empty() and reinforce_candidates.is_empty(): continue - var target_cell: EvoTypes.HexCell = candidates[0] - if candidates.size() > 1: + var is_reinforcing := candidates.is_empty() + var pool := reinforce_candidates if is_reinforcing else candidates + var target_cell: EvoTypes.HexCell = pool[0] + if pool.size() > 1: var best_score := -INF - for cand in candidates: + for cand in pool: 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) @@ -675,6 +825,25 @@ static func run_demography_and_migration_phase( deme.population -= migrants deme.energy_reserves = maxf(0.0, snappedf(deme.energy_reserves - energy_fraction, 0.01)) + if is_reinforcing: + var other_id := target_cell.get_resident_deme_id() + var other: EvoTypes.Deme = state.demes[other_id] + other.population += migrants + other.energy_reserves = minf(280.0, snappedf(other.energy_reserves + transferred_energy, 0.01)) + pre_fission_pop_by_deme[other.id] = other.population + had_lineage_contact[deme.id] = true + had_lineage_contact[other.id] = true + + var r_flow := EvoTypes.HexEdgeFlow.new() + r_flow.from_hex_id = current_cell.id + r_flow.to_hex_id = target_cell.id + r_flow.flow_type = "merge" + r_flow.intensity = snappedf(clampf(float(migrants) / float(original_pop), 0.05, 1.0), 0.01) + r_flow.from_deme_id = deme.id + r_flow.to_deme_id = other.id + state.active_flows.append(r_flow) + continue + 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 @@ -704,6 +873,7 @@ static func run_demography_and_migration_phase( 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 + daughter.set_meta("_fission_gen", state.generation) 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")) @@ -712,7 +882,7 @@ static func run_demography_and_migration_phase( state.demes[daughter_id] = daughter newly_created_demes[daughter_id] = true - target_cell.deme_ids.append(daughter_id) + target_cell.set_resident_deme_id(daughter_id) rival_dist_cache.clear() initial_pop_by_deme[daughter_id] = migrants pre_fission_pop_by_deme[daughter_id] = migrants @@ -819,20 +989,68 @@ static func run_demography_and_migration_phase( 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: + var host_d := deme_a if deme_a.population >= deme_b.population else deme_b + var join_d := deme_b if deme_a.population >= deme_b.population else deme_a _merge_deme_into( state, - deme_a, - deme_b, + host_d, + join_d, cell, newly_created_demes, had_lineage_contact, pact_synoecism, - lang + lang, + effective_vec_by_deme ) continue j += 1 i += 1 + var kin_deme_ids := state.demes.keys() + for idx_1 in range(kin_deme_ids.size()): + var id_1 := str(kin_deme_ids[idx_1]) + if not state.demes.has(id_1) or newly_created_demes.has(id_1): + continue + var d1: EvoTypes.Deme = state.demes[id_1] + if d1.has_meta("_fission_gen") and state.generation - int(d1.get_meta("_fission_gen")) <= 3: + continue + if not state.cells.has(d1.hex_id): + continue + var c1: EvoTypes.HexCell = state.cells[d1.hex_id] + if c1.biome == "ocean" or c1.altitude > 0.85: + continue + for nid in HexMath.get_neighbor_ids(c1.q, c1.r, state.cells): + var c2: EvoTypes.HexCell = state.cells[nid] + if c2.biome == "ocean" or c2.altitude > 0.85 or c2.deme_ids.is_empty(): + continue + var id_2 := c2.get_resident_deme_id() + if id_2 == "" or id_2 == d1.id or not state.demes.has(id_2) or newly_created_demes.has(id_2): + continue + var d2: EvoTypes.Deme = state.demes[id_2] + if d2.has_meta("_fission_gen") and state.generation - int(d2.get_meta("_fission_gen")) <= 3: + continue + if d1.lineage_id != d2.lineage_id or compute_deme_divergence(d1, d2) > 0.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, + false, + lang, + effective_vec_by_deme + ) + break + var pact_keys := state.active_pacts.keys() for pk_var in pact_keys: var pk := str(pk_var) @@ -860,31 +1078,6 @@ static func run_demography_and_migration_phase( 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) @@ -900,7 +1093,8 @@ static func run_demography_and_migration_phase( newly_created_demes, had_lineage_contact, true, - lang + lang, + effective_vec_by_deme ) var conn_cache: Dictionary = {} @@ -985,79 +1179,100 @@ static func run_demography_and_migration_phase( surv_coop.append(pc) var s_count := surviving_demes.size() + var surv_idx_by_id: Dictionary = {} + for idx_a in range(s_count): + surv_idx_by_id[surviving_demes[idx_a].id] = idx_a + 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 candidate_cells: Array[EvoTypes.HexCell] = [cell_a] + if not bad_a: + for nid in HexMath.get_neighbor_ids(cell_a.q, cell_a.r, state.cells): + var ncell: EvoTypes.HexCell = state.cells[nid] + if not ncell.deme_ids.is_empty() and ncell.altitude <= 0.85 and ncell.biome != "ocean": + candidate_cells.append(ncell) - 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) + for cell_b in candidate_cells: + var same_hex := cell_a.id == cell_b.id + for id_b in cell_b.deme_ids: + if not surv_idx_by_id.has(id_b): + continue + var idx_b: int = int(surv_idx_by_id[id_b]) + if idx_b <= idx_a: + continue + var deme_b := surviving_demes[idx_b] + 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) + + for cid in state.cells.keys(): + var c: EvoTypes.HexCell = state.cells[cid] + var valid_ids: Array[String] = [] + for did in c.deme_ids: + if state.demes.has(did) and (state.demes[did] as EvoTypes.Deme).hex_id == c.id: + valid_ids.append(did) + c.deme_ids = valid_ids + if c.deme_ids.size() > 1: + var keep_id := c.deme_ids[0] + var extras := c.deme_ids.slice(1) + c.set_resident_deme_id(keep_id) + for ex_id in extras: + if state.demes.has(ex_id): + evacuate_or_merge_displaced_deme(state.demes[ex_id], c, state, lang, "invasion", pre_fission_pop_by_deme, effective_vec_by_deme) var filtered_disused: Array[Dictionary] = [] for conn in state.disused_connections: @@ -1083,7 +1298,8 @@ static func _merge_deme_into( newly_created_demes: Dictionary, had_lineage_contact: Dictionary, is_synoecism: bool, - lang: String + lang: String, + effective_vec_by_deme: Dictionary = {} ) -> void: var old_name_a := deme_a.name var old_name_b := deme_b.name @@ -1117,12 +1333,18 @@ static func _merge_deme_into( deme_a.population = total_pop deme_a.energy_reserves = minf(280.0, snappedf(deme_a.energy_reserves + deme_b.energy_reserves, 0.01)) + var traits_changed := false 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) + traits_changed = true 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) + traits_changed = true + + if traits_changed and not effective_vec_by_deme.is_empty(): + effective_vec_by_deme[deme_a.id] = EvoEcologyInteraction.compute_effective_vector(deme_a, state) if state.lineage_tree.has(deme_a.lineage_id): var lin_node: EvoTypes.LineageNode = state.lineage_tree[deme_a.lineage_id] @@ -1137,9 +1359,9 @@ static func _merge_deme_into( 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 + deme_a.merged_count += 1 + deme_b.merged_count 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 == "": @@ -1168,7 +1390,7 @@ static func _merge_deme_into( 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"]): + if fl.from_deme_id == fl.to_deme_id and not (fl.flow_type in ["migration", "merge"]): continue kept_flows.append(fl) state.active_flows = kept_flows @@ -1189,7 +1411,17 @@ static func _merge_deme_into( 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) + var merged_val: int = 0 + if existing_val == 0: + merged_val = b_val + elif b_val == 0: + merged_val = existing_val + elif existing_val > 0 or b_val > 0: + merged_val = maxi(existing_val, b_val) + else: + merged_val = mini(existing_val, b_val) + if merged_val != 0: + pacts_to_set[new_pk] = merged_val for pk in pacts_to_erase: state.active_pacts.erase(pk) for new_pk in pacts_to_set.keys(): @@ -1220,4 +1452,12 @@ static func _merge_deme_into( (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) + if deme_b.lineage_id != deme_a.lineage_id and state.lineage_tree.has(deme_b.lineage_id): + var has_relatives := false + for rem_id in state.demes.keys(): + if (state.demes[rem_id] as EvoTypes.Deme).lineage_id == deme_b.lineage_id: + has_relatives = true + break + if not has_relatives: + (state.lineage_tree[deme_b.lineage_id] as EvoTypes.LineageNode).extinct_generation = state.generation had_lineage_contact[deme_a.id] = true diff --git a/src/engine/biophys/ecology_interaction.gd b/src/engine/biophys/ecology_interaction.gd index 0e51865..9b916e2 100644 --- a/src/engine/biophys/ecology_interaction.gd +++ b/src/engine/biophys/ecology_interaction.gd @@ -101,10 +101,6 @@ static func compute_pact_defense_bonus( 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: @@ -385,6 +381,7 @@ static func _run_pacts_and_betrayals( var ex_flow: EvoTypes.HexEdgeFlow = existing_flows_by_key[flow_key] ex_flow.flow_type = "pact" ex_flow.intensity = 0.80 + turn_geopolitical_flows.append(ex_flow) else: var flow := EvoTypes.HexEdgeFlow.new() flow.from_hex_id = c1.id @@ -431,13 +428,6 @@ static func _run_defense_fortification_phase( 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: @@ -543,10 +533,14 @@ static func _has_recent_raid_chronicle( raider_id: String, victim_id: String ) -> bool: + var min_gen := state.generation - 2 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: + for idx in range(state.chronicle.size() - 1, -1, -1): + var entry: EvoTypes.ChronicleEntry = state.chronicle[idx] + if entry.generation < min_gen: + break + if entry.event_type == "raid": if entry.id.ends_with(suffix_ab) or entry.id.ends_with(suffix_ba): return true return false @@ -663,23 +657,22 @@ static func _execute_raid_if_successful( 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) + 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 @@ -772,6 +765,102 @@ static func run_resource_and_symbiosis_phase( valid_demes.append(d) valid_cells.append(state.cells[d.hex_id]) + var demand_by_deme: Dictionary = {} + var annexed_energy_by_deme: Dictionary = {} + for d in valid_demes: + var v_eff: EvoTypes.BiophysicalVector = effective_vec_by_deme[d.id] + demand_by_deme[d.id] = maxf( + 0.0, + float(d.population) * 0.50 * (1.0 + 0.60 * v_eff.extraction_efficiency) + ) + annexed_energy_by_deme[d.id] = 0.0 + + var deme_idx_by_id: Dictionary = {} + var v_count := valid_demes.size() + for i in range(v_count): + deme_idx_by_id[valid_demes[i].id] = i + + var adjacent_pairs: Array = [] + for i in range(v_count): + var d1 := valid_demes[i] + var c1 := valid_cells[i] + if c1.biome == "ocean" or c1.altitude > 0.85: + continue + for nid in HexMath.get_neighbor_ids(c1.q, c1.r, state.cells): + var c2: EvoTypes.HexCell = state.cells[nid] + if c2.biome == "ocean" or c2.altitude > 0.85 or c2.deme_ids.is_empty(): + continue + for oid in c2.deme_ids: + if not deme_idx_by_id.has(oid): + continue + var j: int = int(deme_idx_by_id[oid]) + if j <= i: + 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 hawk_clashed: 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] + if d1.lineage_id == d2.lineage_id or EvoTypes.is_pact_active(state, d1.id, d2.id): + continue + var is_h1 := float((arch_probs[d1.id] as Array)[2]) >= 0.25 + var is_h2 := float((arch_probs[d2.id] as Array)[2]) >= 0.25 + if is_h1 and is_h2: + for hawk in [d1, d2]: + if not hawk_clashed.has(hawk.id): + hawk_clashed[hawk.id] = true + 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 = c1.id + flow.to_hex_id = c2.id + flow.flow_type = "dispute" + flow.intensity = 0.75 + flow.from_deme_id = d1.id + flow.to_deme_id = d2.id + state.active_flows.append(flow) + + 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] + if d1.lineage_id == d2.lineage_id or EvoTypes.is_pact_active(state, d1.id, d2.id): + continue + var is_h1 := float((arch_probs[d1.id] as Array)[2]) >= 0.25 + var is_h2 := float((arch_probs[d2.id] as Array)[2]) >= 0.25 + if is_h1 != is_h2: + var h: EvoTypes.Deme = d1 if is_h1 else d2 + var c_h: EvoTypes.HexCell = c1 if is_h1 else c2 + var dove: EvoTypes.Deme = d2 if is_h1 else d1 + var c_d: EvoTypes.HexCell = c2 if is_h1 else c1 + var hawk_social := (effective_vec_by_deme[h.id] as EvoTypes.BiophysicalVector).social_cohesion + var dove_social := (effective_vec_by_deme[dove.id] as EvoTypes.BiophysicalVector).social_cohesion + var capture_mult := 0.25 * (1.0 + maxf(0.0, hawk_social - dove_social)) / (1.0 + 0.35 * float(dove.defense_tier)) + var req_bonus := float(demand_by_deme[h.id]) * capture_mult + var captured := snappedf(minf(c_d.current_resources * 0.28, minf(float(demand_by_deme[dove.id]), req_bonus)), 0.01) + demand_by_deme[dove.id] = maxf(0.0, float(demand_by_deme[dove.id]) - captured) + c_d.current_resources = maxf(0.0, snappedf(c_d.current_resources - captured, 0.01)) + annexed_energy_by_deme[h.id] = float(annexed_energy_by_deme.get(h.id, 0.0)) + captured + var flow := EvoTypes.HexEdgeFlow.new() + flow.from_hex_id = c_h.id + flow.to_hex_id = c_d.id + flow.flow_type = "dispute" + flow.intensity = 0.65 + flow.from_deme_id = h.id + flow.to_deme_id = dove.id + state.active_flows.append(flow) + var tragedy_hexes: Dictionary = {} for entry in state.chronicle: if entry.event_type == "tragedy_of_commons": @@ -787,168 +876,18 @@ static func run_resource_and_symbiosis_phase( 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]) + total_demand += float(demand_by_deme.get(deme.id, 0.0)) var total_extracted := minf(cell.current_resources, total_demand) for deme in resident_demes: - var d_dem := float(demand_by_deme[deme.id]) + var d_dem := float(demand_by_deme.get(deme.id, 0.0)) var ext := (total_extracted * (d_dem / total_demand)) if total_demand > 0.0 else 0.0 - extracted_energy_by_deme[deme.id] = ext + extracted_energy_by_deme[deme.id] = snappedf(ext + float(annexed_energy_by_deme.get(deme.id, 0.0)), 0.0001) 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 @@ -990,96 +929,81 @@ static func run_resource_and_symbiosis_phase( ) 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): + 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 int(state.active_pacts.get(pair_key, 0)) < 0: continue - var donor_cell: EvoTypes.HexCell = state.cells[donor.hex_id] - if donor_cell.biome == "ocean" or donor_cell.altitude > 0.85: + + var ap1: Array = arch_probs[d1.id] + var ap2: Array = arch_probs[d2.id] + var p_coop_1 := float(ap1[0]) + var p_exch_1 := float(ap1[1]) + var p_coop_2 := float(ap2[0]) + var p_exch_2 := float(ap2[1]) + + var both_exch := p_exch_1 >= 0.20 and p_exch_2 >= 0.20 + var hybrid_exch := (p_exch_1 >= 0.20 or p_exch_2 >= 0.20) and (p_coop_1 + p_exch_1 >= 0.32) and (p_coop_2 + p_exch_2 >= 0.32) + if not (both_exch or hybrid_exch): 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: + + if not symbiosis_bonus_applied.has(d1.id): + extracted_energy_by_deme[d1.id] = snappedf(float(extracted_energy_by_deme[d1.id]) * 1.20, 0.0001) + symbiosis_bonus_applied[d1.id] = true + if not symbiosis_bonus_applied.has(d2.id): + extracted_energy_by_deme[d2.id] = snappedf(float(extracted_energy_by_deme[d2.id]) * 1.20, 0.0001) + symbiosis_bonus_applied[d2.id] = true + + var hex_pair_key := ("%s|%s" % [c1.id, c2.id]) if c1.id <= c2.id else ("%s|%s" % [c2.id, c1.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 = c1.id + flow.to_hex_id = c2.id + flow.flow_type = "exchange" + flow.intensity = 0.60 + flow.from_deme_id = d1.id + flow.to_deme_id = d2.id + state.active_flows.append(flow) + existing_flows_by_key[hex_pair_key] = flow + + for dir in [[d1, c1, d2, c2], [d2, c2, d1, c1]]: + var donor: EvoTypes.Deme = dir[0] + var donor_cell: EvoTypes.HexCell = dir[1] + var receiver: EvoTypes.Deme = dir[2] + var receiver_cell: EvoTypes.HexCell = dir[3] + if not EvoCatalog.deme_has_cultural_capacity(receiver, state.traits_catalog): 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: + var donor_initial_traits: Array = initial_cultural_traits_by_deme.get(donor.id, []) + 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 - - 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) + 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, receiver_cell.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, receiver_cell.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, receiver_cell.id] ) - 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]) + entry.hex_id = receiver_cell.id + entry.deme_id = receiver.id + entry.lineage_id = receiver.lineage_id + entry.trait_id = tid + state.chronicle.append(entry) var betrayed_pairs_this_turn: Dictionary = {} _run_pacts_and_betrayals( diff --git a/src/engine/biophys_phase.gd b/src/engine/biophys_phase.gd index 6c4b9f8..4e34e32 100644 --- a/src/engine/biophys_phase.gd +++ b/src/engine/biophys_phase.gd @@ -42,7 +42,7 @@ static func _build_edge_flows( state.active_flows.append(geo_flow) -static func run_biophysical_phase(state: EvoTypes.WorldState, lang: String = "es") -> void: +static func run_ecology_and_borders_step(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: var initial_pop_by_deme: Dictionary = {} var effective_vec_by_deme: Dictionary = {} var hawk_penalty_by_deme: Dictionary = {} @@ -67,6 +67,46 @@ static func run_biophysical_phase(state: EvoTypes.WorldState, lang: String = "es extracted_energy_by_deme, turn_geopolitical_flows ) + _build_edge_flows(state, turn_geopolitical_flows) + + var ctx := { + "initial_pop_by_deme": initial_pop_by_deme, + "effective_vec_by_deme": effective_vec_by_deme, + "hawk_penalty_by_deme": hawk_penalty_by_deme, + "extracted_energy_by_deme": extracted_energy_by_deme, + "turn_geopolitical_flows": turn_geopolitical_flows + } + state.set_meta("_step3_ctx", ctx) + return ctx + + +static func run_demography_and_migration_step(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] = [] + + if state.has_meta("_step3_ctx"): + var ctx: Dictionary = state.get_meta("_step3_ctx") + state.remove_meta("_step3_ctx") + initial_pop_by_deme = ctx["initial_pop_by_deme"] + effective_vec_by_deme = ctx["effective_vec_by_deme"] + hawk_penalty_by_deme = ctx["hawk_penalty_by_deme"] + extracted_energy_by_deme = ctx["extracted_energy_by_deme"] + turn_geopolitical_flows = ctx["turn_geopolitical_flows"] + + for did in state.demes.keys(): + var deme: EvoTypes.Deme = state.demes[did] + if not initial_pop_by_deme.has(deme.id): + initial_pop_by_deme[deme.id] = deme.population + if not hawk_penalty_by_deme.has(deme.id): + hawk_penalty_by_deme[deme.id] = 1.0 + if not extracted_energy_by_deme.has(deme.id): + extracted_energy_by_deme[deme.id] = 0.0 + if not effective_vec_by_deme.has(deme.id): + effective_vec_by_deme[deme.id] = compute_effective_vector(deme, state) + EvoDemographyMigration.run_demography_and_migration_phase( state, lang, @@ -77,3 +117,8 @@ static func run_biophysical_phase(state: EvoTypes.WorldState, lang: String = "es turn_geopolitical_flows ) _build_edge_flows(state, turn_geopolitical_flows) + + +static func run_biophysical_phase(state: EvoTypes.WorldState, lang: String = "es") -> void: + run_ecology_and_borders_step(state, lang) + run_demography_and_migration_step(state, lang) diff --git a/src/engine/cognitive/heuristic_scorer.gd b/src/engine/cognitive/heuristic_scorer.gd index da51f0d..92369d2 100644 --- a/src/engine/cognitive/heuristic_scorer.gd +++ b/src/engine/cognitive/heuristic_scorer.gd @@ -56,7 +56,7 @@ static func _has_nearby_rival_deme(deme: EvoTypes.Deme, state: EvoTypes.WorldSta 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"]) + return bool(geo["has_adjacent_rival"]) static func get_eligible_decisions( @@ -105,16 +105,6 @@ static func pick_dominant_decision(probs: Dictionary) -> String: 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, @@ -122,7 +112,7 @@ static func inspect_geopolitical_context( ) -> 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" % [ + var cache_key := "%d|%s|%d|%d|%d|%d|%d|%d|%d|%.1f|%d" % [ state.generation if state != null else -1, cell.id if cell != null else "", pacts_hash, @@ -132,12 +122,14 @@ static func inspect_geopolitical_context( deme.population, deme.defense_tier, deme.merged_count, - deme.energy_reserves + deme.energy_reserves, + int(cell.current_resources) if cell != null else 0 ] 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 intra_rival_pop: int = 0 var has_adjacent_rival := false var has_nearby_deme := false var neighbor_has_surplus := false @@ -146,6 +138,7 @@ static func inspect_geopolitical_context( var active_pact_count: int = 0 var active_vendettas: int = 0 var adjacent_rivals_count: int = 0 + var adjacent_kin_count: int = 0 var nearby_horde_threat := false var max_neighbor_reserves: float = 0.0 @@ -181,25 +174,6 @@ static func inspect_geopolitical_context( 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: @@ -208,7 +182,9 @@ static func inspect_geopolitical_context( 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)): + if other.lineage_id == deme.lineage_id: + adjacent_kin_count += 1 + elif 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: @@ -276,6 +252,7 @@ static func inspect_geopolitical_context( var res := { "has_intra_rival": has_intra_rival, + "intra_rival_pop": intra_rival_pop, "has_adjacent_rival": has_adjacent_rival, "has_nearby_deme": has_nearby_deme, "neighbor_has_surplus": neighbor_has_surplus, @@ -284,6 +261,7 @@ static func inspect_geopolitical_context( "active_pacts": active_pact_count, "active_vendettas": active_vendettas, "adjacent_rivals": adjacent_rivals_count, + "adjacent_kin": adjacent_kin_count, "nearby_horde_threat": nearby_horde_threat, "memory_digest": memory_digest, "neighbor_max_reserves": max_neighbor_reserves, @@ -303,14 +281,7 @@ static func compute_environmental_decision_weights( ) -> 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_w_key := "%s|%s" % [",".join(deme.biological_trait_ids), ",".join(deme.cultural_trait_ids)] var base_probs: Dictionary var base_eligible: Array[String] var deme_vec: EvoTypes.BiophysicalVector @@ -463,7 +434,19 @@ static func modulate_deme_probabilities_for_turn( 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]) + var pacts_hash: int = state.active_pacts.hash() if not state.active_pacts.is_empty() else 0 + var env_w_key := "%d|%s|%s|%d|%.1f|%d|%d|%d|%d" % [ + state.generation, + state.season, + cell.id, + deme.population, + deme.energy_reserves, + int(cell.current_resources), + int(cell.carrying_capacity), + deme.defense_tier, + pacts_hash + ] + deme.set_meta("env_w_key", env_w_key) deme.set_meta("env_w_val", env_weights) var eligible_ids: Array[String] = [] for k in env_weights.keys(): @@ -602,16 +585,24 @@ static func _get_sorted_passable_neighbors( current_cell: EvoTypes.HexCell, state: EvoTypes.WorldState ) -> Array[Dictionary]: - var n_cache_key := "%d|%s|%d|%d" % [ + var neighbor_ids: Array[String] = HexMath.get_neighbor_ids(current_cell.q, current_cell.r, state.cells) if (current_cell != null and state != null) else [] + var n_state_parts: Array[String] = [] + for nid in neighbor_ids: + var nc: EvoTypes.HexCell = state.cells[nid] + var res_did := nc.get_resident_deme_id() + var res_pop: int = (state.demes[res_did] as EvoTypes.Deme).population if (res_did != "" and state.demes.has(res_did)) else 0 + n_state_parts.append("%s:%d:%s:%d" % [nc.biome, int(nc.current_resources), res_did, res_pop]) + var n_state_sig := "|".join(n_state_parts) + var mob_t_key := "%s|%s|%d" % [",".join(deme.biological_trait_ids), ",".join(deme.cultural_trait_ids), state.decisions_catalog.size() if state != null else 0] + var n_cache_key := "%d|%s|%s|%s" % [ 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() + mob_t_key, + n_state_sig ] 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")) @@ -626,7 +617,7 @@ static func _get_sorted_passable_neighbors( 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] + var cell_n_key := "%d|%.2f|%s" % [state.generation if state != null else -1, effective_mig_mobility, n_state_sig] 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: @@ -635,10 +626,10 @@ static func _get_sorted_passable_neighbors( return cached_pass var passable: Array[Dictionary] = [] - for nid in HexMath.get_neighbor_ids(current_cell.q, current_cell.r, state.cells): + for nid in neighbor_ids: 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: + if not is_passable: continue var total_pop: int = 0 for oid in ncell.deme_ids: @@ -648,6 +639,7 @@ static func _get_sorted_passable_neighbors( "id": ncell.id, "biome": ncell.biome, "current_resources": ncell.current_resources, + "is_empty": ncell.deme_ids.is_empty(), "demes_count": ncell.deme_ids.size(), "total_population": total_pop, "score": ncell.current_resources / maxf(10.0, float(total_pop)) @@ -781,6 +773,9 @@ static func build_geopolitics_summary( "active_pacts": int(geo["active_pacts"]), "active_vendettas": int(geo.get("active_vendettas", 0)), "adjacent_rivals": int(geo["adjacent_rivals"]), + "adjacent_kin": int(geo["adjacent_kin"]), + "neighbor_has_surplus": bool(geo["neighbor_has_surplus"]), + "neighbor_is_weaker": bool(geo["neighbor_is_weaker"]), "defense_tier": deme.defense_tier, "merged_count": deme.merged_count, "horde_threat": bool(geo.get("nearby_horde_threat", false)), @@ -797,21 +792,12 @@ static func build_compact_system_one_state( 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 total_cell_pop: int = maxi(1, deme.population) + var geo_summary := precomputed_geo_summary if not precomputed_geo_summary.is_empty() else build_geopolitics_summary(deme, current_cell, state) + var adj_rivals := int(geo_summary.get("adjacent_rivals", 0)) + var adj_kin := int(geo_summary.get("adjacent_kin", 0)) + var active_pacts_count := int(geo_summary.get("active_pacts", 0)) + var active_vendettas_count := int(geo_summary.get("active_vendettas", 0)) var resource_adequacy_ratio := snappedf( current_cell.current_resources / maxf(10.0, float(total_cell_pop) * 0.48), @@ -839,7 +825,7 @@ static func build_compact_system_one_state( 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)" % [ + var line2 := "Deme \"%s\": pop=%d (%s), reserves=%d, delta=%s, def=Lv%d, merged=%d | BorderCompetition: adjRivals=%d, adjKin=%d, pacts=%d, vendettas=%d" % [ deme.name, deme.population, vitality, @@ -847,10 +833,10 @@ static func build_compact_system_one_state( delta_str, deme.defense_tier, deme.merged_count, - total_cell_pop, - cohabiting_count, - rival_count, - kin_count + adj_rivals, + adj_kin, + active_pacts_count, + active_vendettas_count ] var all_traits: Array[String] = [] @@ -890,7 +876,6 @@ static func build_compact_system_one_state( 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, @@ -931,8 +916,10 @@ static func compute_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTyp 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" % [ + var traits_cache_key := "%s|%s" % [",".join(deme.biological_trait_ids), ",".join(deme.cultural_trait_ids)] + var sig_cache_key := "%d|%s|%s|%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s" % [ state.generation, + state.season, model_key, deme.hex_id, deme.population, @@ -944,24 +931,11 @@ static func compute_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTyp current_cell.deme_ids.size(), int(current_cell.current_resources), pacts_hash, - deme.biological_trait_ids.size() + deme.cultural_trait_ids.size() + traits_cache_key ] 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 total_cell_pop: int = maxi(1, deme.population) var resource_adequacy_ratio := snappedf( current_cell.current_resources / maxf(10.0, float(total_cell_pop) * 0.48), @@ -972,6 +946,8 @@ static func compute_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTyp 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 rival_count := int(geo["adjacent_rivals"]) + var kin_count := int(geo["adjacent_kin"]) 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 @@ -988,7 +964,6 @@ static func compute_deme_evaluation_signature(deme: EvoTypes.Deme, state: EvoTyp 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")) @@ -1099,7 +1074,7 @@ static func invalidate_state_caches(state: EvoTypes.WorldState) -> void: 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"]: + 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", "just_key", "eval_sig"]: if deme.has_meta(k): deme.remove_meta(k) for cid in state.cells.keys(): diff --git a/src/engine/cognitive/ollama_client.gd b/src/engine/cognitive/ollama_client.gd index b11563b..0e1cac9 100644 --- a/src/engine/cognitive/ollama_client.gd +++ b/src/engine/cognitive/ollama_client.gd @@ -456,7 +456,7 @@ static func can_enqueue_jobs() -> bool: static func needs_prefetch() -> bool: _mutex.lock() - var ok := _can_enqueue_locked() and (not _worker_running or _pending_jobs_by_sig.size() < 2) + var ok := _can_enqueue_locked() and (not _worker_running or _pending_jobs_by_sig.size() < 4) _mutex.unlock() return ok @@ -510,13 +510,14 @@ static func sync_and_enqueue_batch( ) -> Dictionary: var live_results: Dictionary = {} var missing_sigs: Array[String] = [] + var avail_slots: int = 0 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 pending_sig in _pending_jobs_by_sig.keys(): + if not buckets.has(pending_sig): + _pending_jobs_by_sig.erase(pending_sig) + avail_slots = maxi(0, 8 - _pending_jobs_by_sig.size()) for sig in buckets.keys(): if _live_signature_cache.has(sig): live_results[sig] = (_live_signature_cache[sig] as Dictionary).duplicate(true) @@ -526,7 +527,7 @@ static func sync_and_enqueue_batch( _mutex.unlock() var new_jobs: Array[Dictionary] = [] - var max_new := mini(3, maxi(0, 8 - _pending_jobs_by_sig.size())) + var max_new := mini(4, avail_slots) for i in range(mini(max_new, missing_sigs.size())): var sig := missing_sigs[i] var rep_deme: EvoTypes.Deme = buckets[sig]["representative"] @@ -550,15 +551,16 @@ static func prefetch_for_reps(reps_by_sig: Dictionary, state: EvoTypes.WorldStat var missing_sigs: Array[String] = [] _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 pending_sig in _pending_jobs_by_sig.keys(): + if not reps_by_sig.has(pending_sig): + _pending_jobs_by_sig.erase(pending_sig) + var avail_slots := maxi(0, 8 - _pending_jobs_by_sig.size()) + var max_prefetch := mini(4, avail_slots) + if can_enq and max_prefetch > 0: 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: + if missing_sigs.size() >= max_prefetch: break _mutex.unlock() diff --git a/src/engine/decision_model.gd b/src/engine/decision_model.gd index d00896b..6161012 100644 --- a/src/engine/decision_model.gd +++ b/src/engine/decision_model.gd @@ -87,7 +87,18 @@ static func blend_with_environmental_weights( 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] + var pacts_hash: int = state.active_pacts.hash() if not state.active_pacts.is_empty() else 0 + var w_key := "%d|%s|%s|%d|%.1f|%d|%d|%d|%d" % [ + state.generation, + state.season, + cell.id, + deme.population, + deme.energy_reserves, + int(cell.current_resources), + int(cell.carrying_capacity), + deme.defense_tier, + pacts_hash + ] 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: @@ -244,7 +255,7 @@ static func evaluate_all_demes( 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] + var just_key := "%d|%s|%s|%s|%.2f" % [state.generation, lang, deme.decision_model_tag, 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( @@ -300,7 +311,7 @@ static func evaluate_all_demes( ) -static func prefetch_for_state(state: EvoTypes.WorldState) -> void: +static func prefetch_for_state(state: EvoTypes.WorldState, update_eval_sig: bool = true) -> void: if state == null or state.demes.is_empty() or not EvoOllamaClient.needs_prefetch(): return var reps_by_sig: Dictionary = {} @@ -309,7 +320,8 @@ static func prefetch_for_state(state: EvoTypes.WorldState) -> void: 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 update_eval_sig: + deme.set_meta("eval_sig", sig) if not reps_by_sig.has(sig): reps_by_sig[sig] = deme if reps_by_sig.size() >= 8: diff --git a/src/engine/env_phase.gd b/src/engine/env_phase.gd index f3d1d81..2b27485 100644 --- a/src/engine/env_phase.gd +++ b/src/engine/env_phase.gd @@ -86,70 +86,16 @@ static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = " cell.current_resources = maxf(cell.current_resources, roundf(cell.carrying_capacity * 0.35)) 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): + cell.deme_ids.erase(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 + EvoDemographyMigration.evacuate_or_merge_displaced_deme(deme, cell, state, lang, "submerge") _prune_stale_pacts(state) @@ -157,7 +103,10 @@ static func run_environmental_phase(state: EvoTypes.WorldState, lang: String = " 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 idx in range(state.chronicle.size() - 1, -1, -1): + var entry: EvoTypes.ChronicleEntry = state.chronicle[idx] + 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 diff --git a/src/engine/turn_engine.gd b/src/engine/turn_engine.gd index be0a2c2..e066f53 100644 --- a/src/engine/turn_engine.gd +++ b/src/engine/turn_engine.gd @@ -7,21 +7,19 @@ const COST_ADJUST_CAPACITY: int = 10 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() - - 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) - +static func _collect_chronicle_summary( + state: EvoTypes.WorldState, + start_idx: int, + phase_num: int, + phase_id: String +) -> Dictionary: var new_entries: Array[EvoTypes.ChronicleEntry] = [] var has_milestone := false var has_macro_outcome := false var milestone_entry: EvoTypes.ChronicleEntry = null var outcome_entry: EvoTypes.ChronicleEntry = null - for i in range(prev_chronicle_count, state.chronicle.size()): + for i in range(maxi(0, start_idx), state.chronicle.size()): var entry := state.chronicle[i] new_entries.append(entry) if entry.event_type in ["baldwin_trait", "cultural_innovation", "allopatric_speciation"]: @@ -31,9 +29,9 @@ static func advance_generation(state: EvoTypes.WorldState, lang: String = "es") has_macro_outcome = true outcome_entry = entry - EvoDecisionModel.prefetch_for_state(state) - return { + "phase": phase_num, + "phase_id": phase_id, "generation": state.generation, "new_entries": new_entries, "has_milestone": has_milestone, @@ -43,6 +41,78 @@ static func advance_generation(state: EvoTypes.WorldState, lang: String = "es") } +static func step_phase_1_environment(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: + var start_idx := state.chronicle.size() + state.set_meta("_turn_start_chronicle_count", start_idx) + state.set_meta("_in_flight_micro_phase", 1) + EvoEnvPhase.run_environmental_phase(state, lang) + EvoDecisionModel.invalidate_state_caches(state) + EvoDecisionModel.prefetch_for_state(state, true) + return _collect_chronicle_summary(state, start_idx, 1, "environment") + + +static func step_phase_2_decisions(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: + var start_idx := state.chronicle.size() + state.set_meta("_in_flight_micro_phase", 2) + EvoDecisionModel.evaluate_all_demes(state, lang, false) + return _collect_chronicle_summary(state, start_idx, 2, "decisions") + + +static func step_phase_3_ecology_borders(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: + var start_idx := state.chronicle.size() + state.set_meta("_in_flight_micro_phase", 3) + EvoBiophysPhase.run_ecology_and_borders_step(state, lang) + return _collect_chronicle_summary(state, start_idx, 3, "ecology_borders") + + +static func step_phase_4_demography_milestones(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: + if state.has_meta("_in_flight_micro_phase"): + state.remove_meta("_in_flight_micro_phase") + var turn_start_idx := state.chronicle.size() + if state.has_meta("_turn_start_chronicle_count"): + turn_start_idx = int(state.get_meta("_turn_start_chronicle_count")) + state.remove_meta("_turn_start_chronicle_count") + EvoBiophysPhase.run_demography_and_migration_step(state, lang) + EvoMilestonePhase.run_milestone_phase(state, lang) + var summary := _collect_chronicle_summary(state, turn_start_idx, 4, "demography_milestones") + EvoDecisionModel.invalidate_state_caches(state) + var cur_season := state.season + state.season = "dry_cold" if cur_season == "wet_warm" else "wet_warm" + EvoDecisionModel.prefetch_for_state(state, false) + state.season = cur_season + EvoDecisionModel.invalidate_state_caches(state) + return summary + + +static func complete_in_flight_turn(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: + var phase := int(state.get_meta("_in_flight_micro_phase", 0)) if state.has_meta("_in_flight_micro_phase") else 0 + if phase == 0: + if state.has_meta("_step3_ctx"): + phase = 3 + elif state.has_meta("_turn_start_chronicle_count"): + phase = 1 + if phase not in [1, 2, 3]: + return {} + if phase < 2: + step_phase_2_decisions(state, lang) + if phase < 3: + step_phase_3_ecology_borders(state, lang) + return step_phase_4_demography_milestones(state, lang) + + +static func flush_pending_micro_phases(state: EvoTypes.WorldState, active_phase: int = 0, lang: String = "es") -> void: + if not state.has_meta("_in_flight_micro_phase") and active_phase in [1, 2, 3] and (state.has_meta("_turn_start_chronicle_count") or state.has_meta("_step3_ctx")): + state.set_meta("_in_flight_micro_phase", active_phase) + complete_in_flight_turn(state, lang) + + +static func advance_generation(state: EvoTypes.WorldState, lang: String = "es") -> Dictionary: + step_phase_1_environment(state, lang) + step_phase_2_decisions(state, lang) + step_phase_3_ecology_borders(state, lang) + return step_phase_4_demography_milestones(state, lang) + + static func _can_afford(state: EvoTypes.WorldState, cost: int) -> bool: return state.unlimited_pressure_mode or state.selective_pressure_points >= cost @@ -53,89 +123,26 @@ static func _spend_points(state: EvoTypes.WorldState, cost: int) -> void: 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): + cell.deme_ids.erase(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 + EvoDemographyMigration.evacuate_or_merge_displaced_deme(deme, cell, state, lang, "submerge_brush") 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): + if fl.from_deme_id != "" and not state.demes.has(fl.from_deme_id) and fl.flow_type != "merge": continue if fl.to_deme_id != "" and not state.demes.has(fl.to_deme_id): continue - if fl.flow_type != "migration": + if fl.flow_type not in ["migration", "merge"]: 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): @@ -150,12 +157,12 @@ static func _displace_submerged_demes(state: EvoTypes.WorldState, lang: String = 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", "")) + 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.get("from_hex_id", "")) - var hb := str(conn.get("to_hex_id", "")) + 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) diff --git a/src/main.gd b/src/main.gd index 0906364..ec0a734 100644 --- a/src/main.gd +++ b/src/main.gd @@ -30,6 +30,7 @@ var is_fast: bool = false var quit_on_close_enabled: bool = true var _play_timer: float = 0.0 var _last_model_revision: int = 0 +var _active_micro_phase: int = 0 func _ready() -> void: @@ -71,23 +72,28 @@ func _exit_tree() -> void: func _process(delta: float) -> void: if world_state == null: return - 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) - world_renderer.refresh_village_decisions() - if bool(async_res.get("dom_changed", true)): - world_renderer.refresh() + if _active_micro_phase in [0, 2]: + 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) + if bool(async_res.get("dom_changed", true)): + world_renderer.refresh() + else: + world_renderer.refresh_village_decisions() if not is_playing: return - var interval: float = FAST_STEP_INTERVAL if is_fast else NORMAL_STEP_INTERVAL + var interval: float = FAST_STEP_INTERVAL if is_fast else (NORMAL_STEP_INTERVAL * 0.25) _play_timer += delta if _play_timer >= interval: - if EvoDecisionModel.has_pending_jobs(): + if (is_fast or _active_micro_phase in [0, 1]) and EvoDecisionModel.has_pending_jobs(): return _play_timer = 0.0 - step_generation() + if is_fast: + step_generation() + else: + _step_micro_phase() func _unhandled_key_input(event: InputEvent) -> void: @@ -137,6 +143,7 @@ func _connect_signals() -> void: hud.preset_selected.connect(func(pid: String) -> void: load_preset(pid)) hud.matchup_changed.connect(_on_matchup_changed) hud.matchup_reset_requested.connect(reset_matchup) + hud.export_json_requested.connect(_on_export_json_requested) hud.state_imported.connect(_on_state_imported) hud.play_pause_toggled.connect(_on_play_pause_toggled) hud.step_generation_requested.connect(step_generation) @@ -165,6 +172,7 @@ func _connect_signals() -> void: func load_preset(preset_id: String, strategy_a_id: String = "", strategy_b_id: String = "") -> void: is_playing = false _play_timer = 0.0 + _set_micro_phase_visuals(0) EvoOllamaClient.clear_cache() var preserved_traits: Dictionary = {} @@ -327,13 +335,64 @@ func _sync_selection_after_world_mutation() -> void: hud._selected_hex_id = world_renderer.selected_hex_id +func _set_micro_phase_visuals(phase: int) -> void: + _active_micro_phase = clampi(phase, 0, 4) + if world_renderer != null: + world_renderer.set_active_micro_phase(_active_micro_phase) + if hud != null: + hud.set_active_micro_phase(_active_micro_phase) + + +func _step_micro_phase() -> void: + if world_state == null: + return + if hud != null and (hud._ollama_startup_blocked or (hud._missing_models_modal != null and hud._missing_models_modal.visible)): + return + var next_phase: int = 1 if (_active_micro_phase <= 0 or _active_micro_phase >= 4) else (_active_micro_phase + 1) + if next_phase == 1: + EvoTurnEngine.step_phase_1_environment(world_state, lang) + _set_micro_phase_visuals(1) + hud.refresh(world_state) + world_renderer.refresh() + elif next_phase == 2: + EvoTurnEngine.step_phase_2_decisions(world_state, lang) + _set_micro_phase_visuals(2) + hud.refresh(world_state) + world_renderer.refresh() + elif next_phase == 3: + EvoTurnEngine.step_phase_3_ecology_borders(world_state, lang) + _set_micro_phase_visuals(3) + _sync_selection_after_world_mutation() + hud.refresh(world_state) + world_renderer.refresh() + elif next_phase == 4: + var res := EvoTurnEngine.step_phase_4_demography_milestones(world_state, lang) + _set_micro_phase_visuals(4) + 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"])): + is_playing = false + hud.set_playing_state(false) + hud.refresh(world_state) + world_renderer.refresh() + + func step_generation() -> void: _play_timer = 0.0 if world_state == null: return 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) + var res: Dictionary + if _active_micro_phase in [1, 2, 3]: + if _active_micro_phase < 2: + EvoTurnEngine.step_phase_2_decisions(world_state, lang) + if _active_micro_phase < 3: + EvoTurnEngine.step_phase_3_ecology_borders(world_state, lang) + res = EvoTurnEngine.step_phase_4_demography_milestones(world_state, lang) + else: + res = EvoTurnEngine.advance_generation(world_state, lang) + _set_micro_phase_visuals(0) hud.mark_generation_advanced() _sync_selection_after_world_mutation() @@ -345,9 +404,27 @@ func step_generation() -> void: world_renderer.refresh() +func _finish_in_flight_micro_phases() -> void: + if world_state == null: + return + if world_state.has_meta("_in_flight_micro_phase") or world_state.has_meta("_step3_ctx") or world_state.has_meta("_turn_start_chronicle_count"): + EvoTurnEngine.complete_in_flight_turn(world_state, lang) + _sync_selection_after_world_mutation() + if _active_micro_phase != 0: + _set_micro_phase_visuals(0) + + +func _on_export_json_requested(_file_path: String) -> void: + _finish_in_flight_micro_phases() + _sync_selection_after_world_mutation() + hud.refresh(world_state) + world_renderer.refresh() + + func _apply_brush_at_hex(hex_id: String) -> void: if world_state == null or not world_state.cells.has(hex_id): return + _finish_in_flight_micro_phases() var tool_id := hud.get_active_tool() var sub_mode := hud.get_active_sub_mode() var rad := hud.get_brush_radius() @@ -412,6 +489,7 @@ func _on_world_deme_clicked(deme_id: String, hex_id: String) -> void: func _on_state_imported(new_state: EvoTypes.WorldState) -> void: is_playing = false _play_timer = 0.0 + _set_micro_phase_visuals(0) world_state = new_state hud.set_playing_state(false) if hud._world_state != world_state: @@ -484,7 +562,6 @@ func _on_settings_changed(auto_pause: bool, unlimited_pressure: bool, free_sandb func _on_decision_models_changed() -> void: if world_renderer != null: - world_renderer.refresh_village_decisions() world_renderer.refresh() @@ -507,6 +584,7 @@ func _on_tool_changed(tool_id: String, _sub_mode: String, brush_radius: int) -> func _on_sea_level_change_requested(delta: float) -> void: if world_state == null: return + _finish_in_flight_micro_phases() if EvoTurnEngine.apply_adjust_sea_level(world_state, delta, lang): EvoDecisionModel.invalidate_state_caches(world_state) EvoDecisionModel.evaluate_all_demes(world_state, lang) @@ -549,6 +627,7 @@ func _on_lineage_highlight_requested(lineage_id: String) -> void: func _on_inject_trait_requested(_deme_id: String, _trait_id: String) -> void: if world_state != null: + _finish_in_flight_micro_phases() EvoDecisionModel.invalidate_state_caches(world_state) EvoDecisionModel.evaluate_all_demes(world_state, lang) EvoDecisionModel.prefetch_for_state(world_state) diff --git a/src/ui/hud/hud_inspector_panel.gd b/src/ui/hud/hud_inspector_panel.gd index 6a4be6a..0028294 100644 --- a/src/ui/hud/hud_inspector_panel.gd +++ b/src/ui/hud/hud_inspector_panel.gd @@ -23,7 +23,7 @@ static func build_right_scroll_inspector(hud: Control) -> void: hud._inspector_frame.anchor_bottom = 0.0 hud._inspector_frame.offset_left = -356 hud._inspector_frame.offset_right = -10 - hud._inspector_frame.offset_top = 132 + hud._inspector_frame.offset_top = 158 hud._inspector_frame.offset_bottom = 865 hud._inspector_frame.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(7)) hud.add_child(hud._inspector_frame) diff --git a/src/ui/hud/hud_top_bar.gd b/src/ui/hud/hud_top_bar.gd index c7840da..cc64813 100644 --- a/src/ui/hud/hud_top_bar.gd +++ b/src/ui/hud/hud_top_bar.gd @@ -301,7 +301,7 @@ static func build_top_right_clock(hud: Control) -> void: clock_frame.offset_left = -356 clock_frame.offset_right = -10 clock_frame.offset_top = 10 - clock_frame.offset_bottom = 126 + clock_frame.offset_bottom = 150 clock_frame.add_theme_stylebox_override("panel", EvoUiStyle.make_wood_frame_box(7)) hud.add_child(clock_frame) @@ -417,6 +417,40 @@ static func build_top_right_clock(hud: Control) -> void: EvoUiStyle.style_progress_bar(hud._pressure_bar, Color("#22c55e")) mana_vbox.add_child(hud._pressure_bar) + # Row 4: Compact 4-Pip Turn Phase Bar + var phase_row := HBoxContainer.new() + phase_row.add_theme_constant_override("separation", 3) + dial_vbox.add_child(phase_row) + + hud._phase_pip_panels.clear() + hud._phase_pip_labels.clear() + hud._phase_pip_active_styles.clear() + var active_colors := [ + Color("#10b981"), + Color("#38bdf8"), + Color("#ef4444"), + Color("#f59e0b") + ] + for col in active_colors: + hud._phase_pip_active_styles.append(EvoUiStyle.make_parchment_card((col as Color).darkened(0.55), EvoUiStyle.COLOR_GOLD_BRIGHT, 2)) + hud._phase_pip_inactive_style = EvoUiStyle.make_parchment_card(EvoUiStyle.COLOR_PARCHMENT_ALT, EvoUiStyle.COLOR_GOLD_BEVEL, 2) + for p_idx in range(4): + var pip_panel := PanelContainer.new() + pip_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + pip_panel.custom_minimum_size = Vector2(0, 18) + pip_panel.add_theme_stylebox_override("panel", hud._phase_pip_inactive_style) + phase_row.add_child(pip_panel) + + var pip_lbl := Label.new() + pip_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + pip_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + pip_lbl.clip_text = true + pip_lbl.add_theme_font_size_override("font_size", 9) + pip_panel.add_child(pip_lbl) + + hud._phase_pip_panels.append(pip_panel) + hud._phase_pip_labels.append(pip_lbl) + static func build_settings_popup(hud: Control) -> void: hud._settings_popup = PanelContainer.new() @@ -767,6 +801,32 @@ static func update_static_labels(hud: Control) -> void: if hud._villages_title_lbl != null: hud._villages_title_lbl.text = EvoUiStyle.tr_text(hud._lang, "🏘️ Aldeas Vivas del Valle", "🏘️ Living Valley Villages") + var phase_texts_es := ["1. Clima", "2. Mente", "3. Choque", "4. Linaje"] + var phase_texts_en := ["1. Env", "2. Mind", "3. Clash", "4. Lineage"] + for i in range(mini(4, hud._phase_pip_labels.size())): + hud._phase_pip_labels[i].text = phase_texts_en[i] if hud._lang == "en" else phase_texts_es[i] + update_phase_bar_visuals(hud) + + +static func update_phase_bar_visuals(hud: Control) -> void: + var active_phase: int = hud._active_micro_phase + if hud._phase_pip_active_styles.size() < 4: + hud._phase_pip_active_styles.clear() + for col in [Color("#10b981"), Color("#38bdf8"), Color("#ef4444"), Color("#f59e0b")]: + hud._phase_pip_active_styles.append(EvoUiStyle.make_parchment_card((col as Color).darkened(0.55), EvoUiStyle.COLOR_GOLD_BRIGHT, 2)) + if hud._phase_pip_inactive_style == null: + hud._phase_pip_inactive_style = EvoUiStyle.make_parchment_card(EvoUiStyle.COLOR_PARCHMENT_ALT, EvoUiStyle.COLOR_GOLD_BEVEL, 2) + for i in range(mini(4, hud._phase_pip_panels.size())): + var panel: PanelContainer = hud._phase_pip_panels[i] + var lbl: Label = hud._phase_pip_labels[i] + var is_active: bool = (active_phase == i + 1) + if is_active: + panel.add_theme_stylebox_override("panel", hud._phase_pip_active_styles[i]) + lbl.add_theme_color_override("font_color", Color("#fef08a")) + else: + panel.add_theme_stylebox_override("panel", hud._phase_pip_inactive_style) + lbl.add_theme_color_override("font_color", EvoUiStyle.COLOR_WOOD_DARK if active_phase == 0 else EvoUiStyle.COLOR_SEPIA) + static func update_top_bar_state(hud: Control) -> void: if hud._world_state == null: @@ -846,7 +906,7 @@ 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 decision_models: Array = status["decision_models"] var p_model := EvoUserSettings.primary_model var s_model := EvoUserSettings.secondary_model diff --git a/src/ui/main_hud.gd b/src/ui/main_hud.gd index fa7fb44..36eed8f 100644 --- a/src/ui/main_hud.gd +++ b/src/ui/main_hud.gd @@ -89,6 +89,11 @@ var _speed_btn: Button var _pressure_bar: ProgressBar var _pressure_label: Label var _settings_btn: Button +var _active_micro_phase: int = 0 +var _phase_pip_panels: Array[PanelContainer] = [] +var _phase_pip_labels: Array[Label] = [] +var _phase_pip_active_styles: Array[StyleBoxFlat] = [] +var _phase_pip_inactive_style: StyleBoxFlat = null # Settings, Camera & Ollama Models Diagnostic Popup var _settings_popup: PanelContainer @@ -438,6 +443,11 @@ func set_playing_state(is_playing: bool) -> void: _update_static_labels() +func set_active_micro_phase(phase: int) -> void: + _active_micro_phase = clampi(phase, 0, 4) + EvoHudTopBar.update_phase_bar_visuals(self) + + func set_language(lang: String) -> void: _lang = lang refresh_strategies(_world_state, _lang) @@ -585,6 +595,9 @@ func _on_village_cycle_pressed(step: int) -> void: func _inject_trait_into_deme(deme_id: String, trait_id: String) -> void: if _world_state != null: + EvoTurnEngine.flush_pending_micro_phases(_world_state, _active_micro_phase, _lang) + if _active_micro_phase in [1, 2, 3]: + set_active_micro_phase(0) EvoCatalog.inject_trait_into_deme(_world_state, deme_id, trait_id, _lang) inject_trait_requested.emit(deme_id, trait_id) refresh(_world_state) @@ -610,7 +623,7 @@ func _on_toggle_inspector_minimize() -> void: _inspector_minimized = not _inspector_minimized _inspector_body_vbox.visible = not _inspector_minimized _inspector_minimize_btn.text = "▸" if _inspector_minimized else "▾" - _inspector_frame.offset_bottom = 228 if _inspector_minimized else 865 + _inspector_frame.offset_bottom = 254 if _inspector_minimized else 865 if not _inspector_minimized: _rebuild_active_inspector_sheet() @@ -649,6 +662,9 @@ func _on_preset_selected(index: int) -> void: func _on_export_pressed() -> void: if _world_state != null: + EvoTurnEngine.flush_pending_micro_phases(_world_state, _active_micro_phase, _lang) + if _active_micro_phase in [1, 2, 3]: + set_active_micro_phase(0) var json_str := JSON.stringify(_world_state.to_dict(), "\t") var f := FileAccess.open(DEFAULT_EXPORT_PATH, FileAccess.WRITE) if f != null: @@ -759,6 +775,7 @@ func _commit_context_window_text(raw_text: String) -> void: EvoUserSettings.save_settings() EvoOllamaClient.clear_cache() if _world_state != null: + EvoDecisionModel.invalidate_state_caches(_world_state) EvoDecisionModel.evaluate_all_demes(_world_state, _lang) _update_top_bar_state() if _context_window_input != null: @@ -769,6 +786,7 @@ func _apply_decision_models_update() -> void: EvoOllamaClient.clear_cache() EvoDecisionModel.check_ollama_status(true) if _world_state != null: + EvoDecisionModel.invalidate_state_caches(_world_state) EvoDecisionModel.reassign_world_models(_world_state) EvoDecisionModel.evaluate_all_demes(_world_state, _lang) _update_static_labels() diff --git a/src/world/pixel_art_gen.gd b/src/world/pixel_art_gen.gd index 20b5178..713b3be 100644 --- a/src/world/pixel_art_gen.gd +++ b/src/world/pixel_art_gen.gd @@ -122,18 +122,12 @@ static func ensure_all_assets(force_rebuild: bool = false) -> void: if _initialized and not force_rebuild: return _initialized = true - DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://assets/sprites/terrain")) - DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://assets/sprites/villages")) - DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://assets/sprites/ui")) - - for s_name in HEX_TILE_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/terrain/%s.png" % s_name, force_rebuild) - for s_name in TERRAIN_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/terrain/%s.png" % s_name, force_rebuild) - for s_name in VILLAGE_SPRITES: - _generate_and_register(s_name, "res://assets/sprites/villages/%s.png" % s_name, force_rebuild) + if force_rebuild: + _image_cache.clear() + _texture_cache.clear() + _villager_colored_cache.clear() 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) static func get_texture(sprite_name: String) -> ImageTexture: @@ -174,17 +168,8 @@ static func get_villager_texture(pose: String, tunic_color: Color) -> ImageTextu return tex -static func _generate_and_register(sprite_name: String, file_path: String, force_rebuild: bool = false) -> void: - var abs_path := ProjectSettings.globalize_path(file_path) - var img: Image - 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: - img = _build_sprite_image(sprite_name) - img.save_png(abs_path) - else: - img = _build_sprite_image(sprite_name) - img.save_png(abs_path) +static func _generate_and_register(sprite_name: String) -> void: + var img := _build_sprite_image(sprite_name) _image_cache[sprite_name] = img _texture_cache[sprite_name] = ImageTexture.create_from_image(img) diff --git a/src/world/render/prop_flora_renderer.gd b/src/world/render/prop_flora_renderer.gd index ffd9628..bf2c30d 100644 --- a/src/world/render/prop_flora_renderer.gd +++ b/src/world/render/prop_flora_renderer.gd @@ -10,9 +10,9 @@ const STATUS_COLORS: Dictionary = { "starving": Color("#fb7185") } -const NON_SWAYING_SPRITES: Array[String] = ["mossy_rock", "fallen_log", "rock_mountain", "tree_stump", "soil_crack"] +const NON_SWAYING_SPRITES: Array[String] = ["mossy_rock", "fallen_log", "rock_mountain", "tree_stump", "soil_crack", "ruins"] const VILLAGER_POSES: Array[String] = ["villager_idle", "villager_walk1", "villager_walk2"] -const TRAVELER_ROAD_KINDS: Array[String] = ["collab", "bridge", "pact", "tribute", "merge"] +const TRAVELER_ROAD_KINDS: Array[String] = ["collab", "bridge", "pact", "tribute", "merge", "symbiosis"] const BIOME_FLORA_PALETTES: Dictionary = { "tropical_forest": [ @@ -44,31 +44,65 @@ const BIOME_FLORA_PALETTES: Dictionary = { } const DEFAULT_FLORA_PALETTE: Array[String] = ["tree_savanna", "wildflowers", "tall_grass"] +const ARCHETYPE_STYLE_MAP: Dictionary = { + "forage": "forage_lodge", + "cooperate": "amphitheater_dome", + "exchange": "twin_canopy_market", + "dispute": "horned_war_keep", + "war": "horned_war_keep", + "raid": "horned_war_keep", + "betrayal": "horned_war_keep", + "overexploit": "furnace_rig", + "migrate": "nomad_pavilion", + "innovate": "astronomical_spire" +} + +const TRAIT_MODULE_MAP: Dictionary = { + "bio-encephalization": {"structure_type": "monolith", "color": "#38bdf8"}, + "bio-social-instinct": {"structure_type": "hearth_circle", "color": "#fb923c"}, + "bio-r-strategy": {"structure_type": "brood_totem", "color": "#a3e635"}, + "bio-k-strategy": {"structure_type": "terraced_sanctuary", "color": "#2dd4bf"}, + "cul-warrior-expansion": {"structure_type": "war_brazier", "color": "#ef4444"}, + "cul-communal-conservation": {"structure_type": "grove_shrine", "color": "#10b981"}, + "cul-interdeme-barter": {"structure_type": "caravan_depot", "color": "#f59e0b"}, + "cul-symbolic-storage": {"structure_type": "granary_silo", "color": "#eab308"}, + "cul-extraction-tools": {"structure_type": "lithic_workshop", "color": "#94a3b8"} +} + class Prop: var hex_id: String = "" + var kind: String = "" var world_xy: Vector2 var top_z: float var screen_offset: Vector2 var anchor_offset: Vector2 + var foot: Vector2 var y: float - var texture: Texture2D + var scale_val: float = 1.0 + var texture: Texture2D = null var rect: Rect2 var modulate: Color var trim_color: Color = Color.TRANSPARENT + var primary_color: Color = Color.WHITE var sways: bool = false var sway_phase: float = 0.0 + var anim_step: int = 0 - func _init(p_texture: Texture2D, scale: float, anchor_y: float, p_modulate: Color) -> void: + func _init(p_kind: String, p_scale: float, anchor_y: float, p_modulate: Color, p_texture: Texture2D = null) -> void: + kind = p_kind + scale_val = p_scale texture = p_texture - var size := p_texture.get_size() * scale + var base_sz := p_texture.get_size() if p_texture != null else Vector2(24.0, 28.0) + var size := base_sz * p_scale anchor_offset = Vector2(size.x * 0.5, size.y * anchor_y) rect = Rect2(Vector2.ZERO, size) modulate = p_modulate - func set_foot(foot: Vector2) -> void: - rect.position = foot - anchor_offset - y = foot.y + func set_foot(p_foot: Vector2) -> void: + foot = p_foot + rect.position = p_foot - anchor_offset + y = p_foot.y func project(ground: Transform2D, lift: float) -> void: set_foot(ground * world_xy + Vector2(0.0, -top_z * lift) + screen_offset) @@ -82,17 +116,127 @@ static func resolve_deme_strategy_color(world_state: EvoTypes.WorldState, deme: return fallback -static func get_villager_textures_for_color(r: Node2D, tunic_col: Color) -> Array[ImageTexture]: - var hex_key := tunic_col.to_html(false) - if r._villager_tex_sets.has(hex_key): - return r._villager_tex_sets[hex_key] - var tex_set: Array[ImageTexture] = [ - EvoPixelArtGen.get_villager_texture(VILLAGER_POSES[0], tunic_col), - EvoPixelArtGen.get_villager_texture(VILLAGER_POSES[1], tunic_col), - EvoPixelArtGen.get_villager_texture(VILLAGER_POSES[2], tunic_col) - ] - r._villager_tex_sets[hex_key] = tex_set - return tex_set +static func resolve_dominant_archetype(state: EvoTypes.WorldState, deme: EvoTypes.Deme) -> String: + if state != null and state.decisions_catalog.has(deme.last_dominant_decision_id): + var dec: EvoTypes.DecisionDef = state.decisions_catalog[deme.last_dominant_decision_id] + return dec.archetype + var did := deme.last_dominant_decision_id + if did.contains("war"): + return "war" + if did.contains("raid"): + return "raid" + if did.contains("pact"): + return "pact" + if did.contains("betrayal"): + return "betrayal" + if did.contains("overexploit"): + return "overexploit" + if did.contains("migration") or did.contains("migrate"): + return "migrate" + if did.contains("cooperate"): + return "cooperate" + if did.contains("exchange"): + return "exchange" + if did.contains("dispute"): + return "dispute" + if did.contains("innovate"): + return "innovate" + return "forage" + + +static func get_deme_dominant_archetype(r: Node2D, deme: EvoTypes.Deme) -> String: + return resolve_dominant_archetype(r.world_state if r != null else null, deme) + + +static func build_settlement_morphology( + deme: EvoTypes.Deme, + cell: EvoTypes.HexCell, + state: EvoTypes.WorldState, + lang: String = "es" +) -> Dictionary: + var arch := resolve_dominant_archetype(state, deme) + var tier := deme.get_village_tier() + var vital_status := deme.get_vital_status() + var style: String = ARCHETYPE_STYLE_MAP.get(arch, "forage_lodge") + + var raw_traits: Array[Dictionary] = [] + for tid in deme.biological_trait_ids: + if raw_traits.size() >= 6: + break + raw_traits.append({"trait_id": tid, "category": "biological"}) + for tid in deme.cultural_trait_ids: + if raw_traits.size() >= 6: + break + raw_traits.append({"trait_id": tid, "category": "cultural"}) + + var ring_1_traits: Array[Dictionary] = [] + var t_count := raw_traits.size() + for i in range(t_count): + var entry: Dictionary = raw_traits[i] + var tid: String = entry["trait_id"] + var cat: String = entry["category"] + var spec: Dictionary + if TRAIT_MODULE_MAP.has(tid): + spec = TRAIT_MODULE_MAP[tid] + elif tid.begins_with("emergent-"): + spec = {"structure_type": "crystal_obelisk", "color": "#c084fc"} + elif cat == "biological": + spec = {"structure_type": "monolith", "color": "#34d399"} + else: + spec = {"structure_type": "grove_shrine", "color": "#f472b6"} + var color_str := str(spec["color"]) + var color_val := Color.from_string(color_str, Color("#38bdf8")) + var t_label := tid + if state != null and state.traits_catalog.has(tid): + t_label = EvoI18n.trait_name(lang, state.traits_catalog[tid]) + if t_label.length() > 13: + t_label = t_label.substr(0, 12) + "…" + ring_1_traits.append({ + "angle_deg": fmod(float(i) * (360.0 / float(maxi(1, t_count))) + 32.0, 360.0), + "trait_id": tid, + "category": cat, + "structure_type": str(spec["structure_type"]), + "color": color_str, + "trait_color": color_val, + "trait_label": t_label + }) + + var wall_type := "none" + if deme.defense_tier == 1: + wall_type = "palisade" + elif deme.defense_tier == 2: + wall_type = "stone_wall" + elif deme.defense_tier >= 3: + wall_type = "fortress_bastion" + + var k_ratio := clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) if cell != null else 1.0 + var res_ratio := clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity), 0.0, 1.5) if cell != null else 1.0 + + return { + "deme_id": deme.id, + "hex_id": cell.id if cell != null else deme.hex_id, + "tier": tier, + "archetype": arch, + "vital_status": vital_status, + "defense_tier": deme.defense_tier, + "is_merged": deme.merged_count > 0, + "ring_0_core": { + "archetype": arch, + "tier": tier, + "style": style, + "vital_status": vital_status + }, + "ring_1_traits": ring_1_traits, + "ring_2_defense": { + "defense_tier": deme.defense_tier, + "wall_type": wall_type + }, + "biome_vitality": { + "k_ratio": k_ratio, + "res_ratio": res_ratio, + "degraded": k_ratio < 0.75 + } + } static func collect_villages(r: Node2D) -> Array[Dictionary]: @@ -102,66 +246,58 @@ static func collect_villages(r: Node2D) -> Array[Dictionary]: var hex_list: Array = r._sorted_hex_ids if not r._sorted_hex_ids.is_empty() else state.cells.keys() for hex_id in hex_list: var cell: EvoTypes.HexCell = state.cells[hex_id] - if r._is_water(cell): + if r._is_water(cell) or cell.deme_ids.is_empty(): 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 + 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 v_world := world_xy + var scale_factor := 1.0 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 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 sub_text := "%d pop" % deme.population if r.lang == "en" else "%d hab" % deme.population + 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) + var morphology := build_settlement_morphology(deme, cell, state, r.lang) + + 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": morphology["archetype"], + "morphology": morphology, + "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_visuals": strat_vis, + "strat_color": strat_color, + "strategy_color": strat_color, + "secondary_color": Color.from_string(deme.secondary_color, Color("#fbbf24")), + "villager_count": clampi(2 + tier + int(deme.population / 110), 3, 7), + "sub_text": sub_text, + "hit_rect": Rect2(v_pos - Vector2(36, 32) * scale_factor, Vector2(72, 56) * scale_factor) + }) return villages @@ -172,232 +308,335 @@ static func get_village_building_layout( cultural_count: int, archetype: String ) -> Array[Dictionary]: - var items: Array[Dictionary] = [] - match tier: - 1: - items.append({"sprite": "tier1_tent", "offset": Vector2(-48, -18) * sf, "scale": 2.50 * sf}) - 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}) - 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": "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}) - - 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 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 items: Array[Dictionary] = [ + {"sprite": "act_" + archetype, "offset": Vector2(0, -12) * sf, "scale": (2.2 + float(tier) * 0.12) * sf} + ] + if has_culture and cultural_count > 0: + items.append({"sprite": "cultural_totem", "offset": Vector2(-42, 20) * sf, "scale": 2.1 * sf}) 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 ci: Node2D = r._network_layer var deme: EvoTypes.Deme = village["deme"] + var morphology: Dictionary = village["morphology"] 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 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) - var layout := get_village_building_layout( - int(village["tier"]), - sf, - 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) - + var plaza_center: Vector2 = v_pos + Vector2(0.0, 6.0 * sf) + var strat_color: Color = village["strategy_color"] + var strat_hi := strat_color.lightened(0.28) 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 - 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) - if is_merged: - EvoWorldOverlayRenderer.draw_ellipse_outline( - ci, - plaza_center, - plinth_r * 0.64, - plinth_r * 0.64 * 0.58, - Color("#fbbf24") * modulate_col, - 2.4 * sf - ) - - # 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( - ci, - plaza_center, - mosaic_outer_r, - mosaic_outer_r * 0.58, - strat_color.darkened(0.22) * modulate_col - ) - EvoWorldOverlayRenderer.draw_ellipse_filled( - ci, - plaza_center, - mosaic_inner_r, - mosaic_inner_r * 0.58, - strat_color * modulate_col - ) - EvoWorldOverlayRenderer.draw_ellipse_outline( - ci, - plaza_center, - mosaic_outer_r, - mosaic_outer_r * 0.58, - strat_hi * modulate_col, - 2.2 * sf - ) - EvoWorldOverlayRenderer.draw_ellipse_filled( - ci, - plaza_center, - plinth_r * 0.14, - plinth_r * 0.14 * 0.58, - Color("#fef3c7") * modulate_col - ) + var tier: int = int(morphology["tier"]) + var arch: String = str(morphology["archetype"]) + var vital_status: String = str(morphology["vital_status"]) + var weights: Vector3 = r.get_zoom_tier_weights() + + # Outer earthen terrace pad + var plinth_r := (56.0 if is_merged else 51.0) * sf + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, plinth_r + 16.0 * sf, (plinth_r + 16.0 * sf) * 0.58, Color(0.28, 0.20, 0.13, 0.36) * modulate_col) + + # Ring 2: Defense Perimeter (palisade / stone_wall / fortress_bastion) + _draw_ring_2_defense(ci, plaza_center, plinth_r + 10.0 * sf, def_tier, strat_color, modulate_col, sf) + + # Ring 0: Central stone plaza & strategy mosaic + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, plinth_r + 4.0 * sf, (plinth_r + 4.0 * sf) * 0.58, Color("#4d3522") * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, plinth_r, plinth_r * 0.58, Color("#bfa074") * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, plinth_r * 0.78, plinth_r * 0.78 * 0.58, Color("#d8be98") * modulate_col) - 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) - - # 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 wave := sin(r._anim_time * 4.2 + v_pos.x * 0.04) * 2.0 * sf - 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) - ]) - 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) - ]) - 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) - ]) - 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 + var sec_col: Color = village["secondary_color"] + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, plinth_r * 0.86, plinth_r * 0.86 * 0.58, sec_col * modulate_col, 2.8 * sf) + + # Inner Strategy Mosaic Carpet Ring + var mosaic_r := plinth_r * 0.54 + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, mosaic_r, mosaic_r * 0.58, strat_color.darkened(0.26) * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, plaza_center, mosaic_r * 0.70, mosaic_r * 0.70 * 0.58, strat_color * modulate_col) + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, mosaic_r, mosaic_r * 0.58, strat_hi * modulate_col, 2.0 * sf) + + # Vital status ring + var status_ring_r := plinth_r + (13.5 if def_tier > 0 else 5.5) * sf + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, status_ring_r, status_ring_r * 0.58, status_col * modulate_col, 3.0) + + # Ring 1: Radial Trait Crown structures (visible in Meso & Micro, full fidelity when micro_w > 0.05) + var trait_vis := clampf(weights.y * 0.85 + weights.z * 1.0, 0.0, 1.0) + if trait_vis > 0.05: + var ring_1_traits: Array = morphology["ring_1_traits"] + var ring1_rx := (plinth_r * 0.84) * sf + var ring1_ry := ring1_rx * 0.58 + for t_mod in ring_1_traits: + var ang := deg_to_rad(float(t_mod["angle_deg"]) - 90.0) + var t_pos := plaza_center + Vector2(cos(ang) * ring1_rx, sin(ang) * ring1_ry) + ci.draw_line(plaza_center.lerp(t_pos, 0.35), t_pos, Color("#8c6d46") * modulate_col, 3.0 * sf) + _draw_ring_1_trait_module(ci, t_pos, str(t_mod["structure_type"]), t_mod["trait_color"] as Color, sf * (0.82 + 0.18 * weights.z), modulate_col) + + # Ring 0: Central Archetype Citadel / Hall + _draw_ring_0_citadel(ci, plaza_center + Vector2(0.0, -4.0 * sf), arch, tier, vital_status, strat_color, strat_hi, modulate_col, sf) + + # Strategy Pennant Flag Pole + var pole_base := plaza_center + Vector2(-24.0 * sf, 4.0 * sf) + var pole_top := pole_base + Vector2(0.0, -28.0 * sf) + ci.draw_line(pole_base, pole_top, Color("#4a2810") * modulate_col, 2.8 * sf) + ci.draw_circle(pole_top, 2.4 * sf, Color("#fbbf24") * modulate_col) + var wave := sin(v_pos.x * 0.04) * 2.2 * sf + ci.draw_colored_polygon(PackedVector2Array([ + pole_top + Vector2(1.0 * sf, 1.0 * sf), + pole_top + Vector2(18.0 * sf, 4.5 * sf + wave), + pole_top + Vector2(1.0 * sf, 10.5 * sf) + ]), strat_color * modulate_col) + + if bool(village["in_siege"]): + var siege_r := status_ring_r + 6.0 * sf + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, siege_r, siege_r * 0.58, Color(0.98, 0.32, 0.12, 0.85) * modulate_col, 3.2 * sf) + + if deme.id == r.selected_deme_id: + var sel_r := status_ring_r + 5.5 * sf + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, plaza_center, sel_r, sel_r * 0.58, C_SELECTION, 4.0) + elif deme.id == r.hovered_deme_id: + var hov_r := status_ring_r + 4.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.6) + + +static func _draw_ring_2_defense( + ci: CanvasItem, + center: Vector2, + def_r: float, + def_tier: int, + strat_color: Color, + mod_col: Color, + sf: float +) -> void: + if def_tier <= 0: + return + var ry := def_r * 0.58 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: + # Timber Palisade Ring + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, center, def_r, ry, Color("#5c3317") * mod_col, 3.6 * sf) + for i in range(24): + var ang := TAU * float(i) / 24.0 + if absf(ang - PI * 0.5) < 0.22: 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) + var base := center + Vector2(cos(ang) * def_r, sin(ang) * ry) + var top := base + Vector2(0.0, -7.0 * sf) + ci.draw_line(base, top, Color("#78350f") * mod_col, 2.6 * sf) + ci.draw_circle(top, 1.4 * sf, Color("#fde68a") * mod_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: + # Crenellated Stone Rampart Ring + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, center, def_r, ry, Color("#334155") * mod_col, 6.2 * sf) + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, center, def_r, ry, Color("#94a3b8") * mod_col, 3.6 * sf) + for i in range(20): + var ang := TAU * float(i) / 20.0 + if absf(ang - PI * 0.5) < 0.22: 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: + var pt := center + Vector2(cos(ang) * def_r, sin(ang) * ry) + Vector2(0.0, -2.5 * sf) + ci.draw_rect(Rect2(pt - Vector2(2.2 * sf, 2.2 * sf), Vector2(4.4 * sf, 3.8 * sf)), Color("#cbd5e1") * mod_col, true) + else: + # Tier 3 Bastioned Fortress Wall with 6 Corner Towers + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, center, def_r, ry, Color("#1e293b") * mod_col, 7.8 * sf) + EvoWorldOverlayRenderer.draw_ellipse_outline(ci, center, def_r, ry, Color("#94a3b8") * mod_col, 4.6 * sf) + for i in range(24): + var ang := TAU * float(i) / 24.0 + if absf(ang - PI * 0.5) < 0.20: 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 - 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) + var pt := center + Vector2(cos(ang) * def_r, sin(ang) * ry) + Vector2(0.0, -2.8 * sf) + ci.draw_rect(Rect2(pt - Vector2(2.4 * sf, 2.4 * sf), Vector2(4.8 * sf, 4.2 * sf)), Color("#e2e8f0") * mod_col, true) + for t_i in range(6): + var b_ang := deg_to_rad(float(t_i) * 60.0 - 30.0) + var b_pt := center + Vector2(cos(b_ang) * def_r, sin(b_ang) * ry) + ci.draw_circle(b_pt, 6.8 * sf, Color("#334155") * mod_col) + ci.draw_circle(b_pt + Vector2(0.0, -3.0 * sf), 5.2 * sf, Color("#cbd5e1") * mod_col) + ci.draw_circle(b_pt + Vector2(0.0, -4.5 * sf), 3.4 * sf, strat_color * mod_col) + ci.draw_circle(b_pt + Vector2(0.0, -5.5 * sf), 1.6 * sf, Color("#fbbf24") * mod_col) + + +static func _draw_ring_0_citadel( + ci: CanvasItem, + pos: Vector2, + arch: String, + tier: int, + vital_status: String, + strat_color: Color, + strat_hi: Color, + mod_col: Color, + sf: float +) -> void: + var t_scale := (0.88 + float(clampi(tier, 1, 5)) * 0.10) * sf + # Flanking wing pavilions for higher tiers + if tier >= 2: + var w_off := Vector2(20.0 * t_scale, 4.0 * t_scale) + for side in [-1.0, 1.0]: + if side > 0.0 and tier < 3: + continue + var wp := pos + Vector2(w_off.x * side, w_off.y) + ci.draw_rect(Rect2(wp - Vector2(7.0, 10.0) * t_scale, Vector2(14.0, 11.0) * t_scale), Color("#7c5c3b") * mod_col, true) + ci.draw_colored_polygon(PackedVector2Array([ + wp + Vector2(-9.0, -9.0) * t_scale, + wp + Vector2(0.0, -17.0) * t_scale, + wp + Vector2(9.0, -9.0) * t_scale + ]), strat_color.darkened(0.18) * mod_col) + + match arch: + "cooperate": + # Concentric Amphitheater Dome + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos, 18.0 * t_scale, 11.0 * t_scale, Color("#e2e8f0") * mod_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(0.0, -5.0 * t_scale), 14.0 * t_scale, 9.0 * t_scale, strat_color * mod_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(0.0, -9.0 * t_scale), 8.5 * t_scale, 5.5 * t_scale, strat_hi * mod_col) + ci.draw_circle(pos + Vector2(0.0, -12.0 * t_scale), 3.0 * t_scale, Color("#fde047") * mod_col) + "exchange": + # Twin-Canopy Market Pavilion + for side in [-1.0, 1.0]: + var mp := pos + Vector2(side * 8.5 * t_scale, 0.0) + ci.draw_rect(Rect2(mp - Vector2(6.5, 11.0) * t_scale, Vector2(13.0, 12.0) * t_scale), Color("#92400e") * mod_col, true) + ci.draw_colored_polygon(PackedVector2Array([ + mp + Vector2(-9.0, -10.0) * t_scale, + mp + Vector2(0.0, -20.0) * t_scale, + mp + Vector2(9.0, -10.0) * t_scale + ]), (strat_color if side < 0.0 else strat_hi) * mod_col) + ci.draw_line(pos + Vector2(-8.5, -15.0) * t_scale, pos + Vector2(8.5, -15.0) * t_scale, Color("#fbbf24") * mod_col, 2.4 * t_scale) + "dispute", "war", "raid", "betrayal": + # Horned Basalt War-Keep + var keep_rect := Rect2(pos - Vector2(13.0, 18.0) * t_scale, Vector2(26.0, 20.0) * t_scale) + ci.draw_rect(keep_rect, Color("#334155") * mod_col, true) + ci.draw_rect(keep_rect, Color("#1e293b") * mod_col, false, 2.0) + # Horned battlements + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-13.0, -18.0) * t_scale, + pos + Vector2(-18.0, -28.0) * t_scale, + pos + Vector2(-7.0, -18.0) * t_scale + ]), strat_color * mod_col) + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(13.0, -18.0) * t_scale, + pos + Vector2(18.0, -28.0) * t_scale, + pos + Vector2(7.0, -18.0) * t_scale + ]), strat_color * mod_col) + ci.draw_rect(Rect2(pos - Vector2(5.0, 9.0) * t_scale, Vector2(10.0, 11.0) * t_scale), Color("#ef4444") * mod_col, true) + "overexploit": + # Extraction Furnace Rig + ci.draw_rect(Rect2(pos - Vector2(14.0, 15.0) * t_scale, Vector2(28.0, 17.0) * t_scale), Color("#451a03") * mod_col, true) + ci.draw_rect(Rect2(pos + Vector2(-10.0, -26.0) * t_scale, Vector2(6.0, 14.0) * t_scale), Color("#57534e") * mod_col, true) + ci.draw_rect(Rect2(pos + Vector2(4.0, -29.0) * t_scale, Vector2(6.0, 17.0) * t_scale), Color("#57534e") * mod_col, true) + ci.draw_circle(pos + Vector2(0.0, -6.0 * t_scale), 5.0 * t_scale, Color("#f97316") * mod_col) + "migrate": + # Nomad Multi-Peaked Pavilion + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-17.0, 2.0) * t_scale, + pos + Vector2(-7.0, -19.0) * t_scale, + pos + Vector2(0.0, -12.0) * t_scale, + pos + Vector2(7.0, -21.0) * t_scale, + pos + Vector2(17.0, 2.0) * t_scale + ]), strat_color * mod_col) + ci.draw_polyline(PackedVector2Array([ + pos + Vector2(-17.0, 2.0) * t_scale, + pos + Vector2(-7.0, -19.0) * t_scale, + pos + Vector2(0.0, -12.0) * t_scale, + pos + Vector2(7.0, -21.0) * t_scale, + pos + Vector2(17.0, 2.0) * t_scale + ]), Color("#fef3c7") * mod_col, 2.0) + "innovate": + # Stepped Astronomical Spire + ci.draw_rect(Rect2(pos - Vector2(14.0, 8.0) * t_scale, Vector2(28.0, 10.0) * t_scale), Color("#475569") * mod_col, true) + ci.draw_rect(Rect2(pos - Vector2(10.0, 16.0) * t_scale, Vector2(20.0, 9.0) * t_scale), strat_color.darkened(0.2) * mod_col, true) + ci.draw_rect(Rect2(pos - Vector2(6.0, 24.0) * t_scale, Vector2(12.0, 9.0) * t_scale), strat_color * mod_col, true) + var apex := pos + Vector2(0.0, -29.0 * t_scale) + ci.draw_arc(apex, 5.5 * t_scale, 0.0, TAU, 16, Color("#38bdf8") * mod_col, 1.8) + ci.draw_circle(apex, 2.6 * t_scale, Color("#fde047") * mod_col) + _: + # Forage Timber & Thatch Lodge + ci.draw_rect(Rect2(pos - Vector2(12.0, 12.0) * t_scale, Vector2(24.0, 14.0) * t_scale), Color("#78350f") * mod_col, true) + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-16.0, -10.0) * t_scale, + pos + Vector2(0.0, -24.0) * t_scale, + pos + Vector2(16.0, -10.0) * t_scale + ]), strat_color * mod_col) + ci.draw_polyline(PackedVector2Array([ + pos + Vector2(-16.0, -10.0) * t_scale, + pos + Vector2(0.0, -24.0) * t_scale, + pos + Vector2(16.0, -10.0) * t_scale + ]), Color("#fde68a") * mod_col, 2.0) + + # Vital status visual reflection: warm golden hearths when thriving, cracks/smoke when starving + if vital_status == "thriving": + ci.draw_circle(pos + Vector2(0.0, -3.0 * t_scale), 4.5 * t_scale, Color(1.0, 0.88, 0.25, 0.85) * mod_col) + ci.draw_circle(pos + Vector2(0.0, -3.0 * t_scale), 2.5 * t_scale, Color("#fef08a") * mod_col) + elif vital_status == "starving" or vital_status == "struggling": + ci.draw_line(pos + Vector2(-6.0, -14.0) * t_scale, pos + Vector2(2.0, -4.0) * t_scale, Color("#1c1917") * mod_col, 2.0) + ci.draw_circle(pos + Vector2(0.0, -26.0 * t_scale), 3.5 * sf, Color(0.2, 0.2, 0.22, 0.55) * mod_col) + + +static func _draw_ring_1_trait_module( + ci: CanvasItem, + pos: Vector2, + structure_type: String, + col: Color, + sf: float, + mod_col: Color +) -> void: + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos, 9.5 * sf, 5.8 * sf, Color("#57534e") * mod_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos, 7.5 * sf, 4.5 * sf, Color("#a8a29e") * mod_col) + match structure_type: + "monolith": + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-3.5, 1.0) * sf, + pos + Vector2(-2.2, -15.0) * sf, + pos + Vector2(0.0, -18.5) * sf, + pos + Vector2(2.2, -15.0) * sf, + pos + Vector2(3.5, 1.0) * sf + ]), Color("#1e293b") * mod_col) + ci.draw_line(pos + Vector2(0.0, -2.0) * sf, pos + Vector2(0.0, -15.0) * sf, col * mod_col, 2.0 * sf) + "hearth_circle": + for i in range(6): + var a := TAU * float(i) / 6.0 + ci.draw_circle(pos + Vector2(cos(a) * 5.2, sin(a) * 3.2) * sf, 2.0 * sf, Color("#78716c") * mod_col) + ci.draw_circle(pos + Vector2(0.0, -2.5 * sf), 3.6 * sf, col * mod_col) + ci.draw_circle(pos + Vector2(0.0, -2.5 * sf), 1.8 * sf, Color("#fef08a") * mod_col) + "brood_totem": + ci.draw_line(pos, pos + Vector2(0.0, -15.0 * sf), Color("#78350f") * mod_col, 4.2 * sf) + ci.draw_circle(pos + Vector2(0.0, -6.0 * sf), 3.6 * sf, col * mod_col) + ci.draw_circle(pos + Vector2(0.0, -12.0 * sf), 3.0 * sf, col.lightened(0.2) * mod_col) + "terraced_sanctuary": + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(0.0, -2.0 * sf), 6.5 * sf, 3.8 * sf, col.darkened(0.25) * mod_col) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(0.0, -5.0 * sf), 4.2 * sf, 2.5 * sf, col * mod_col) + "war_brazier": + ci.draw_line(pos + Vector2(-4.0, 1.0) * sf, pos + Vector2(0.0, -7.0) * sf, Color("#1e293b") * mod_col, 2.0 * sf) + ci.draw_line(pos + Vector2(4.0, 1.0) * sf, pos + Vector2(0.0, -7.0) * sf, Color("#1e293b") * mod_col, 2.0 * sf) + ci.draw_circle(pos + Vector2(0.0, -8.5 * sf), 4.0 * sf, col * mod_col) + ci.draw_circle(pos + Vector2(0.0, -10.0 * sf), 2.2 * sf, Color("#fde047") * mod_col) + "grove_shrine": + ci.draw_line(pos, pos + Vector2(0.0, -9.0 * sf), Color("#78350f") * mod_col, 2.8 * sf) + ci.draw_circle(pos + Vector2(0.0, -11.5 * sf), 5.8 * sf, col * mod_col) + ci.draw_circle(pos + Vector2(-2.0, -13.0) * sf, 3.2 * sf, col.lightened(0.25) * mod_col) + "caravan_depot": + ci.draw_rect(Rect2(pos + Vector2(-5.5, -9.0) * sf, Vector2(11.0, 9.0) * sf), Color("#92400e") * mod_col, true) + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-7.0, -9.0) * sf, + pos + Vector2(0.0, -14.5) * sf, + pos + Vector2(7.0, -9.0) * sf + ]), col * mod_col) + "granary_silo": + ci.draw_rect(Rect2(pos + Vector2(-4.2, -12.0) * sf, Vector2(8.4, 12.0) * sf), Color("#d6d3d1") * mod_col, true) + ci.draw_circle(pos + Vector2(0.0, -12.0 * sf), 4.4 * sf, col * mod_col) + "lithic_workshop": + ci.draw_rect(Rect2(pos + Vector2(-5.0, -6.5) * sf, Vector2(10.0, 6.5) * sf), col * mod_col, true) + ci.draw_line(pos + Vector2(-6.0, -6.5) * sf, pos + Vector2(6.0, -6.5) * sf, Color("#334155") * mod_col, 2.5 * sf) + "crystal_obelisk": + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(0.0, -2.0 * sf), + pos + Vector2(-4.2 * sf, -10.0 * sf), + pos + Vector2(0.0, -18.0 * sf), + pos + Vector2(4.2 * sf, -10.0 * sf) + ]), col * mod_col) + ci.draw_circle(pos + Vector2(0.0, -10.0 * sf), 2.0 * sf, Color.WHITE * mod_col) + _: + ci.draw_circle(pos + Vector2(0.0, -7.0 * sf), 4.5 * sf, col * mod_col) 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 k_band: int = 0 if k_ratio < 0.75 else (1 if k_ratio < 0.90 else 2) 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 [cell.biome, top_z, v_count, k_band, r._built_yaw, r._built_pitch] static func collect_static_props(r: Node2D) -> Array[Prop]: @@ -426,215 +665,85 @@ static func collect_static_props(r: Node2D) -> Array[Prop]: 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 + cell_props.sort_custom(func(a: Prop, b: Prop) -> bool: return a.y < b.y) 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() > 45: + if full_flora_rebuild or not changed_hexes.is_empty(): 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 + flora_sorted.sort_custom(func(a: Prop, b: Prop) -> bool: return a.y < b.y) 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] = [] + var props: Array[Prop] = flora_sorted.duplicate() 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) + var r_prop := world_prop("ruins", world_xy + Vector2(0, 12), r._get_cell_top_z(cell), Vector2.ZERO, 1.8, Color.WHITE) r_prop.project(r._ground, r._lift) - dyn_props.append(r_prop) + 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 + props.sort_custom(func(a: Prop, b: Prop) -> bool: return a.y < b.y) + return props - 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: + 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 = 12 + elif cell.biome == "savanna": + base_count = 9 + elif cell.biome == "tundra": + base_count = 8 + elif cell.biome == "coastal_shallows": + base_count = 6 + var has_village := not cell.deme_ids.is_empty() + var prop_count: int = clampi(int(round(float(base_count) * (0.55 + 0.45 * k_ratio))), 5, 12) + if has_village: + prop_count = clampi(prop_count - 2, 5, 9) -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 c_count := deme.cultural_trait_ids.size() - var arch := str(village["archetype"]) - var flags: int = ( - (1 if bool(village.get("has_pact", false)) else 0) - | (2 if bool(village.get("in_war", false)) else 0) - | (4 if bool(village.get("in_raid", false)) else 0) - | (8 if bool(village.get("in_betrayal", false)) else 0) - | (16 if bool(village.get("in_tribute", false)) else 0) - | (32 if bool(village.get("in_siege", false)) else 0) - ) - var vp_key: Array = [ - tier, - sf, - c_count, - arch, - flags, - deme.defense_tier, - deme.merged_count, - m, - v_pos - ] - if deme.has_meta("_vp_key") and (deme.get_meta("_vp_key") as Array) == vp_key: - props.append_array(deme.get_meta("_vp_val") as Array[Prop]) - return + var palette_sprites: Array = BIOME_FLORA_PALETTES.get(cell.biome, DEFAULT_FLORA_PALETTE) + var base_seed: int = cell.q * 73856093 ^ cell.r * 19349663 - 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) - 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) - p.set_foot(v_pos + off) - v_props.append(p) - _draw_settlement_props(village, v_props) - deme.set_meta("_vp_key", vp_key) - deme.set_meta("_vp_val", v_props) - props.append_array(v_props) - - -static func _draw_settlement_props(village: Dictionary, props: Array[Prop]) -> void: - var deme: EvoTypes.Deme = village["deme"] - 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) - 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) - 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) - 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) - 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) - 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) - 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) - p.set_foot(v_pos + off) - props.append(p) + 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 := (TAU * float(idx) / float(maxi(1, prop_count))) + float(s1 % 23 - 11) * 0.035 + var rad_norm := (0.66 + float(s2 % 16) * 0.01) if has_village else (0.32 + float(s2 % 44) * 0.01) + var local_off := Vector2(cos(angle), sin(angle)) * (HexMath.HEX_SIZE * rad_norm) + + var sprite_key: String = palette_sprites[idx % palette_sprites.size()] + var can_sway := true + if k_ratio < 0.75: + can_sway = false + if idx % 3 == 0: + sprite_key = "tree_stump" + elif idx % 3 == 1: + sprite_key = "soil_crack" + else: + sprite_key = "dry_scrub" + + var prop := world_prop(sprite_key, world_xy + local_off, top_z, Vector2.ZERO, 1.0 + float(s1 % 16) * 0.02, 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 build_geopolitics_by_deme(state: EvoTypes.WorldState) -> Dictionary: @@ -703,243 +812,21 @@ static func build_geopolitics_by_deme(state: EvoTypes.WorldState) -> Dictionary: 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") + var all_geo := build_geopolitics_by_deme(state) + if all_geo.has(deme.id): + return all_geo[deme.id] 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 + "has_pact": false, + "in_war": false, + "in_raid": false, + "in_betrayal": false, + "in_tribute": false, + "in_siege": false, + "in_merge": deme.merged_count > 0, + "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 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] - 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 moving := collect_moving_props(r) moving.sort_custom(func(a: Prop, b: Prop) -> bool: return a.y < b.y) @@ -957,47 +844,118 @@ static func draw_props(r: Node2D) -> void: static func draw_prop(r: Node2D, prop: Prop) -> void: - var rect := prop.rect + var ci: Node2D = r._anim_layer + var pos := prop.foot if prop.sways: - rect.position.x += sin(r._anim_time * 2.1 + prop.sway_phase) * 1.15 - r._anim_layer.draw_texture_rect(prop.texture, rect, false, prop.modulate) - if prop.trim_color.a > 0.0: - var trim_rect := Rect2( - rect.position + Vector2(rect.size.x * 0.30, rect.size.y * 0.44), - Vector2(rect.size.x * 0.40, maxf(2.0, rect.size.y * 0.06)) - ) - r._anim_layer.draw_rect(trim_rect, prop.trim_color, true) + pos.x += sin(r._anim_time * 2.1 + prop.sway_phase) * 1.4 + var s := prop.scale_val + var m := prop.modulate + + match prop.kind: + "villager": + var step_off := sin(float(prop.anim_step) * 2.1) * 1.5 * s + ci.draw_circle(pos + Vector2(0.0, 1.0 * s), 3.2 * s, Color(0.0, 0.0, 0.0, 0.28) * m) + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-3.2 * s, 0.0), + pos + Vector2(-2.0 * s, -8.5 * s), + pos + Vector2(2.0 * s, -8.5 * s), + pos + Vector2(3.2 * s, 0.0) + ]), prop.primary_color * m) + if prop.trim_color.a > 0.0: + ci.draw_line(pos + Vector2(-2.6 * s, -3.5 * s), pos + Vector2(2.6 * s, -3.5 * s), prop.trim_color * m, 1.8 * s) + ci.draw_circle(pos + Vector2(step_off * 0.3, -10.8 * s), 2.5 * s, Color("#fde68a") * m) + "tree_tropical", "tree_tropical_2": + ci.draw_circle(pos, 5.5 * s, Color(0.0, 0.0, 0.0, 0.22) * m) + ci.draw_line(pos, pos + Vector2(0.0, -14.0 * s), Color("#5c3a21") * m, 3.2 * s) + ci.draw_circle(pos + Vector2(0.0, -17.0 * s), 9.5 * s, Color("#15803d") * m) + ci.draw_circle(pos + Vector2(-4.0 * s, -14.0 * s), 6.5 * s, Color("#16a34a") * m) + ci.draw_circle(pos + Vector2(4.0 * s, -15.0 * s), 6.0 * s, Color("#22c55e") * m) + "tree_savanna", "tree_savanna_2": + ci.draw_circle(pos, 5.0 * s, Color(0.0, 0.0, 0.0, 0.20) * m) + ci.draw_line(pos, pos + Vector2(0.0, -13.0 * s), Color("#6b4226") * m, 2.8 * s) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(0.0, -15.0 * s), 12.0 * s, 5.0 * s, Color("#4d7c0f") * m) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(1.5 * s, -16.5 * s), 8.5 * s, 3.5 * s, Color("#65a30d") * m) + "tree_tundra", "tree_tundra_2": + ci.draw_line(pos, pos + Vector2(0.0, -14.0 * s), Color("#4a2c18") * m, 2.6 * s) + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-7.5 * s, -4.0 * s), + pos + Vector2(0.0, -19.0 * s), + pos + Vector2(7.5 * s, -4.0 * s) + ]), Color("#0f5132") * m) + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-5.5 * s, -10.0 * s), + pos + Vector2(0.0, -21.0 * s), + pos + Vector2(5.5 * s, -10.0 * s) + ]), Color("#e2e8f0") * m) + "bush_arid", "dry_scrub": + ci.draw_line(pos, pos + Vector2(-4.5 * s, -6.0 * s), Color("#92400e") * m, 1.8 * s) + ci.draw_line(pos, pos + Vector2(4.5 * s, -6.5 * s), Color("#92400e") * m, 1.8 * s) + ci.draw_line(pos, pos + Vector2(0.0, -8.0 * s), Color("#b45309") * m, 1.8 * s) + "bush_berry", "wildflowers", "tall_grass": + ci.draw_circle(pos + Vector2(0.0, -3.5 * s), 4.5 * s, Color("#22c55e") * m) + ci.draw_circle(pos + Vector2(2.0 * s, -5.0 * s), 1.8 * s, Color("#fbbf24") * m) + "coastal_reeds": + ci.draw_line(pos + Vector2(-2.5 * s, 0.0), pos + Vector2(-3.5 * s, -10.0 * s), Color("#0d9488") * m, 1.8 * s) + ci.draw_line(pos, pos + Vector2(0.0, -12.0 * s), Color("#14b8a6") * m, 2.0 * s) + ci.draw_line(pos + Vector2(2.5 * s, 0.0), pos + Vector2(3.5 * s, -9.5 * s), Color("#0d9488") * m, 1.8 * s) + "rock_mountain", "mossy_rock", "fallen_log": + ci.draw_colored_polygon(PackedVector2Array([ + pos + Vector2(-7.0 * s, 0.0), + pos + Vector2(-4.0 * s, -7.5 * s), + pos + Vector2(3.5 * s, -6.0 * s), + pos + Vector2(7.0 * s, 0.0) + ]), Color("#64748b") * m) + "tree_stump": + ci.draw_rect(Rect2(pos + Vector2(-4.0 * s, -5.0 * s), Vector2(8.0 * s, 5.0 * s)), Color("#5c3a21") * m, true) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, pos + Vector2(0.0, -5.0 * s), 4.2 * s, 2.2 * s, Color("#a16207") * m) + "soil_crack": + ci.draw_polyline(PackedVector2Array([ + pos + Vector2(-7.0 * s, -2.0 * s), + pos + Vector2(-1.5 * s, 0.0), + pos + Vector2(2.5 * s, -3.0 * s), + pos + Vector2(7.5 * s, 1.0 * s) + ]), Color("#451a03") * m, 2.0 * s) + "ruins": + ci.draw_rect(Rect2(pos + Vector2(-8.0 * s, -9.0 * s), Vector2(5.0 * s, 9.0 * s)), Color("#64748b") * m, true) + ci.draw_rect(Rect2(pos + Vector2(2.0 * s, -6.0 * s), Vector2(5.0 * s, 6.0 * s)), Color("#475569") * m, true) + _: + if prop.texture != null: + ci.draw_texture_rect(prop.texture, prop.rect, false, m) static func acquire_moving_prop( r: Node2D, - tex: ImageTexture, + p_kind: String, foot: Vector2, scale_val: float, - anchor_y_ratio: float, + primary_col: Color, + trim_col: Color, + anim_step: int, modulate_col: Color ) -> Prop: var prop: Prop if r._moving_prop_count < r._moving_prop_pool.size(): prop = r._moving_prop_pool[r._moving_prop_count] else: - prop = Prop.new(tex, scale_val, anchor_y_ratio, modulate_col) + prop = Prop.new(p_kind, scale_val, 0.85, modulate_col) r._moving_prop_pool.append(prop) r._moving_prop_count += 1 - prop.texture = tex - var sz: Vector2 = tex.get_size() * scale_val - 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 + prop.kind = p_kind + prop.scale_val = scale_val + prop.primary_color = primary_col + prop.trim_color = trim_col + prop.anim_step = anim_step prop.modulate = modulate_col - prop.trim_color = Color.TRANSPARENT prop.sways = false + prop.set_foot(foot) return prop static func collect_moving_props(r: Node2D) -> Array[Prop]: r._moving_prop_count = 0 var props: Array[Prop] = [] + if r.get_semantic_zoom_tier() == "macro": + return props var vis_rect: Rect2 = r._visible_world_rect var anim_time: float = r._anim_time @@ -1005,44 +963,36 @@ 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 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)) - + var strat_color: Color = village["strategy_color"] + var cape_trim := strat_color.lightened(0.25) var villager_count: int = village["villager_count"] - var v_textures: Array[ImageTexture] = get_villager_textures_for_color(r, strat_color) 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_r := (28.0 + float(v_idx % 3) * 8.0) * sf + var foot := v_pos + Vector2(cos(orbit_angle) * orbit_r, sin(orbit_angle) * (orbit_r * 0.56) + 8.0 * sf) 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) - v_prop.trim_color = cape_trim - props.append(v_prop) + props.append(acquire_moving_prop(r, "villager", foot, 1.05 * sf, strat_color, cape_trim, frame, m)) for r_idx in range(r._roads.size()): var road: Dictionary = r._roads[r_idx] - if not TRAVELER_ROAD_KINDS.has(road["kind"]) or not road["show_traffic"]: + if not TRAVELER_ROAD_KINDS.has(str(road.get("kind", ""))) or not bool(road.get("show_traffic", false)): continue 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["curve_factor"]) - var r_textures: Array[ImageTexture] = road["villager_textures"] + var curve_f: float = float(road.get("curve_factor", 0.045)) + var r_col: Color = road.get("color", Color("#eab308")) 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: raw_walk = 1.0 - raw_walk - var t_walk := lerpf(0.24, 0.76, raw_walk) + var t_walk := lerpf(0.22, 0.78, 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, "villager", foot, 1.0, r_col, r_col.lightened(0.25), frame, Color.WHITE)) return props @@ -1054,7 +1004,7 @@ static func world_prop( scale_val: float, modulate_col: Color ) -> Prop: - var prop := Prop.new(EvoPixelArtGen.get_texture(sprite_name), scale_val, 0.78, modulate_col) + var prop := Prop.new(sprite_name, scale_val, 0.78, modulate_col) prop.world_xy = world_xy prop.top_z = top_z prop.screen_offset = screen_offset diff --git a/src/world/render/road_network_renderer.gd b/src/world/render/road_network_renderer.gd index 3d63ace..c8b6a0e 100644 --- a/src/world/render/road_network_renderer.gd +++ b/src/world/render/road_network_renderer.gd @@ -1,12 +1,30 @@ class_name EvoRoadNetworkRenderer extends RefCounted +const FLOW_PRIORITY: Dictionary = { + "merge": 130, + "war": 120, + "siege": 110, + "betrayal": 100, + "raid": 90, + "tribute": 80, + "pact": 70, + "migration": 60, + "collab": 50, + "symbiosis": 40, + "exchange": 30, + "cooperate": 20, + "dispute": 10 +} + +static var _spiral_buf_a := PackedVector2Array() +static var _spiral_buf_b := PackedVector2Array() + 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 @@ -14,6 +32,7 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: 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"] @@ -52,30 +71,46 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: r.set_meta("_deme_v_cache", deme_village) r.set_meta("_deme_stance_cache", deme_road_stance) + var best_flow_by_pair: Dictionary = {} + var ordered_pair_keys: Array[String] = [] 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 h_key := make_pair_key(flow.from_hex_id, flow.to_hex_id) + var prio: int = int(FLOW_PRIORITY.get(flow.flow_type, 0)) + if not best_flow_by_pair.has(h_key): + best_flow_by_pair[h_key] = flow + ordered_pair_keys.append(h_key) + else: + var cur_best: EvoTypes.HexEdgeFlow = best_flow_by_pair[h_key] + if prio > int(FLOW_PRIORITY.get(cur_best.flow_type, 0)): + best_flow_by_pair[h_key] = flow + + for h_key in ordered_pair_keys: + var flow: EvoTypes.HexEdgeFlow = best_flow_by_pair[h_key] 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)): - continue + handled_hex_pairs[h_key] = true - var endpoints := resolve_road_endpoints(r, cell_a, cell_b, flow.from_deme_id, flow.to_deme_id) + var da_id: String = 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 db_id: String = 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 da_id != "" and db_id != "": + handled_deme_pairs[make_pair_key(da_id, db_id)] = true + + var is_pact := (da_id != "" and db_id != "" and EvoTypes.is_pact_active(state, da_id, db_id)) + var is_vendetta := (da_id != "" and db_id != "" and is_vendetta_pair(state, da_id, db_id)) + + var endpoints := resolve_road_endpoints(r, cell_a, cell_b, da_id, db_id) var trim_a := get_cell_plaza_trim(cell_a) var trim_b := get_cell_plaza_trim(cell_b) - var trimmed := trim_road_segment( - endpoints["p_a"] as Vector2, - endpoints["p_b"] as Vector2, - trim_a, - trim_b - ) + var trimmed := trim_road_segment(endpoints["p_a"] as Vector2, endpoints["p_b"] as Vector2, trim_a, trim_b) 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 edge_mid := (p_a + p_b) * 0.5 + var edge_tangent := compute_edge_tangent(p_a, p_b) + 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,230 +118,51 @@ 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 - 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 - var flow_color := Color("#eab308") - if flow.from_deme_id != "" and deme_village.has(flow.from_deme_id): - flow_color = deme_village[flow.from_deme_id]["strat_color"] - elif flow.from_deme_id != "" and state.demes.has(flow.from_deme_id): - flow_color = EvoPropFloraRenderer.resolve_deme_strategy_color(state, state.demes[flow.from_deme_id]) - elif not cell_a.deme_ids.is_empty() and deme_village.has(cell_a.deme_ids[0]): - flow_color = deme_village[cell_a.deme_ids[0]]["strat_color"] - elif not cell_a.deme_ids.is_empty() and state.demes.has(cell_a.deme_ids[0]): - flow_color = EvoPropFloraRenderer.resolve_deme_strategy_color(state, state.demes[cell_a.deme_ids[0]]) + if da_id != "" and deme_village.has(da_id): + flow_color = deme_village[da_id]["strat_color"] + elif da_id != "" and state.demes.has(da_id): + flow_color = EvoPropFloraRenderer.resolve_deme_strategy_color(state, state.demes[da_id]) var effective_type := flow.flow_type - 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): + if effective_type in ["exchange", "cooperate", "symbiosis", "trade"]: + if is_pact: effective_type = "pact" + else: + effective_type = "symbiosis" + elif effective_type == "migration": + effective_type = "migration" - 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 show_badge := register_road_badge(r, b_pos, badge_positions, true) - roads.append({ - "kind": effective_type, - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "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 show_badge := register_road_badge(r, b_pos, badge_positions, true) - roads.append({ - "kind": "raid", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "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": - var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) - var show_badge := register_road_badge(r, b_pos, badge_positions, true) - var show_traffic := claim_road_traffic(cell_a.id, cell_b.id, hex_traffic_counts, true) - roads.append({ - "kind": "tribute", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "world_b": endpoints["world_b"], - "z_b": endpoints["z_b"], - "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": - var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) - var show_badge := register_road_badge(r, b_pos, badge_positions, true) - var show_traffic := claim_road_traffic(cell_a.id, cell_b.id, hex_traffic_counts, true) - roads.append({ - "kind": "merge", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "world_b": endpoints["world_b"], - "z_b": endpoints["z_b"], - "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 show_badge := register_road_badge(r, b_pos, badge_positions, true) - roads.append({ - "kind": "betrayal", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "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 - }) - "pact": - var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, curve_f) - var show_badge := register_road_badge(r, b_pos, badge_positions, true) - var show_traffic := claim_road_traffic(cell_a.id, cell_b.id, hex_traffic_counts, true) - roads.append({ - "kind": "pact", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "world_b": endpoints["world_b"], - "z_b": endpoints["z_b"], - "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) - roads.append({ - "kind": "dispute", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "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 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) - roads.append({ - "kind": kind, - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "world_b": endpoints["world_b"], - "z_b": endpoints["z_b"], - "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, - "color": Color("#eab308") - }) - "migration": - roads.append({ - "kind": "expedition", - "hex_a": cell_a.id, - "hex_b": cell_b.id, - "world_a": endpoints["world_a"], - "z_a": endpoints["z_a"], - "trim_a": trim_a, - "world_b": endpoints["world_b"], - "z_b": endpoints["z_b"], - "trim_b": trim_b, - "p_a": p_a, - "p_b": p_b, - "curve_factor": curve_f - }) + var show_badge := register_road_badge(r, edge_mid, badge_positions, is_prio or effective_type in ["war", "siege", "raid", "pact", "betrayal", "tribute", "merge"]) + var show_traffic := claim_road_traffic(cell_a.id, cell_b.id, hex_traffic_counts, is_prio or effective_type in ["pact", "tribute", "merge"]) + + var kind := effective_type + if effective_type == "symbiosis" and should_use_bridge(r, cell_a, cell_b): + kind = "bridge" + + roads.append({ + "kind": kind, + "flow_type": effective_type, + "hex_a": cell_a.id, + "hex_b": cell_b.id, + "world_a": endpoints["world_a"], + "z_a": endpoints["z_a"], + "trim_a": trim_a, + "world_b": endpoints["world_b"], + "z_b": endpoints["z_b"], + "trim_b": trim_b, + "p_a": p_a, + "p_b": p_b, + "edge_mid": edge_mid, + "edge_tangent": edge_tangent, + "is_pact": is_pact or kind == "pact", + "is_vendetta": is_vendetta or kind in ["dispute", "war", "siege", "raid", "betrayal"], + "curve_factor": 0.14 if kind in ["migration", "expedition"] else curve_f, + "show_badge": show_badge, + "show_traffic": show_traffic, + "badge_pos": edge_mid, + "color": Color("#10b981") if kind == "pact" else flow_color + }) var all_demes: Array[EvoTypes.Deme] = [] for d_id in state.demes.keys(): @@ -317,130 +173,11 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: 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): - 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 - }) - for hex_id in state.cells: var ca: EvoTypes.HexCell = state.cells[hex_id] if ca.deme_ids.is_empty(): @@ -460,34 +197,35 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: var da: EvoTypes.Deme = pair["da"] var db: EvoTypes.Deme = pair["db"] handled_deme_pairs[make_pair_key(da.id, db.id)] = true + var world_a: Vector2 = pair["wa"] var z_a: float = r._get_cell_top_z(ca) var trim_a := get_cell_plaza_trim(ca) var world_b: Vector2 = pair["wb"] var z_b: float = r._get_cell_top_z(cb) var trim_b := get_cell_plaza_trim(cb) - var trimmed := trim_road_segment( - r._project(world_a, z_a), - r._project(world_b, z_b), - trim_a, - trim_b - ) + var trimmed := trim_road_segment(r._project(world_a, z_a), r._project(world_b, z_b), trim_a, trim_b) var p_a: Vector2 = trimmed["p_a"] var p_b: Vector2 = trimmed["p_b"] + var edge_mid := (p_a + p_b) * 0.5 + var edge_tangent := compute_edge_tangent(p_a, p_b) var is_prio: bool = ( ca.id == r.selected_hex_id or cb.id == r.selected_hex_id or ca.id == r.hovered_hex_id or cb.id == r.hovered_hex_id ) + var is_pact := EvoTypes.is_pact_active(state, da.id, db.id) + var is_vendetta := is_vendetta_pair(state, da.id, db.id) + var rel := "pact" if is_pact else ("dispute_front" if is_vendetta else classify_village_pair_relation(r, da, db)) - match classify_village_pair_relation(r, da, db): + match rel: "pact": - var b_pos := sample_road_curve(p_a, p_b, 0.5, 1.0, 0.045) - var show_badge := register_road_badge(r, b_pos, badge_positions, true) + var show_badge := register_road_badge(r, edge_mid, badge_positions, true) var show_traffic := claim_road_traffic(ca.id, cb.id, hex_traffic_counts, true) roads.append({ "kind": "pact", + "flow_type": "pact", "hex_a": ca.id, "hex_b": cb.id, "world_a": world_a, @@ -498,18 +236,23 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": false, + "edge_mid": edge_mid, + "edge_tangent": edge_tangent, + "is_pact": true, + "is_vendetta": false, "curve_factor": 0.045, "show_badge": show_badge, "show_traffic": show_traffic, - "badge_pos": b_pos, + "badge_pos": edge_mid, "color": Color("#10b981") }) "dispute_front": - var b_pos := (p_a + p_b) * 0.5 - var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) + var show_badge := register_road_badge(r, edge_mid, badge_positions, is_prio) roads.append({ "kind": "dispute", + "flow_type": "dispute", + "hex_a": ca.id, + "hex_b": cb.id, "world_a": world_a, "z_a": z_a, "trim_a": trim_a, @@ -518,17 +261,21 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": false, + "edge_mid": edge_mid, + "edge_tangent": edge_tangent, + "is_pact": false, + "is_vendetta": true, "show_badge": show_badge, - "badge_pos": b_pos + "badge_pos": edge_mid, + "color": Color("#ef4444") }) "active_road": - var kind := "bridge" if should_use_bridge(r, ca, cb) 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, 0.045) - var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) + var kind := "bridge" if should_use_bridge(r, ca, cb) else "symbiosis" + var show_badge := register_road_badge(r, edge_mid, badge_positions, is_prio) var show_traffic := claim_road_traffic(ca.id, cb.id, hex_traffic_counts, is_prio) roads.append({ "kind": kind, + "flow_type": "symbiosis", "hex_a": ca.id, "hex_b": cb.id, "world_a": world_a, @@ -539,19 +286,24 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "is_same_hex": false, + "edge_mid": edge_mid, + "edge_tangent": edge_tangent, + "is_pact": false, + "is_vendetta": false, "curve_factor": 0.045, "show_badge": show_badge, "show_traffic": show_traffic, - "color": Color(da.color) + "badge_pos": edge_mid, + "color": EvoPropFloraRenderer.resolve_deme_strategy_color(state, da) }) _: var dis_age := get_disused_road_age(r, da.id, db.id, ca.id, cb.id) if dis_age > 0: - var b_pos := (p_a + p_b) * 0.5 - var show_badge := register_road_badge(r, b_pos, badge_positions, is_prio) + var show_badge := register_road_badge(r, edge_mid, badge_positions, is_prio) roads.append({ "kind": "broken", + "hex_a": ca.id, + "hex_b": cb.id, "world_a": world_a, "z_a": z_a, "trim_a": trim_a, @@ -560,59 +312,12 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, + "edge_mid": edge_mid, + "edge_tangent": edge_tangent, "show_badge": show_badge, "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 @@ -630,16 +335,23 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: 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) + var p_a: Vector2 = seg["p_a"] + var p_b: Vector2 = seg["p_b"] roads.append({ "kind": "expedition", + "flow_type": "migration", + "hex_a": ca.id, + "hex_b": ncell.id, "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"], + "p_a": p_a, + "p_b": p_b, + "edge_mid": (p_a + p_b) * 0.5, + "edge_tangent": compute_edge_tangent(p_a, p_b), "curve_factor": 0.14 }) @@ -649,46 +361,30 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: 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 from_id == to_id or not state.cells.has(from_id) or not state.cells.has(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): - continue - if d_key != "" and handled_deme_pairs.has(d_key): + if handled_hex_pairs.has(h_key): continue - if not is_same_hex: - handled_hex_pairs[h_key] = true - if d_key != "": - handled_deme_pairs[d_key] = true + handled_hex_pairs[h_key] = true + var fa := str(disused.get("from_deme_id", "")) + var fb := str(disused.get("to_deme_id", "")) var endpoints := resolve_road_endpoints(r, ca, cb, fa, fb) var trim_a := get_cell_plaza_trim(ca) var trim_b := get_cell_plaza_trim(cb) - var seg := trim_road_segment( - endpoints["p_a"] as Vector2, - endpoints["p_b"] as Vector2, - trim_a, - trim_b - ) + var seg := trim_road_segment(endpoints["p_a"] as Vector2, endpoints["p_b"] as Vector2, trim_a, trim_b) var p_a: Vector2 = seg["p_a"] var p_b: Vector2 = seg["p_b"] - var is_prio: bool = ( - from_id == r.selected_hex_id - or to_id == r.selected_hex_id - or from_id == r.hovered_hex_id - or to_id == r.hovered_hex_id - ) - var show_badge := register_road_badge(r, (p_a + p_b) * 0.5, badge_positions, is_prio) + var edge_mid := (p_a + p_b) * 0.5 roads.append({ "kind": "broken", + "hex_a": from_id, + "hex_b": to_id, "world_a": endpoints["world_a"], "z_a": endpoints["z_a"], "trim_a": trim_a, @@ -697,19 +393,35 @@ static func collect_roads(r: Node2D) -> Array[Dictionary]: "trim_b": trim_b, "p_a": p_a, "p_b": p_b, - "show_badge": show_badge, + "edge_mid": edge_mid, + "edge_tangent": compute_edge_tangent(p_a, p_b), + "show_badge": register_road_badge(r, edge_mid, badge_positions, false), "alpha": 0.52 if age_val == 2 else 0.90 }) return prune_redundant_triangle_chords(r, roads) +static func is_vendetta_pair(state: EvoTypes.WorldState, id_a: String, id_b: String) -> bool: + var pk := EvoTypes.make_pair_key(id_a, id_b) + return int(state.active_pacts.get(pk, 0)) < 0 + + +static func compute_edge_tangent(p_a: Vector2, p_b: Vector2) -> Vector2: + var diff := p_b - p_a + var len := diff.length() + if len < 0.01: + return Vector2.UP + var dir := diff / len + return Vector2(-dir.y, dir.x) + + static func prune_redundant_triangle_chords(r: Node2D, roads: Array[Dictionary]) -> Array[Dictionary]: var state: EvoTypes.WorldState = r.world_state 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", "symbiosis", "bridge"]: var ha := str(rd.get("hex_a", "")) var hb := str(rd.get("hex_b", "")) if ha != "" and hb != "": @@ -718,7 +430,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", "symbiosis", "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): @@ -736,8 +448,6 @@ static func prune_redundant_triangle_chords(r: Node2D, roads: Array[Dictionary]) break if has_triangle_bypass: continue - if rd.has("color"): - rd["villager_textures"] = EvoPropFloraRenderer.get_villager_textures_for_color(r, rd["color"]) project_road_entry(r, rd, false) result.append(rd) return result @@ -759,17 +469,15 @@ static func project_road_entry(r: Node2D, road: Dictionary, force_reproject: boo p_b = road["p_b"] road["a_pos"] = p_a road["b_pos"] = p_b + var edge_mid := (p_a + p_b) * 0.5 + var edge_tangent := compute_edge_tangent(p_a, p_b) + road["edge_mid"] = edge_mid + road["edge_tangent"] = edge_tangent + road["badge_pos"] = edge_mid 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 + var curve_sign := -1.0 if road.get("kind", "") in ["expedition", "migration"] else 1.0 + var dist := p_a.distance_to(p_b) + road["ctrl"] = edge_mid + edge_tangent * (minf(dist * curve_f, 12.0) * curve_sign) static func reproject_roads(r: Node2D) -> void: @@ -777,37 +485,86 @@ 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"]: + var kind := str(road.get("kind", "")) + match kind: "pact": draw_pact_highway(r, road) + draw_pact_border_front(r, road) "merge": draw_merge_avenue(r, road) "tribute": draw_tribute_route(r, road) "war", "siege": draw_war_corridor(r, road) + draw_conflict_border_front(r, road) "raid": draw_raid_trail(r, road) + draw_conflict_border_front(r, road) "betrayal": draw_betrayal_road(r, road) - "collab": + draw_conflict_border_front(r, road) + "symbiosis", "exchange", "collab": draw_collaboration_route(r, road) "bridge": draw_wooden_bridge(r, road) "dispute": draw_dispute_barricade_front(r, road) + draw_conflict_border_front(r, road) "broken": draw_broken_cut_road(r, road) - "expedition": + "expedition", "migration": draw_migration_expedition(r, road) +static func draw_pact_border_front(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var edge_mid: Vector2 = road["edge_mid"] + var tangent: Vector2 = road["edge_tangent"] + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var axis := (p_b - p_a).normalized() if p_a.distance_to(p_b) > 0.01 else Vector2.RIGHT + + # Shared hex edge golden-emerald double diplomatic border line + var e0 := edge_mid - tangent * 30.0 + var e1 := edge_mid + tangent * 30.0 + ci.draw_line(e0 - axis * 2.5, e1 - axis * 2.5, Color("#10b981"), 2.6) + ci.draw_line(e0 + axis * 2.5, e1 + axis * 2.5, Color("#fbbf24"), 2.6) + + # Stone bridge arch spanning edge_mid + var arch_a := edge_mid - axis * 14.0 + var arch_b := edge_mid + axis * 14.0 + ci.draw_line(arch_a - tangent * 6.5, arch_b - tangent * 6.5, Color("#cbd5e1"), 3.0) + ci.draw_line(arch_a + tangent * 6.5, arch_b + tangent * 6.5, Color("#cbd5e1"), 3.0) + ci.draw_circle(e0, 3.5, Color("#fbbf24")) + ci.draw_circle(e1, 3.5, Color("#fbbf24")) + + +static func draw_conflict_border_front(r: Node2D, road: Dictionary) -> void: + var ci: Node2D = r._network_layer + var edge_mid: Vector2 = road["edge_mid"] + var tangent: Vector2 = road["edge_tangent"] + var p_a: Vector2 = road["p_a"] + var p_b: Vector2 = road["p_b"] + var axis := (p_b - p_a).normalized() if p_a.distance_to(p_b) > 0.01 else Vector2.RIGHT + + # Crimson-iron jagged barricade along shared hex edge + var jag_pts := PackedVector2Array() + var steps := 8 + for i in range(steps + 1): + var t := lerpf(-28.0, 28.0, float(i) / float(steps)) + var offset := (4.5 if i % 2 == 0 else -4.5) + jag_pts.append(edge_mid + tangent * t + axis * offset) + ci.draw_polyline(jag_pts, Color("#1e293b"), 4.2) + ci.draw_polyline(jag_pts, Color("#dc2626"), 2.2) + + # Crossed iron lances at border front + for s_pos in [edge_mid - tangent * 12.0, edge_mid + tangent * 12.0]: + ci.draw_line(s_pos - axis * 7.0 - tangent * 4.0, s_pos + axis * 7.0 + tangent * 4.0, Color("#94a3b8"), 2.0) + ci.draw_line(s_pos - axis * 7.0 + tangent * 4.0, s_pos + axis * 7.0 - tangent * 4.0, Color("#fca5a5"), 2.0) + + static func draw_animated_roads(r: Node2D) -> void: var anim_layer: Node2D = r._anim_layer var vis_rect: Rect2 = r._visible_world_rect @@ -817,70 +574,83 @@ static func draw_animated_roads(r: Node2D) -> void: 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"]: + var edge_mid: Vector2 = road.get("edge_mid", (p_a + p_b) * 0.5) + var tangent: Vector2 = road.get("edge_tangent", compute_edge_tangent(p_a, p_b)) + var axis := (p_b - p_a).normalized() if p_a.distance_to(p_b) > 0.01 else Vector2.RIGHT + var kind := str(road.get("kind", "")) + + match 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) + for i in range(3): + var t_fwd := fposmod(anim_time * 0.36 + float(i) * 0.33, 1.0) + var t_rev := 1.0 - t_fwd + var pos_1 := p_a.lerp(p_b, lerpf(0.14, 0.86, t_fwd)) + tangent * 3.5 + var pos_2 := p_a.lerp(p_b, lerpf(0.14, 0.86, t_rev)) - tangent * 3.5 + anim_layer.draw_circle(pos_1, 3.8, Color("#10b981")) + anim_layer.draw_circle(pos_1, 2.0, Color("#fef08a")) + anim_layer.draw_circle(pos_2, 3.8, Color("#f59e0b")) + anim_layer.draw_circle(pos_2, 2.0, Color("#fef08a")) + "symbiosis", "exchange", "collab", "bridge": + # Trait-transfer spiral beams along conduit + var segs := 18 + if _spiral_buf_a.size() != segs + 1: + _spiral_buf_a.resize(segs + 1) + _spiral_buf_b.resize(segs + 1) + for i in range(segs + 1): + var u := float(i) / float(segs) + var base_pt := p_a.lerp(p_b, u) + var wave := sin(u * TAU * 2.0 - anim_time * 5.0) * 5.5 + _spiral_buf_a[i] = base_pt + tangent * wave + _spiral_buf_b[i] = base_pt - tangent * wave + anim_layer.draw_polyline(_spiral_buf_a, Color(0.22, 0.74, 0.97, 0.72), 2.2) + anim_layer.draw_polyline(_spiral_buf_b, Color(0.98, 0.75, 0.14, 0.72), 2.2) + var mote_t := fposmod(anim_time * 0.48, 1.0) + anim_layer.draw_circle(p_a.lerp(p_b, mote_t), 4.0, Color("#fef08a")) "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) + for i in range(3): + var u := fposmod(anim_time * 0.45 + float(i) * 0.33, 1.0) + var pt := p_a.lerp(p_b, u) + anim_layer.draw_circle(pt, 5.0, Color("#fbbf24")) + anim_layer.draw_circle(pt, 2.8, Color("#10b981")) "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) + for i in range(3): + var u := fposmod(anim_time * 0.42 + float(i) * 0.33, 1.0) + var pt := p_a.lerp(p_b, lerpf(0.12, 0.88, u)) + anim_layer.draw_circle(pt, 4.5, Color("#d97706")) + anim_layer.draw_circle(pt, 2.5, Color("#fef08a")) "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) + # Directional annexation chevrons & Hawk-Hawk clash shockwaves + for c_i in range(3): + var u := fposmod(anim_time * 0.50 + float(c_i) * 0.33, 1.0) + var c_pos := p_a.lerp(p_b, lerpf(0.16, 0.84, u)) + var tip := c_pos + axis * 5.5 + anim_layer.draw_line(c_pos - axis * 4.5 + tangent * 5.5, tip, Color("#fca5a5"), 2.2) + anim_layer.draw_line(c_pos - axis * 4.5 - tangent * 5.5, tip, Color("#fca5a5"), 2.2) + draw_battle_sparks(r, edge_mid) + "expedition", "migration": + for i in range(3): + var u := fposmod(anim_time * 0.44 + float(i) * 0.33, 1.0) + var c_pos := p_a.lerp(p_b, lerpf(0.14, 0.88, u)) + var tip := c_pos + axis * 6.0 + anim_layer.draw_line(c_pos - axis * 4.5 + tangent * 5.0, tip, Color("#38bdf8"), 2.4) + anim_layer.draw_line(c_pos - axis * 4.5 - tangent * 5.0, tip, Color("#38bdf8"), 2.4) 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) + var pulse := fposmod(anim_time * 1.8, 1.0) + anim_layer.draw_arc(mid, lerpf(6.0, 22.0, pulse), 0.0, TAU, 20, Color(0.94, 0.27, 0.27, (1.0 - pulse) * 0.75), 2.2) 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) + var spark_pos := mid + Vector2(cos(ang) * 10.0, sin(ang) * 6.0 - 4.0) + anim_layer.draw_circle(spark_pos, 2.8, Color("#fde047") if s_i % 2 == 0 else Color("#f97316")) static func make_pair_key(a: String, b: String) -> String: @@ -888,11 +658,7 @@ 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: - return 16.0 - var sf := 1.0 if count == 1 else (0.86 if count == 2 else 0.78) - return 44.0 * sf + return 16.0 if cell.deme_ids.is_empty() else 46.0 static func trim_road_segment(p_a: Vector2, p_b: Vector2, trim_a: float, trim_b: float) -> Dictionary: @@ -918,10 +684,6 @@ static func register_road_badge( 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 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: @@ -989,6 +751,8 @@ static func get_deme_archetype_prob(r: Node2D, deme: EvoTypes.Deme, target_arch: 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 is_vendetta_pair(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): @@ -1009,144 +773,58 @@ static func classify_village_pair_relation(r: Node2D, da: EvoTypes.Deme, db: Evo 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,16 +837,6 @@ 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, @@ -1192,111 +860,48 @@ 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 curve_f: float = float(road.get("curve_factor", 0.045)) + 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("#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) + ci.draw_polyline(pts, Color("#4a3219"), 12.0) + ci.draw_polyline(pts, Color("#8c6d46"), 9.0) + ci.draw_polyline(pts, Color("#d6b88a"), 5.5) 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 perp: Vector2 = road.get("edge_tangent", compute_edge_tangent(p_a, p_b)) + var steps := 18 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) + rail_left.append(pt - perp * 6.5 + Vector2(0, -4.0)) + rail_right.append(pt + perp * 6.5 + Vector2(0, -4.0)) + ci.draw_polyline(curve_pts, Color("#3d2314"), 13.0) + ci.draw_polyline(curve_pts, Color("#b07d4f"), 9.5) + ci.draw_polyline(rail_left, Color("#6b3e26"), 2.2) + ci.draw_polyline(rail_right, Color("#6b3e26"), 2.2) 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) + var mid: Vector2 = road.get("edge_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"), 9.5) + ci.draw_line(p_a, stop_a, Color("#785233"), 6.0) + ci.draw_line(p_b, stop_b, Color("#3b2313"), 9.5) + ci.draw_line(p_b, stop_b, Color("#785233"), 6.0) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, 18.0, 10.5, Color(0.16, 0.08, 0.05, 0.68)) static func draw_broken_cut_road(r: Node2D, road: Dictionary) -> void: @@ -1304,24 +909,12 @@ static func draw_broken_cut_road(r: Node2D, road: Dictionary) -> void: 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) + for i in range(6): + var pt := p_a.lerp(p_b, lerpf(rng.x, rng.y, float(i) / 5.0)) + ci.draw_circle(pt, 4.2, Color("#47423c") * mod_col) + ci.draw_circle(pt, 2.8, Color("#8c8479") * mod_col) static func draw_migration_expedition(r: Node2D, road: Dictionary) -> void: @@ -1329,11 +922,11 @@ static func draw_migration_expedition(r: Node2D, road: Dictionary) -> void: 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 + var steps := 16 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")) + ci.draw_circle(pt, 4.2, Color("#0369a1")) + ci.draw_circle(pt, 2.6, Color("#bae6fd") if i % 2 == 0 else Color("#e0f2fe")) static func draw_pact_highway(r: Node2D, road: Dictionary) -> void: @@ -1341,41 +934,14 @@ static func draw_pact_highway(r: Node2D, road: Dictionary) -> void: 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 segments := 22 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) + ci.draw_polyline(pts, Color(0.06, 0.73, 0.51, 0.32), 18.0) + ci.draw_polyline(pts, Color("#f59e0b"), 13.5) + ci.draw_polyline(pts, Color("#475569"), 10.5) + ci.draw_polyline(pts, Color("#cbd5e1"), 7.2) static func draw_merge_avenue(r: Node2D, road: Dictionary) -> void: @@ -1383,24 +949,14 @@ static func draw_merge_avenue(r: Node2D, road: Dictionary) -> void: 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 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(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) + ci.draw_polyline(pts, Color(0.98, 0.75, 0.14, 0.36), 18.0) + ci.draw_polyline(pts, Color("#10b981"), 13.5) + ci.draw_polyline(pts, Color("#fbbf24"), 9.5) + ci.draw_polyline(pts, Color("#fef3c7"), 6.0) static func draw_tribute_route(r: Node2D, road: Dictionary) -> void: @@ -1408,48 +964,25 @@ static func draw_tribute_route(r: Node2D, road: Dictionary) -> void: 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 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("#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) + ci.draw_polyline(pts, Color("#78350f"), 13.0) + ci.draw_polyline(pts, Color("#d97706"), 9.5) + ci.draw_polyline(pts, Color("#fbbf24"), 6.0) 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) + var mid: Vector2 = road.get("edge_mid", (p_a + p_b) * 0.5) + ci.draw_line(p_a, p_b, Color(0.50, 0.07, 0.07, 0.38), 16.0) + ci.draw_line(p_a, p_b, Color("#2a0a0a"), 12.0) + ci.draw_line(p_a, p_b, Color("#5c1919"), 8.0) + ci.draw_line(p_a, p_b, Color("#991b1b"), 4.2) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, 18.0, 10.5, Color(0.18, 0.04, 0.04, 0.72)) static func draw_war_animated_invasion(r: Node2D, road: Dictionary) -> void: @@ -1461,136 +994,67 @@ static func draw_war_animated_invasion(r: Node2D, road: Dictionary) -> void: 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 mid: Vector2 = road.get("edge_mid", (p_a + p_b) * 0.5) - # 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) + anim_layer.draw_line(p_a, p_b, Color(0.94, 0.27, 0.27, 0.52 * pulse), 4.0) 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) + anim_layer.draw_line(c_pos - dir * 5.0 + perp * 6.0, tip, Color("#fca5a5"), 2.4) + anim_layer.draw_line(c_pos - dir * 5.0 - perp * 6.0, 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) + anim_layer.draw_circle(w_pos, 4.2, atk_col) + var spear_tip := w_pos + dir * 10.0 + anim_layer.draw_line(w_pos, spear_tip, Color("#cbd5e1"), 2.0) + anim_layer.draw_circle(spear_tip, 2.0, Color("#fde047")) + draw_battle_sparks(r, mid) 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 perp: Vector2 = road.get("edge_tangent", compute_edge_tangent(p_a, p_b)) + var steps := 12 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 jag := 0.0 if i == steps else (4.8 if i % 2 == 1 else -4.8) 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")) + ci.draw_line(prev_pt, next_pt, Color("#431407"), 7.0) + ci.draw_line(prev_pt, next_pt, Color("#ef4444"), 4.2) 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) + anim_layer.draw_circle(r_pos, 4.5, Color("#f97316")) + anim_layer.draw_circle(r_pos, 2.2, Color("#fde047")) 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 mid: Vector2 = road.get("edge_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) - - # 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) + ci.draw_line(p_a, stop_a, Color("#334155"), 11.0) + ci.draw_line(p_b, stop_b, Color("#334155"), 11.0) + EvoWorldOverlayRenderer.draw_ellipse_filled(ci, mid, 18.0, 10.5, Color(0.18, 0.04, 0.22, 0.72)) static func draw_betrayal_animated_ruin(r: Node2D, road: Dictionary) -> void: @@ -1598,18 +1062,9 @@ static func draw_betrayal_animated_ruin(r: Node2D, road: Dictionary) -> void: 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 + var mid: Vector2 = road.get("edge_mid", (p_a + p_b) * 0.5) 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))) + var smoke_pos := mid + Vector2(sin(anim_time * 2.5 + float(s_i) * 1.7) * 9.0, -phase * 22.0) + var smoke_col := Color(0.49, 0.13, 0.81, (1.0 - phase) * 0.65) + anim_layer.draw_circle(smoke_pos, lerpf(3.0, 5.8, phase), smoke_col) diff --git a/src/world/render/world_overlay_renderer.gd b/src/world/render/world_overlay_renderer.gd index 578ec0f..4c5ae44 100644 --- a/src/world/render/world_overlay_renderer.gd +++ b/src/world/render/world_overlay_renderer.gd @@ -6,6 +6,26 @@ const BADGE_W := 114.0 const BADGE_H := 30.0 const SHIELD_W := 26.0 +static var _unit_circle_16 := PackedVector2Array() +static var _unit_circle_19 := PackedVector2Array() +static var _ellipse_fill_buf := PackedVector2Array() +static var _ellipse_line_buf := PackedVector2Array() + + +static func _ensure_ellipse_tables() -> void: + if not _unit_circle_16.is_empty(): + return + _unit_circle_16.resize(16) + _ellipse_fill_buf.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) + _ellipse_line_buf.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( world_state: EvoTypes.WorldState, @@ -14,18 +34,24 @@ static func _resolve_deme_strategy_visuals( ) -> 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 strat_icon := "🛡️" + var strat_color := fallback_color + var strat_name := deme.name if world_state.strategies_catalog.has(deme.strategy_id): var strategy: EvoTypes.StrategyDef = world_state.strategies_catalog[deme.strategy_id] - return { - "icon": strategy.icon, - "color": Color.from_string(strategy.color, fallback_color), - "name": EvoI18n.strategy_name(lang, strategy), - "slot_tag": slot_tag - } + strat_icon = strategy.icon + strat_color = Color.from_string(strategy.color, fallback_color) + strat_name = EvoI18n.strategy_name(lang, strategy) + var short_name := strat_name.split(" ")[0] + var short_label := ("%s · %s" % [slot_tag, short_name]) if slot_tag != "" else short_name + if short_label.length() > 13: + short_label = short_label.substr(0, 12) + "…" return { - "icon": "🛡️", - "color": fallback_color, - "name": deme.name, + "icon": strat_icon, + "color": strat_color, + "name": strat_name, + "label": strat_name, + "short_label": short_label, "slot_tag": slot_tag } @@ -48,11 +74,11 @@ static func draw_centered_sprite( static func draw_ellipse_filled(ci: CanvasItem, center: Vector2, rx: float, ry: float, col: Color) -> void: - var pts := PackedVector2Array() + _ensure_ellipse_tables() + var radii := Vector2(rx, ry) for i in range(16): - var a := TAU * float(i) / 16.0 - pts.append(center + Vector2(cos(a) * rx, sin(a) * ry)) - ci.draw_colored_polygon(pts, col) + _ellipse_fill_buf[i] = center + _unit_circle_16[i] * radii + ci.draw_colored_polygon(_ellipse_fill_buf, col) static func draw_ellipse_outline( @@ -63,11 +89,11 @@ static func draw_ellipse_outline( col: Color, width: float ) -> void: - var pts := PackedVector2Array() + _ensure_ellipse_tables() + var radii := Vector2(rx, ry) for i in range(19): - var a := TAU * float(i) / 18.0 - pts.append(center + Vector2(cos(a) * rx, sin(a) * ry)) - ci.draw_polyline(pts, col, width) + _ellipse_line_buf[i] = center + _unit_circle_19[i] * radii + ci.draw_polyline(_ellipse_line_buf, col, width) static func init_particles(particles: Array[Dictionary]) -> void: @@ -102,18 +128,18 @@ static func update_particles(r: Node2D, delta: float) -> void: static func draw_overlay(r: Node2D) -> void: if r.world_state == null: return + if r.active_micro_phase >= 1 and r.active_micro_phase <= 4: + _draw_micro_phase_highlights(r) _draw_deme_billboards(r) draw_seasonal_particles(r) -static func _draw_billboards(r: Node2D) -> void: - _draw_deme_billboards(r) - - static func _draw_deme_billboards(r: Node2D) -> void: var to_screen: Transform2D = r._world_layer.transform 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)) + var weights: Vector3 = r.get_zoom_tier_weights() + var tier: String = r.get_semantic_zoom_tier() r._deme_hit_rects.clear() var drawn_hex_rings: Dictionary = {} @@ -127,21 +153,243 @@ static func _draw_deme_billboards(r: Node2D) -> void: 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) + if tier == "macro": + _draw_macro_medallion(r, village, screen_pos, alpha) + continue r._deme_hit_rects.append({ "rect": to_screen * (village["hit_rect"] as Rect2), "deme_id": deme.id, "hex_id": hex_id }) + + 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) + var crest_pos: Vector2 = (to_screen * (v_pos + Vector2(0.0, -54.0 * sf))) + Vector2(0.0, -16.0) draw_village_billboard(r, village, crest_pos) + if weights.z > 0.05 or tier == "micro": + _draw_micro_diagnostics(r, village, v_pos, screen_pos, crest_pos, alpha * clampf(weights.z * 1.4, 0.45, 1.0)) + + +static func _draw_macro_medallion(r: Node2D, village: Dictionary, center: Vector2, alpha: float) -> void: + var ci: Node2D = r._overlay_layer + var deme: EvoTypes.Deme = village["deme"] + var font := ThemeDB.fallback_font + var strat_vis: Dictionary = village["strat_visuals"] + var strat_color: Color = strat_vis["color"] + var strat_icon: String = strat_vis["icon"] + var status_col: Color = village["status_col"] + var is_selected: bool = deme.id == r.selected_deme_id + var is_hovered: bool = deme.id == r.hovered_deme_id + var rad := 22.0 + + ci.draw_circle(center + Vector2(0.0, 2.0), rad + 1.5, Color(0.0, 0.0, 0.0, 0.42 * alpha)) + ci.draw_circle(center, rad, Color(0.10, 0.07, 0.05, 0.94 * alpha)) + + var ring_col := C_SELECTION if is_selected else (strat_color.lightened(0.28) if is_hovered else strat_color) + ring_col.a = alpha + ci.draw_arc(center, rad, 0.0, TAU, 28, ring_col, 3.2 if is_selected else 2.4) + + var vital_col := status_col + vital_col.a = 0.90 * alpha + ci.draw_arc(center, rad - 3.2, -PI * 0.85, -PI * 0.15, 16, vital_col, 2.2) + + ci.draw_string( + font, + Vector2(center.x - rad, center.y - 2.0), + strat_icon, + HORIZONTAL_ALIGNMENT_CENTER, + rad * 2.0, + 13, + Color(1.0, 1.0, 1.0, alpha) + ) + var pop_str := str(deme.population) + var pop_col := Color("#fde047") if is_selected else Color("#fef3c7") + pop_col.a = alpha + ci.draw_string( + font, + Vector2(center.x - rad, center.y + 13.0), + pop_str, + HORIZONTAL_ALIGNMENT_CENTER, + rad * 2.0, + 10, + pop_col + ) + r._deme_hit_rects.append({ + "rect": Rect2(center - Vector2(rad + 2.0, rad + 2.0), Vector2((rad + 2.0) * 2.0, (rad + 2.0) * 2.0)), + "deme_id": deme.id, + "hex_id": village["hex_id"] + }) + + +static func _draw_micro_diagnostics( + r: Node2D, + village: Dictionary, + v_pos: Vector2, + screen_pos: Vector2, + crest_pos: Vector2, + alpha: float +) -> void: + var ci: Node2D = r._overlay_layer + var deme: EvoTypes.Deme = village["deme"] + var font := ThemeDB.fallback_font + var to_screen: Transform2D = r._world_layer.transform + var sf: float = village["sf"] + + # 1. Energy Delta Badge (+12.0 E in green or -8.5 E in red) + var e_delta: float = float(r._deme_energy_delta.get(deme.id, 0.0)) + var e_text := "%+0.1f E" % e_delta + var e_col := Color("#34d399") if e_delta >= 0.0 else Color("#fb7185") + e_col.a = alpha + var e_rect := Rect2(crest_pos.x - BADGE_W * 0.5 - 56.0, crest_pos.y - 10.0, 52.0, 20.0) + ci.draw_rect(e_rect, Color(0.09, 0.06, 0.04, 0.92 * alpha), true) + ci.draw_rect(e_rect, e_col, false, 1.4) + ci.draw_string(font, Vector2(e_rect.position.x + 2.0, e_rect.position.y + 14.0), e_text, HORIZONTAL_ALIGNMENT_CENTER, e_rect.size.x - 4.0, 9, e_col) + + # 2. Evolutionary Progress Pips/Arcs (Baldwin /8, Innovation %, Isolation /10) + var max_baldwin := 0 + for b_val in deme.baldwin_counters.values(): + max_baldwin = maxi(max_baldwin, int(b_val)) + var innov_pct := int(round(clampf(deme.innovation_counter, 0.0, 1.0) * 100.0)) + var iso_turns := clampi(deme.isolation_turns, 0, 10) + + var z_scale: float = r.camera.zoom_level if r.camera != null else 1.0 + var diag_w := 142.0 + var diag_h := 20.0 + var diag_y_off := maxf(46.0, 48.0 * sf * z_scale) + var diag_rect := Rect2(screen_pos.x - diag_w * 0.5, screen_pos.y + diag_y_off, diag_w, diag_h) + ci.draw_rect(diag_rect, Color(0.09, 0.06, 0.04, 0.90 * alpha), true) + ci.draw_rect(diag_rect, Color(0.78, 0.65, 0.42, 0.72 * alpha), false, 1.2) + + var diag_str := "🧬%d/8 💡%d%% 🏝️%d/10" % [clampi(max_baldwin, 0, 8), innov_pct, iso_turns] + ci.draw_string( + font, + Vector2(diag_rect.position.x + 4.0, diag_rect.position.y + 13.0), + diag_str, + HORIZONTAL_ALIGNMENT_CENTER, + diag_w - 8.0, + 9, + Color(0.98, 0.95, 0.84, alpha) + ) + # Mini progress bar segments at bottom of diagnostic strip + var seg_w := (diag_w - 12.0) / 3.0 + var ratios := [ + clampf(float(max_baldwin) / 8.0, 0.0, 1.0), + clampf(deme.innovation_counter, 0.0, 1.0), + clampf(float(iso_turns) / 10.0, 0.0, 1.0) + ] + var bar_cols := [Color("#34d399"), Color("#fbbf24"), Color("#38bdf8")] + for i in range(3): + var bx := diag_rect.position.x + 4.0 + float(i) * (seg_w + 2.0) + var by := diag_rect.end.y - 3.5 + ci.draw_rect(Rect2(bx, by, seg_w, 2.0), Color(0.25, 0.22, 0.18, 0.8 * alpha), true) + var fc: Color = bar_cols[i] + fc.a = alpha + ci.draw_rect(Rect2(bx, by, seg_w * ratios[i], 2.0), fc, true) + + # 3. Compact labels on Ring 1 trait structures when zoom >= 1.55 or selected/hovered + var show_trait_labels: bool = ( + (r.camera != null and r.camera.zoom_level >= 1.55) + or deme.id == r.selected_deme_id + or deme.id == r.hovered_deme_id + ) + if show_trait_labels: + var morph: Dictionary = village["morphology"] + var ring_1: Array = morph["ring_1_traits"] + var plaza_center := v_pos + Vector2(0.0, 6.0 * sf) + var plinth_r := (56.0 if deme.merged_count > 0 else 51.0) * sf + var ring1_rx := plinth_r * 0.84 * sf + var ring1_ry := ring1_rx * 0.58 + for t_mod in ring_1: + var t_name: String = t_mod["trait_label"] + var ang := deg_to_rad(float(t_mod["angle_deg"]) - 90.0) + var out_dir := Vector2(cos(ang), sin(ang)) + var t_world := plaza_center + Vector2(out_dir.x * ring1_rx, out_dir.y * ring1_ry) + var t_scr := (to_screen * t_world) + Vector2(out_dir.x * 18.0, out_dir.y * 8.0 - 16.0) + var lw := 68.0 + var lh := 14.0 + var l_rect := Rect2(t_scr.x - lw * 0.5, t_scr.y - lh * 0.5, lw, lh) + var t_col: Color = t_mod["trait_color"] + t_col.a = alpha + ci.draw_rect(l_rect, Color(0.08, 0.06, 0.04, 0.86 * alpha), true) + ci.draw_rect(l_rect, t_col, false, 1.0) + ci.draw_string(font, Vector2(l_rect.position.x + 2.0, l_rect.position.y + 10.0), t_name, HORIZONTAL_ALIGNMENT_CENTER, lw - 4.0, 8, Color(0.98, 0.96, 0.90, alpha)) + + +static func _draw_micro_phase_highlights(r: Node2D) -> void: + var ci: Node2D = r._overlay_layer + var to_screen: Transform2D = r._world_layer.transform + var phase: int = r.active_micro_phase + var pulse := fposmod(r._anim_time * 2.0, 1.0) + var zoom_s: float = r.camera.zoom_level if r.camera != null else 1.0 + var font := ThemeDB.fallback_font + + if phase == 1: + # Phase 1 (Environment): Expanding emerald/amber resource pulse ring on terrestrial hexes + for hex_id in r._sorted_hex_ids: + var cell: EvoTypes.HexCell = r.world_state.cells[hex_id] + if r._is_water(cell) or not r._hex_centers.has(hex_id): + continue + var c_scr: Vector2 = to_screen * (r._hex_centers[hex_id] as Vector2) + var k_ratio := clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + var p_col := Color("#10b981") if k_ratio >= 0.75 else Color("#f59e0b") + p_col.a = (1.0 - pulse) * 0.65 + var rad := lerpf(16.0, 56.0, pulse) * zoom_s + draw_ellipse_outline(ci, c_scr, rad, rad * 0.58, p_col, 2.4) + elif phase == 2: + # Phase 2 (System 1 Decision): Prominent Decision Intent Crest + dashed intent tether + for village in r._villages: + var v_pos: Vector2 = village["pos"] + var v_scr: Vector2 = to_screen * v_pos + var crest_c := v_scr + Vector2(0.0, -102.0) + var strat_col: Color = village["strat_color"] + ci.draw_circle(crest_c, 16.0 + pulse * 6.0, Color(strat_col.r, strat_col.g, strat_col.b, (1.0 - pulse) * 0.65)) + ci.draw_circle(crest_c, 13.0, Color(0.10, 0.07, 0.04, 0.95)) + ci.draw_arc(crest_c, 13.0, 0.0, TAU, 20, C_SELECTION, 2.2) + var dec_tex: ImageTexture = EvoPixelArtGen.get_texture("icon_" + str(village["archetype"])) + ci.draw_texture_rect(dec_tex, Rect2(crest_c - Vector2(8.0, 8.0), Vector2(16.0, 16.0)), false) + + var hex_id: String = village["hex_id"] + if r.world_state.cells.has(hex_id): + var cell: EvoTypes.HexCell = r.world_state.cells[hex_id] + for n_id in HexMath.get_neighbor_ids(cell.q, cell.r, r.world_state.cells): + var ncell: EvoTypes.HexCell = r.world_state.cells[n_id] + if not ncell.deme_ids.is_empty() and r._hex_centers.has(n_id): + var n_scr: Vector2 = to_screen * (r._hex_centers[n_id] as Vector2) + for seg_i in range(6): + var t0 := float(seg_i) / 6.0 + var t1 := t0 + 0.08 + ci.draw_line(v_scr.lerp(n_scr, t0), v_scr.lerp(n_scr, t1), C_SELECTION, 2.0) + break + elif phase == 3: + # Phase 3 (Ecology & Borders): Pulsing border clash / symbiosis ring on active shared hex edges + for road in r._roads: + var edge_mid: Vector2 = road.get("edge_mid", ((road["p_a"] as Vector2) + (road["p_b"] as Vector2)) * 0.5) + var mid_scr: Vector2 = to_screen * edge_mid + var is_conflict: bool = str(road.get("kind", "")) in ["dispute", "war", "siege", "raid", "betrayal"] + var b_col := Color("#ef4444") if is_conflict else Color("#10b981") + b_col.a = (1.0 - pulse) * 0.85 + var rad := lerpf(12.0, 44.0, pulse) * zoom_s + ci.draw_arc(mid_scr, rad, 0.0, TAU, 24, b_col, 3.2) + elif phase == 4: + # Phase 4 (Demography & Milestones): Floating +Pop / -Pop delta indicator above settlements + for village in r._villages: + var deme: EvoTypes.Deme = village["deme"] + var v_scr: Vector2 = to_screen * (village["pos"] as Vector2) + var pop_delta: int = int(r._deme_pop_delta.get(deme.id, 0)) + var d_str := "%+d Pop" % pop_delta if r.lang == "en" else "%+d Hab" % pop_delta + var d_col := Color("#34d399") if pop_delta >= 0 else Color("#fb7185") + var float_pos := v_scr + Vector2(-32.0, -96.0 - pulse * 14.0) + ci.draw_rect(Rect2(float_pos, Vector2(64.0, 18.0)), Color(0.08, 0.06, 0.04, 0.90), true) + ci.draw_rect(Rect2(float_pos, Vector2(64.0, 18.0)), d_col, false, 1.5) + ci.draw_string(font, float_pos + Vector2(2.0, 13.0), d_str, HORIZONTAL_ALIGNMENT_CENTER, 60.0, 10, d_col) + static func _draw_hex_strategy_ring( r: Node2D, @@ -183,11 +431,11 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) var is_dimmed: bool = village["is_dimmed"] var font := ThemeDB.fallback_font - 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 top_text: String = strat_vis["short_label"] + var status_col: Color = village["status_col"] var badge_w := BADGE_W var badge_h := BADGE_H @@ -238,10 +486,6 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) 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( @@ -254,15 +498,20 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) top_col ) - var pop_text := ("%d pop" if r.lang == "en" else "%d hab") % deme.population + # Vital status dot + population text + var dot_col := status_col + dot_col.a = alpha + ci.draw_circle(Vector2(rect.position.x + SHIELD_W + 8.0, rect.position.y + 21.5), 3.0, dot_col) + + var pop_text: String = village["sub_text"] 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), + Vector2(rect.position.x + SHIELD_W + 14.0, rect.position.y + 25.0), pop_text, HORIZONTAL_ALIGNMENT_LEFT, - badge_w - SHIELD_W - 24.0, + badge_w - SHIELD_W - 32.0, 10, pop_col ) @@ -276,7 +525,7 @@ static func draw_village_billboard(r: Node2D, village: Dictionary, pos: Vector2) Color(1.0, 1.0, 1.0, alpha) ) - var geo_icons: Array = village.get("geo_icons", []) + var geo_icons: Array = village["geo_icons"] for g_idx in range(geo_icons.size()): var g_type := str(geo_icons[g_idx]) var pill_rect := Rect2( diff --git a/src/world/world_renderer.gd b/src/world/world_renderer.gd index 5f1c7ac..d6dea69 100644 --- a/src/world/world_renderer.gd +++ b/src/world/world_renderer.gd @@ -77,6 +77,33 @@ 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 OCEAN_SHADER_CODE := """ +shader_type canvas_item; +uniform float season_warmth : hint_range(0.0, 1.0) = 1.0; + +void fragment() { + vec2 uv = UV - vec2(0.5); + float dist = length(uv); + float wave = sin(uv.x * 18.0 + TIME * 2.2) * cos(uv.y * 14.0 - TIME * 1.8) * 0.04; + float shimmer = smoothstep(0.30, 0.48, dist + wave) * (0.08 + 0.04 * sin(TIME * 3.0 + uv.x * 10.0)); + vec3 base = COLOR.rgb * (1.0 - dist * 0.20) + vec3(shimmer * 0.6, shimmer * 0.85, shimmer); + base = mix(base * vec3(0.92, 0.96, 1.04), base, season_warmth); + COLOR = vec4(base, COLOR.a); +} +""" + +const TERRAIN_SHADER_CODE := """ +shader_type canvas_item; +uniform float season_warmth : hint_range(0.0, 1.0) = 1.0; + +void fragment() { + vec2 uv = UV - vec2(0.5); + float radial = clamp(1.0 - length(uv) * 0.30, 0.86, 1.04); + vec3 seasonal = mix(vec3(0.94, 0.96, 1.02), vec3(1.02, 1.0, 0.97), season_warmth); + COLOR = vec4(COLOR.rgb * radial * seasonal, COLOR.a); +} +""" + var world_state: EvoTypes.WorldState var camera: CameraController var lang: String = "es" @@ -88,6 +115,11 @@ var highlighted_lineage_id: String = "" var hovered_hex_id: String = "" var hovered_deme_id: String = "" var brush_radius: int = 1 +var active_micro_phase: int = 0 +var semantic_zoom_tier: String = "meso" + +var _ocean_shader_mat: ShaderMaterial +var _terrain_shader_mat: ShaderMaterial var _world_layer := Node2D.new() var _terrain_layer := Node2D.new() @@ -101,10 +133,14 @@ var _last_vp_size: Vector2 = Vector2.ZERO var _dirty: bool = true var _built_yaw: float = NAN var _built_pitch: float = NAN +var _last_zoom_weights: Vector3 = Vector3(0.0, 1.0, 0.0) var _ground := Transform2D.IDENTITY var _lift: float = 0.0 var _hex_uvs := PackedVector2Array() +var _hex_mid_uvs := PackedVector2Array() +var _hex_inner_uvs := PackedVector2Array() var _hex_corner_offsets := PackedVector2Array() +var _last_season_warmth: float = -1.0 var _columns: Dictionary = {} var _water_fx: Dictionary = {} @@ -124,6 +160,12 @@ var _column_state_cache: Dictionary = {} var _last_sort_signature: Array = [] var _cell_flora_cache: Dictionary = {} +var _prev_deme_pop: Dictionary = {} +var _deme_pop_delta: Dictionary = {} +var _prev_deme_energy: Dictionary = {} +var _deme_energy_delta: Dictionary = {} +var _delta_recorded_gen: int = -1 + var _press_pos: Vector2 = Vector2.ZERO var _is_left_down: bool = false var _last_dragged_hex_id: String = "" @@ -132,17 +174,23 @@ var _visible_world_rect: Rect2 = Rect2(-4000, -4000, 8000, 8000) var _last_world_transform: Transform2D = Transform2D.IDENTITY var _moving_prop_pool: Array[Prop] = [] var _moving_prop_count: int = 0 -var _villager_tex_sets: Dictionary = {} func _init() -> void: texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - 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)) - _hex_corner_offsets.append(Vector2(cos(rad), sin(rad)) * HexMath.HEX_SIZE) + _build_hex_uvs() EvoWorldOverlayRenderer.init_particles(_particles) + var ocean_sh := Shader.new() + ocean_sh.code = OCEAN_SHADER_CODE + _ocean_shader_mat = ShaderMaterial.new() + _ocean_shader_mat.shader = ocean_sh + + var terrain_sh := Shader.new() + terrain_sh.code = TERRAIN_SHADER_CODE + _terrain_shader_mat = ShaderMaterial.new() + _terrain_shader_mat.shader = terrain_sh + add_child(_world_layer) _world_layer.add_child(_terrain_layer) _world_layer.add_child(_network_layer) @@ -153,21 +201,80 @@ func _init() -> void: _overlay_layer.draw.connect(_draw_overlay) +func _build_hex_uvs() -> void: + var uv_center := Vector2(0.5, 0.5) + for i in range(6): + var rad := deg_to_rad(60.0 * float(i) - 30.0) + var dir := Vector2(cos(rad), sin(rad)) + var outer_uv := uv_center + dir * 0.46 + _hex_uvs.append(outer_uv) + _hex_mid_uvs.append(uv_center.lerp(outer_uv, 0.90)) + _hex_inner_uvs.append(uv_center.lerp(outer_uv, 0.58)) + _hex_corner_offsets.append(dir * HexMath.HEX_SIZE) + + func _ready() -> void: PixelArtGen.ensure_all_assets() +func get_semantic_zoom_tier() -> String: + if camera == null: + semantic_zoom_tier = "meso" + return semantic_zoom_tier + if camera.zoom_level < 0.68: + semantic_zoom_tier = "macro" + elif camera.zoom_level < 1.35: + semantic_zoom_tier = "meso" + else: + semantic_zoom_tier = "micro" + return semantic_zoom_tier + + +func get_zoom_tier_weights() -> Vector3: + if camera == null: + return Vector3(0.0, 1.0, 0.0) + var z := camera.zoom_level + var t_macro_to_meso := smoothstep(0.60, 0.76, z) + var t_meso_to_micro := smoothstep(1.27, 1.43, z) + var macro_w := clampf(1.0 - t_macro_to_meso, 0.0, 1.0) + var micro_w := clampf(t_meso_to_micro, 0.0, 1.0) + var meso_w := clampf(1.0 - macro_w - micro_w, 0.0, 1.0) + return Vector3(macro_w, meso_w, micro_w) + + +func set_active_micro_phase(phase: int) -> void: + active_micro_phase = clampi(phase, 0, 4) + _network_layer.queue_redraw() + _anim_layer.queue_redraw() + _overlay_layer.queue_redraw() + + +func get_settlement_morphology_summary(deme_id: String) -> Dictionary: + if world_state == null or not world_state.demes.has(deme_id): + return {} + var deme: EvoTypes.Deme = world_state.demes[deme_id] + var cell: EvoTypes.HexCell = world_state.cells.get(deme.hex_id, null) + return EvoPropFloraRenderer.build_settlement_morphology(deme, cell, world_state, lang) + + func _process(delta: float) -> void: if world_state == null or camera == null: return _anim_time += delta EvoWorldOverlayRenderer.update_particles(self, delta) + get_semantic_zoom_tier() if _dirty: _rebuild() elif camera.yaw_deg != _built_yaw or camera.pitch_deg != _built_pitch: _reproject() + var cur_weights := get_zoom_tier_weights() + if cur_weights.distance_to(_last_zoom_weights) > 0.02: + _last_zoom_weights = cur_weights + _layout_columns() + _network_layer.queue_redraw() + 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 _last_vp_size = vp_size @@ -198,7 +305,6 @@ func _process(delta: float) -> void: _fx_accum = fposmod(_fx_accum, 0.083) step_fx = true - _bob_ocean_columns() if step_fx or vp_changed: for hex_id in _animated_fx_ids: if _visible_world_rect.has_point(_hex_centers[hex_id]): @@ -210,12 +316,19 @@ func _process(delta: float) -> void: func set_world_state(p_state: EvoTypes.WorldState) -> void: world_state = p_state + _prev_deme_pop.clear() + _deme_pop_delta.clear() + _prev_deme_energy.clear() + _deme_energy_delta.clear() + _delta_recorded_gen = -1 + _last_season_warmth = -1.0 _create_columns() _rebuild() func set_camera(p_camera: CameraController) -> void: camera = p_camera + get_semantic_zoom_tier() _dirty = true @@ -227,9 +340,11 @@ func refresh_village_decisions() -> void: var any_arch_changed := false for village in _villages: var deme: EvoTypes.Deme = village["deme"] + var cell: EvoTypes.HexCell = world_state.cells.get(village["hex_id"], null) var new_arch := _get_deme_dominant_archetype(deme) if village["archetype"] != new_arch: village["archetype"] = new_arch + village["morphology"] = EvoPropFloraRenderer.build_settlement_morphology(deme, cell, world_state, lang) any_arch_changed = true if any_arch_changed: _static_props = EvoPropFloraRenderer.collect_static_props(self) @@ -310,12 +425,49 @@ func _create_columns() -> void: _water_fx[hex_id] = fx +func _update_deme_deltas() -> void: + if world_state == null: + return + if _delta_recorded_gen < 0: + for did in world_state.demes.keys(): + var d: EvoTypes.Deme = world_state.demes[did] + _prev_deme_pop[did] = d.population + _deme_pop_delta[did] = 0 + _prev_deme_energy[did] = d.energy_reserves + _deme_energy_delta[did] = d.last_energy_delta + _delta_recorded_gen = world_state.generation + return + + var gen_changed := world_state.generation != _delta_recorded_gen + var gen_completed := gen_changed and (active_micro_phase == 0 or active_micro_phase == 4) + for did in world_state.demes.keys(): + var d: EvoTypes.Deme = world_state.demes[did] + if gen_changed: + var base_pop: int = int(_prev_deme_pop[did]) if _prev_deme_pop.has(did) else d.population + _deme_pop_delta[did] = d.population - base_pop + if gen_completed: + _prev_deme_pop[did] = d.population + elif not _deme_pop_delta.has(did): + _prev_deme_pop[did] = d.population + _deme_pop_delta[did] = 0 + + if not is_equal_approx(d.last_energy_delta, 0.0) or gen_changed or not _deme_energy_delta.has(did): + _deme_energy_delta[did] = d.last_energy_delta + _prev_deme_energy[did] = d.energy_reserves + + if gen_completed: + _delta_recorded_gen = world_state.generation + + func _rebuild() -> void: _dirty = false + get_semantic_zoom_tier() + _last_zoom_weights = get_zoom_tier_weights() _built_yaw = camera.yaw_deg _built_pitch = camera.pitch_deg _ground = camera.ground_transform() _lift = camera.height_scale() + _update_deme_deltas() _brush_hexes = _compute_brush_hexes() _rebuild_cell_topology() _layout_columns() @@ -336,7 +488,7 @@ func _reproject() -> void: 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(36, 32) * sf, Vector2(72, 56) * sf) EvoRoadNetworkRenderer.reproject_roads(self) for prop in _static_props: prop.project(_ground, _lift) @@ -407,20 +559,29 @@ func _layout_columns() -> void: if column.get_index() != i: _terrain_layer.move_child(column, i) + var warmth := 1.0 if world_state.season == "wet_warm" else 0.0 + if warmth != _last_season_warmth: + _last_season_warmth = warmth + _ocean_shader_mat.set_shader_parameter("season_warmth", warmth) + _terrain_shader_mat.set_shader_parameter("season_warmth", warmth) _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 + var macro_band := snappedf(get_zoom_tier_weights().x, 0.05) for i in range(_sorted_hex_ids.size()): var hex_id := _sorted_hex_ids[i] var cell: EvoTypes.HexCell = world_state.cells[hex_id] var is_ocean := _is_water(cell) - if not is_ocean: - _columns[hex_id].position = Vector2.ZERO + var column: Node2D = _columns[hex_id] + var target_mat: ShaderMaterial = _ocean_shader_mat if is_ocean else _terrain_shader_mat + if column.material != target_mat: + column.material = target_mat var eff_biome: String = "ocean" if is_ocean else cell.biome 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 r_band: int = int(roundf(clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) * 20.0)) var is_brushed := _brush_hexes.has(hex_id) + var occ_deme_id: String = cell.deme_ids[0] if not cell.deme_ids.is_empty() else "" var col_key: Array = [ _built_yaw, @@ -434,12 +595,14 @@ func _layout_columns() -> void: is_brushed, active_tool == "inspect" if is_brushed else false, _coast_dirs[hex_id], - _diff_biome_dirs[hex_id] + _diff_biome_dirs[hex_id], + occ_deme_id, + macro_band if occ_deme_id != "" else 0.0 ] if not _column_state_cache.has(hex_id) or _column_state_cache[hex_id] != col_key: _column_state_cache[hex_id] = col_key - _columns[hex_id].queue_redraw() + column.queue_redraw() _water_fx[hex_id].queue_redraw() @@ -457,16 +620,6 @@ func _update_brush() -> void: _columns[hex_id].queue_redraw() -func _bob_ocean_columns() -> void: - var sea_z := _sea_z() - for hex_id in _ocean_ids: - if not _visible_world_rect.has_point(_hex_centers[hex_id]): - continue - var cell: EvoTypes.HexCell = world_state.cells[hex_id] - var wave := sin(_anim_time * 2.4 + float(cell.q) * 0.7 + float(cell.r) * 0.9) * 1.2 - _columns[hex_id].position.y = -(maxf(0.0, sea_z + wave) - sea_z) * _lift - - # ============================================================================ # GEOMETRY HELPERS # ============================================================================ @@ -512,7 +665,7 @@ func _cell_seed(q: int, r: int, salt: int = 0) -> int: # ============================================================================ -# BACKDROP & TERRAIN COLUMNS +# BACKDROP & VECTOR TERRAIN COLUMNS # ============================================================================ func _draw() -> void: @@ -547,31 +700,93 @@ func _draw_hex_column(column: Node2D, hex_id: String) -> void: 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] + var center: Vector2 = _hex_centers[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)) + # Multi-layered vector hex surface polygons + var k_ratio: float = clampf(cell.carrying_capacity / maxf(1.0, cell.carrying_capacity_max), 0.0, 1.0) + var res_ratio: float = clampf(cell.current_resources / maxf(1.0, cell.carrying_capacity), 0.0, 1.5) + var base_col: Color = pal["top"] + if not is_ocean: + if active_lens == "food" or active_lens == "health": + base_col = _get_top_tint(cell, false) + else: + if k_ratio < 0.75: + base_col = base_col.lerp(Color("#8c5e3c"), (0.75 - k_ratio) * 0.55) + elif res_ratio > 0.85: + base_col = base_col.lerp(pal["tuft"] as Color, clampf((res_ratio - 0.85) * 0.25, 0.0, 0.18)) + + var edge_col := base_col.darkened(0.16) + column.draw_colored_polygon(top_pts, edge_col, _hex_uvs) + + var mid_pts := PackedVector2Array() + var inner_pts := PackedVector2Array() + var contour_ring := PackedVector2Array() + for i in range(6): + mid_pts.append(center.lerp(top_pts[i], 0.90)) + inner_pts.append(center.lerp(top_pts[i], 0.58)) + contour_ring.append(center.lerp(top_pts[i], 0.74)) + contour_ring.append(contour_ring[0]) + + column.draw_colored_polygon(mid_pts, base_col, _hex_mid_uvs) + column.draw_colored_polygon(inner_pts, base_col.lightened(0.08), _hex_inner_uvs) + column.draw_polyline(contour_ring, base_col.lightened(0.14) if not is_ocean else Color(0.49, 0.83, 0.99, 0.22), 1.3) if not is_ocean: - _draw_hex_borders(column, cell, pal, tint_col, top_pts) + # Coastline sand bevel arcs + var coast_dirs: PackedInt32Array = _coast_dirs[hex_id] + for dir_idx in coast_dirs: + var c0 := top_pts[dir_idx] + var c1 := top_pts[(dir_idx + 1) % 6] + var sand_poly := PackedVector2Array([ + c0, + c1, + c1.lerp(center, 0.13), + c0.lerp(center, 0.13) + ]) + column.draw_colored_polygon(sand_poly, C_SAND) + + _draw_hex_borders(column, cell, pal, Color.WHITE, 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) + # Sovereign territorial color wash & bold territory border contour in Macro zoom + var macro_w := get_zoom_tier_weights().x + if macro_w > 0.05 and not cell.deme_ids.is_empty(): + var deme_id: String = cell.deme_ids[0] + if world_state.demes.has(deme_id): + var deme: EvoTypes.Deme = world_state.demes[deme_id] + var terr_col := EvoPropFloraRenderer.resolve_deme_strategy_color(world_state, deme) + var wash_col := terr_col + wash_col.a = 0.28 * macro_w + column.draw_colored_polygon(top_pts, wash_col, _hex_uvs) + var terr_ring := PackedVector2Array() + for i in range(7): + terr_ring.append(center.lerp(top_pts[i % 6], 0.92)) + var border_col := terr_col.lightened(0.18) + border_col.a = 0.88 * macro_w + column.draw_polyline(terr_ring, border_col, 3.6) + + if hex_id == selected_hex_id or _brush_hexes.has(hex_id): + var border_pts := top_pts.duplicate() + border_pts.append(top_pts[0]) + if _brush_hexes.has(hex_id) and active_tool != "inspect": + column.draw_colored_polygon(top_pts, Color(0.22, 0.74, 0.97, 0.16), _hex_uvs) + var inner_brush := PackedVector2Array() + for i in range(7): + inner_brush.append(center.lerp(top_pts[i % 6], 0.91)) + column.draw_polyline(inner_brush, Color("#38bdf8"), 2.4) + column.draw_polyline(border_pts, C_SELECTION if hex_id == selected_hex_id else Color("#fde047"), 3.4) + elif hex_id == selected_hex_id: + column.draw_polyline(border_pts, C_SELECTION, 3.4) + else: + column.draw_polyline(border_pts, Color("#38bdf8"), 2.6) func _draw_cliff_walls( @@ -755,10 +970,6 @@ func _draw_overlay() -> void: 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, diff --git a/tests/test_runner.gd b/tests/test_runner.gd index e354022..b589f9e 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -53,6 +53,41 @@ func _assert(cond: bool, msg: String) -> void: printerr("[FAIL] " + msg) +func _assert_single_deme_per_cell_invariant(state: EvoTypes.WorldState, context_label: String) -> void: + var all_single := true + var bad_detail := "" + for cid in state.cells.keys(): + var cell: EvoTypes.HexCell = state.cells[cid] + if cell.deme_ids.size() > 1: + all_single = false + bad_detail = "cell %s has %d demes (%s)" % [cid, cell.deme_ids.size(), str(cell.deme_ids)] + break + if cell.deme_ids.size() == 1: + var did: String = cell.deme_ids[0] + if not state.demes.has(did): + all_single = false + bad_detail = "cell %s references missing deme %s" % [cid, did] + break + var d: EvoTypes.Deme = state.demes[did] + if d.hex_id != cell.id: + all_single = false + bad_detail = "deme %s hex_id (%s) != cell.id (%s)" % [did, d.hex_id, cell.id] + break + if all_single: + for did in state.demes.keys(): + var d: EvoTypes.Deme = state.demes[did] + if not state.cells.has(d.hex_id): + all_single = false + bad_detail = "deme %s points to non-existent cell %s" % [did, d.hex_id] + break + var c: EvoTypes.HexCell = state.cells[d.hex_id] + if not c.deme_ids.has(d.id): + all_single = false + bad_detail = "cell %s deme_ids (%s) missing resident deme %s" % [c.id, str(c.deme_ids), d.id] + break + _assert(all_single, "Single-deme-per-cell invariant holds [%s]%s" % [context_label, (": " + bad_detail) if bad_detail != "" else ""]) + + 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) @@ -84,15 +119,18 @@ func _test_catalog_and_presets() -> void: _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") + _assert_single_deme_per_cell_invariant(rift, "preset-rift-savanna init") 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") + _assert_single_deme_per_cell_invariant(darwin, "preset-darwin-archipelago init") 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)") + _assert_single_deme_per_cell_invariant(island, "preset-finite-island init") 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") @@ -155,6 +193,7 @@ func _test_strategies_duel_and_persistence() -> void: 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") + _assert_single_deme_per_cell_invariant(duel_world, "duel_world colonist budding") var spec_world := EvoCatalog.create_preset_world("preset-darwin-archipelago", "en", "strat-expansion", "strat-war") spec_world.generation = 12 @@ -166,6 +205,7 @@ func _test_strategies_duel_and_persistence() -> void: 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") + _assert_single_deme_per_cell_invariant(spec_world, "spec_world speciation") # 4. JSON persistence of custom user strategies and traits via EvoUserSettings var tmp_dir := "/tmp/evolution_test_catalog_%d" % Time.get_ticks_usec() @@ -304,16 +344,36 @@ func _test_hawk_dove_and_symbiosis() -> void: 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 + # Place d1 in 0,0 and d2 in adjacent 1,0 for inter-cell border 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]) + d2.hex_id = "1,0" + (state.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(d1.id) + (state.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(d2.id) + _assert_single_deme_per_cell_invariant(state, "hawk_dove_setup") + + # 1. Force Hawk (d1) vs Dove (d2) across shared hex border 0,0 -> 1,0 (resource annexation) + var dove_cell: EvoTypes.HexCell = state.cells["1,0"] + dove_cell.current_resources = 300.0 + d1.last_decision_probabilities = {"dec-territorial-dispute": 0.70, "dec-conservative-forage": 0.30} + d2.last_decision_probabilities = {"dec-conservative-forage": 0.90, "dec-cooperate-store": 0.10} + var hd_vecs := { + d1.id: EvoEcologyInteraction.compute_effective_vector(d1, state), + d2.id: EvoEcologyInteraction.compute_effective_vector(d2, state) + } + var hd_hawks := {d1.id: 1.0, d2.id: 1.0} + var hd_ext := {d1.id: 0.0, d2.id: 0.0} + var hd_flows: Array[EvoTypes.HexEdgeFlow] = [] + var d1_own_demand := float(d1.population) * 0.50 * (1.0 + 0.60 * (hd_vecs[d1.id] as EvoTypes.BiophysicalVector).extraction_efficiency) + EvoEcologyInteraction.run_resource_and_symbiosis_phase(state, "es", hd_vecs, hd_hawks, hd_ext, hd_flows) + _assert(float(hd_ext[d1.id]) > d1_own_demand, "Inter-cell Hawk vs Dove annexes border resources into Hawk extracted energy (%.1f > %.1f)" % [float(hd_ext[d1.id]), d1_own_demand]) + _assert_single_deme_per_cell_invariant(state, "hawk_dove_annexation") + + # 2. Force Hawk vs Hawk between d1 (0,0) and d2 (1,0) + state.active_flows.clear() 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") @@ -323,17 +383,27 @@ func _test_hawk_dove_and_symbiosis() -> void: 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") + _assert(has_dispute_flow, "Hawk vs Hawk generates inter-cell dispute flow") + _assert(d1.population < init_pop_1 and d2.population < init_pop_2, "Hawks suffer casualties and metabolic penalty across shared border") + _assert_single_deme_per_cell_invariant(state, "hawk_hawk_border") + + # 3. Force Symbiosis and Horizontal Cultural Diffusion between adjacent d1 (0,0) and d2 (1,0) + var state_sym := EvoCatalog.create_preset_world("preset-finite-island", "es") + var ds1: EvoTypes.Deme = state_sym.demes["deme-island-1"] + var ds2: EvoTypes.Deme = state_sym.demes["deme-island-2"] + (state_sym.cells[ds1.hex_id] as EvoTypes.HexCell).deme_ids.erase(ds1.id) + (state_sym.cells[ds2.hex_id] as EvoTypes.HexCell).deme_ids.erase(ds2.id) + ds1.hex_id = "0,0" + ds2.hex_id = "1,0" + (state_sym.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(ds1.id) + (state_sym.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(ds2.id) + ds1.cultural_trait_ids.assign(["cul-fire-processing"]) + ds2.cultural_trait_ids.clear() + ds1.last_decision_probabilities = {"dec-exchange-symbiosis": 0.80, "dec-cooperate-store": 0.20} + ds2.last_decision_probabilities = {"dec-exchange-symbiosis": 0.80, "dec-cooperate-store": 0.20} + EvoBiophysPhase.run_biophysical_phase(state_sym, "es") + _assert(ds2.cultural_trait_ids.has("cul-fire-processing"), "Inter-cell horizontal cultural diffusion transmitted cul-fire-processing to d2") + _assert_single_deme_per_cell_invariant(state_sym, "hawk_dove_symbiosis") func _test_tragedy_of_the_commons() -> void: @@ -347,6 +417,7 @@ func _test_tragedy_of_the_commons() -> void: var tragedy_hex_id := "" for _g in range(12): EvoTurnEngine.advance_generation(island, "es") + _assert_single_deme_per_cell_invariant(island, "tragedy_of_commons_gen_%d" % island.generation) for entry in island.chronicle: if entry.event_type == "tragedy_of_commons": tragedy_occurred = true @@ -369,6 +440,7 @@ func _test_baldwin_innovation_and_speciation() -> void: 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") + _assert_single_deme_per_cell_invariant(state_b, "baldwin_assimilation") # 2. Cultural innovation & dec-explore-innovate eligibility var state_c := EvoCatalog.create_preset_world("preset-rift-savanna", "es") @@ -395,6 +467,7 @@ func _test_baldwin_innovation_and_speciation() -> void: 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") + _assert_single_deme_per_cell_invariant(state_c, "cultural_innovation") # 3. Allopatric speciation var state_s := EvoCatalog.create_preset_world("preset-darwin-archipelago", "es") @@ -411,21 +484,27 @@ func _test_baldwin_innovation_and_speciation() -> void: _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)") + _assert_single_deme_per_cell_invariant(state_s, "allopatric_speciation") 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 + # 1. Inter-cell kin merging across shared border (-2,1 and -1,1) 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"] + (state_m.cells[deme_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(deme_a.id) + deme_a.hex_id = "-2,1" + (state_m.cells["-2,1"] as EvoTypes.HexCell).set_resident_deme_id(deme_a.id) + 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.hex_id = "-1,1" 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) + (state_m.cells["-1,1"] as EvoTypes.HexCell).set_resident_deme_id(deme_b.id) + _assert_single_deme_per_cell_invariant(state_m, "kin_merge_setup") var inter_flow := EvoTypes.HexEdgeFlow.new() inter_flow.from_hex_id = deme_b.hex_id @@ -446,7 +525,9 @@ func _test_kin_merge_fission_and_macro_outcome() -> void: 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") + _assert(not state_m.demes.has(deme_b.id), "Adjacent kin deme_b (-1,1) merged into deme_a (-2,1)") + _assert((state_m.cells["-1,1"] as EvoTypes.HexCell).deme_ids.is_empty(), "Merged kin cell -1,1 is vacated after inter-cell kin merge") + _assert_single_deme_per_cell_invariant(state_m, "kin_merge_after") 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: @@ -454,7 +535,7 @@ func _test_kin_merge_fission_and_macro_outcome() -> void: 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 + # 2. Fission migration into empty neighbor cell 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 @@ -476,6 +557,7 @@ func _test_kin_merge_fission_and_macro_outcome() -> void: 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") + _assert_single_deme_per_cell_invariant(state_f, "fission_migration_empty_neighbor") # 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") @@ -490,7 +572,6 @@ func _test_kin_merge_fission_and_macro_outcome() -> void: "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() @@ -500,6 +581,7 @@ func _test_kin_merge_fission_and_macro_outcome() -> void: 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") + _assert_single_deme_per_cell_invariant(state_mo, "macro_outcome_turns") func _test_interventions() -> void: @@ -528,6 +610,7 @@ func _test_interventions() -> void: 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") + _assert_single_deme_per_cell_invariant(state, "interventions") func _test_serialization_roundtrip() -> void: @@ -554,6 +637,17 @@ func _test_serialization_roundtrip() -> void: _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") + _assert_single_deme_per_cell_invariant(restored, "serialization_roundtrip") + + # Verify legacy multi-deme cell snapshot sanitization in WorldState.from_dict() + var legacy_dict: Dictionary = (parsed as Dictionary).duplicate(true) + var demes_dict: Dictionary = legacy_dict["demes"] + var cells_dict: Dictionary = legacy_dict["cells"] + (demes_dict["deme-rift-east"] as Dictionary)["hex_id"] = "-2,1" + (cells_dict["2,-1"] as Dictionary)["deme_ids"] = [] + (cells_dict["-2,1"] as Dictionary)["deme_ids"] = ["deme-rift-west", "deme-rift-east"] + var sanitized := EvoTypes.WorldState.from_dict(legacy_dict) + _assert_single_deme_per_cell_invariant(sanitized, "legacy_snapshot_sanitization") func _test_25_generations_all_presets() -> void: @@ -577,6 +671,7 @@ func _test_25_generations_all_presets() -> void: 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_single_deme_per_cell_invariant(state, "%s_gen_%d" % [preset_id, state.generation]) _assert(state.generation == 25, "%s reached generation 25 cleanly" % preset_id) for did in state.demes.keys(): var deme: EvoTypes.Deme = state.demes[did] @@ -600,30 +695,65 @@ func _test_main_scene_and_ui_integration() -> void: _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") + _assert_single_deme_per_cell_invariant(main.world_state, "main_scene_startup") - # Verify all 52 PNG files in res://assets/sprites/ and ImageTexture generation + # Verify in-memory UI icons, semantic zoom tiers, procedural settlement morphology & 4-step micro-phase parity 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 + var ui_sprites_ok: int = 0 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) + _assert(tex != null and tex.get_width() > 0, "Valid in-memory UI ImageTexture for %s" % s_name) + ui_sprites_ok += 1 + _assert(ui_sprites_ok == EvoPixelArtGen.UI_SPRITES.size(), "All %d UI icon textures generated in memory" % ui_sprites_ok) + + main.camera.zoom_level = 0.45 + _assert(main.world_renderer.get_semantic_zoom_tier() == "macro", "Zoom 0.45 resolves to 'macro' semantic zoom tier") + main.camera.zoom_level = 1.00 + _assert(main.world_renderer.get_semantic_zoom_tier() == "meso", "Zoom 1.00 resolves to 'meso' semantic zoom tier") + main.camera.zoom_level = 1.80 + _assert(main.world_renderer.get_semantic_zoom_tier() == "micro", "Zoom 1.80 resolves to 'micro' semantic zoom tier") + main.camera.zoom_level = 1.00 + + var morph_deme: EvoTypes.Deme = main.world_state.demes["deme-rift-west"] + morph_deme.defense_tier = 2 + var morph := main.world_renderer.get_settlement_morphology_summary("deme-rift-west") + var ring_0: Dictionary = morph.get("ring_0_core", {}) + var ring_1: Array = morph.get("ring_1_traits", []) + var ring_2: Dictionary = morph.get("ring_2_defense", {}) + var b_vit: Dictionary = morph.get("biome_vitality", {}) + var expected_trait_count := morph_deme.biological_trait_ids.size() + morph_deme.cultural_trait_ids.size() + _assert(not ring_0.is_empty() and str(ring_0.get("archetype", "")) != "" and int(ring_0.get("tier", 0)) == morph_deme.get_village_tier(), "Settlement morphology ring_0_core reflects deme archetype and tier") + _assert(ring_1.size() == expected_trait_count, "Settlement morphology ring_1_traits count matches biological_trait_ids + cultural_trait_ids (%d)" % expected_trait_count) + _assert(int(ring_2.get("defense_tier", -1)) == 2 and str(ring_2.get("wall_type", "")) == "stone_wall", "Settlement morphology ring_2_defense matches defense_tier == 2 ('stone_wall')") + _assert(b_vit.has("k_ratio") and b_vit.has("res_ratio") and float(b_vit.get("k_ratio", 0.0)) > 0.0, "Settlement morphology biome_vitality reports valid k_ratio and res_ratio") + morph_deme.defense_tier = 0 + + # Verify EvoTurnEngine 4-step micro-phase parity with advance_generation & HUD/renderer phase indicator updates + var state_full := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var state_micro := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var parity_entry := { + "probabilities": {"dec-cooperate-store": 0.65, "dec-conservative-forage": 0.35}, + "dominant_decision_id": "dec-cooperate-store", + "confidence": 0.90 + } + for s_ref in [state_full, state_micro]: + for did in s_ref.demes.keys(): + var d_ref: EvoTypes.Deme = s_ref.demes[did] + var sig := EvoDecisionModel.compute_deme_evaluation_signature(d_ref, s_ref) + EvoOllamaClient.seed_signature_cache(sig, parity_entry) + var full_out := EvoTurnEngine.advance_generation(state_full, "es") + EvoTurnEngine.step_phase_1_environment(state_micro, "es") + EvoTurnEngine.step_phase_2_decisions(state_micro, "es") + EvoTurnEngine.step_phase_3_ecology_borders(state_micro, "es") + var micro_out := EvoTurnEngine.step_phase_4_demography_milestones(state_micro, "es") + _assert( + state_micro.generation == state_full.generation + and state_micro.demes.size() == state_full.demes.size() + and (state_micro.demes["deme-rift-west"] as EvoTypes.Deme).population == (state_full.demes["deme-rift-west"] as EvoTypes.Deme).population + and is_equal_approx((state_micro.demes["deme-rift-west"] as EvoTypes.Deme).energy_reserves, (state_full.demes["deme-rift-west"] as EvoTypes.Deme).energy_reserves) + and bool(micro_out["has_milestone"]) == bool(full_out["has_milestone"]), + "4-step micro-phase execution produces identical world state to advance_generation" + ) # Verify Dual System 1 Decision Models (d3:4b + tev1:4b), Settings Persistence & Dynamic UI Text EvoUserSettings.config_path_override = "" @@ -691,6 +821,17 @@ func _test_main_scene_and_ui_integration() -> void: EvoUserSettings.context_length = EvoUserSettings.DEFAULT_CONTEXT_LENGTH main.hud._apply_decision_models_update() + # Verify main._step_micro_phase() updates hud._active_micro_phase and world_renderer.active_micro_phase + var gen_before_micro := main.world_state.generation + main._step_micro_phase() + _assert(main.hud._active_micro_phase == 1 and main.world_renderer.active_micro_phase == 1, "Micro-phase 1 updates HUD and WorldRenderer active_micro_phase to 1") + main._step_micro_phase() + _assert(main.hud._active_micro_phase == 2 and main.world_renderer.active_micro_phase == 2, "Micro-phase 2 updates HUD and WorldRenderer active_micro_phase to 2") + main._step_micro_phase() + _assert(main.hud._active_micro_phase == 3 and main.world_renderer.active_micro_phase == 3, "Micro-phase 3 updates HUD and WorldRenderer active_micro_phase to 3") + main._step_micro_phase() + _assert(main.hud._active_micro_phase == 4 and main.world_renderer.active_micro_phase == 4 and main.world_state.generation == gen_before_micro + 1, "Micro-phase 4 completes generation and updates active_micro_phase to 4") + # Advance generations via main.step_generation() var gen_before := main.world_state.generation main.step_generation() @@ -760,24 +901,25 @@ func _test_main_scene_and_ui_integration() -> void: # 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") + _assert(b_layout.size() >= 1 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 + # Verify disused_connections lifecycle aging and HUD inspector zero-information-loss across adjacent cells 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]) + ld2.hex_id = "1,0" + (life_state.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(ld1.id) + (life_state.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_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.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" @@ -789,9 +931,10 @@ func _test_main_scene_and_ui_integration() -> void: 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") + _assert_single_deme_per_cell_invariant(life_state, "disused_connections_lifecycle") 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") + _assert(rel_lines.size() >= 2, "HUD inspector preserves both active conflict and severed path 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") @@ -840,7 +983,7 @@ func _test_nonblocking_async_decision_and_smooth_panning() -> void: land_hexes.append(hid) for i in range(14): - var target_hid := land_hexes[i % land_hexes.size()] + var target_hid := land_hexes[i] var d := EvoTypes.Deme.new() d.id = "deme-perf-test-%d" % i d.name = "Clan Perf %d" % i @@ -854,9 +997,10 @@ func _test_nonblocking_async_decision_and_smooth_panning() -> void: 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) + (state.cells[target_hid] as EvoTypes.HexCell).set_resident_deme_id(d.id) _assert(state.demes.size() >= 16, "Late-game stress world populated with %d demes" % state.demes.size()) + _assert_single_deme_per_cell_invariant(state, "stress_world_setup") var eval_start_usec := Time.get_ticks_usec() EvoDecisionModel.evaluate_all_demes(state, "es") @@ -879,6 +1023,7 @@ func _test_nonblocking_async_decision_and_smooth_panning() -> void: 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) + _assert_single_deme_per_cell_invariant(state, "stress_world_step_%d" % _step_i) 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 @@ -1006,7 +1151,9 @@ func _test_emergent_geopolitics_and_visual_artifacts() -> void: (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_d1.last_dominant_decision_id = "dec-cooperate-store" p_d2.last_decision_probabilities = {"dec-exchange-symbiosis": 0.75, "dec-conservative-forage": 0.25} + p_d2.last_dominant_decision_id = "dec-exchange-symbiosis" 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) @@ -1109,6 +1256,7 @@ func _test_emergent_geopolitics_and_visual_artifacts() -> void: var warband_created := false for g in range(18): EvoTurnEngine.advance_generation(state_war, "en") + _assert_single_deme_per_cell_invariant(state_war, "state_war_gen_%d" % state_war.generation) for fl in state_war.active_flows: if fl.flow_type == "war": war_flows_seen += 1 @@ -1121,24 +1269,11 @@ func _test_emergent_geopolitics_and_visual_artifacts() -> void: _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) + # 7. Procedural UI icons & renderer settlement morphology integration + EvoPixelArtGen.ensure_all_assets() + for skey in ["icon_war", "icon_raid", "icon_pact", "icon_betrayal"]: 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) + _assert(tex != null and tex.get_width() >= 16 and tex.get_height() >= 16, "In-memory geopolitical icon 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) @@ -1156,6 +1291,8 @@ func _test_emergent_geopolitics_and_visual_artifacts() -> void: main.hud.set_world_state(state_pact) main.world_renderer._rebuild() main.world_renderer._process(0.016) + var pact_morph := main.world_renderer.get_settlement_morphology_summary(p_d1.id) + _assert(not pact_morph.is_empty() and str((pact_morph["ring_0_core"] as Dictionary).get("archetype", "")) == "cooperate", "WorldRenderer settlement morphology reflects cooperative pact deme") 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") @@ -1215,6 +1352,182 @@ func _test_bugfix_regressions() -> void: _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. Mid-turn export / EvoTurnEngine.complete_in_flight_turn flushing an in-flight micro-phase (1..3) before to_dict() + for stop_phase in [1, 2, 3]: + var s_ref := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + var s_mid := EvoCatalog.create_preset_world("preset-rift-savanna", "es") + EvoTurnEngine.advance_generation(s_ref, "es") + EvoTurnEngine.step_phase_1_environment(s_mid, "es") + if stop_phase >= 2: + EvoTurnEngine.step_phase_2_decisions(s_mid, "es") + if stop_phase >= 3: + EvoTurnEngine.step_phase_3_ecology_borders(s_mid, "es") + _assert(int(s_mid.get_meta("_in_flight_micro_phase", 0)) == stop_phase, "In-flight micro-phase recorded as %d before flush" % stop_phase) + var dict_mid := s_mid.to_dict() + var restored_mid := EvoTypes.WorldState.from_dict(dict_mid) + _assert(not s_mid.has_meta("_in_flight_micro_phase") and not s_mid.has_meta("_step3_ctx"), "to_dict() flushes in-flight micro-phase %d via complete_in_flight_turn" % stop_phase) + _assert( + restored_mid.generation == s_ref.generation + and (restored_mid.demes["deme-rift-west"] as EvoTypes.Deme).population == (s_ref.demes["deme-rift-west"] as EvoTypes.Deme).population + and is_equal_approx((restored_mid.demes["deme-rift-west"] as EvoTypes.Deme).energy_reserves, (s_ref.demes["deme-rift-west"] as EvoTypes.Deme).energy_reserves), + "Mid-turn export at micro-phase %d produces identical completed turn state to advance_generation" % stop_phase + ) + + # 7. Cross-lineage synoecism marks absorbed lineage extinct_generation = state.generation when its last deme merges + var state_syn_ext := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") + state_syn_ext.generation = 7 + var se_a: EvoTypes.Deme = state_syn_ext.demes["deme-rift-west"] + var se_b: EvoTypes.Deme = state_syn_ext.demes["deme-rift-east"] + var absorbed_lin_id := se_b.lineage_id + (state_syn_ext.cells[se_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(se_a.id) + (state_syn_ext.cells[se_b.hex_id] as EvoTypes.HexCell).deme_ids.erase(se_b.id) + se_a.hex_id = "0,0" + se_b.hex_id = "1,0" + (state_syn_ext.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(se_a.id) + (state_syn_ext.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(se_b.id) + se_a.population = 120 + se_b.population = 95 + state_syn_ext.active_pacts[EvoTypes.make_pair_key(se_a.id, se_b.id)] = 4 + var se_vecs := { + se_a.id: EvoEcologyInteraction.compute_effective_vector(se_a, state_syn_ext), + se_b.id: EvoEcologyInteraction.compute_effective_vector(se_b, state_syn_ext) + } + EvoDemographyMigration.run_demography_and_migration_phase( + state_syn_ext, + "en", + {se_a.id: 120, se_b.id: 95}, + se_vecs, + {}, + {se_a.id: 75.0, se_b.id: 65.0} + ) + _assert( + state_syn_ext.lineage_tree.has(absorbed_lin_id) + and (state_syn_ext.lineage_tree[absorbed_lin_id] as EvoTypes.LineageNode).extinct_generation == state_syn_ext.generation, + "Cross-lineage synoecism marks absorbed lineage extinct_generation == state.generation (%d) when its last deme merges" % state_syn_ext.generation + ) + + # 8. Vendetta (< 0) preservation and no 0 pollution in state.active_pacts during merge + var state_vp := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-trade") + var vp_a: EvoTypes.Deme = state_vp.demes["deme-rift-west"] + var vp_b: EvoTypes.Deme = state_vp.demes["deme-rift-east"] + (state_vp.cells[vp_a.hex_id] as EvoTypes.HexCell).deme_ids.erase(vp_a.id) + (state_vp.cells[vp_b.hex_id] as EvoTypes.HexCell).deme_ids.erase(vp_b.id) + vp_a.hex_id = "0,0" + vp_b.hex_id = "1,0" + (state_vp.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(vp_a.id) + (state_vp.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(vp_b.id) + var vp_c := vp_b.duplicate_deme() + vp_c.id = "deme-rival-c" + vp_c.lineage_id = "lineage-rival-c" + vp_c.hex_id = "2,-1" + (state_vp.cells["2,-1"] as EvoTypes.HexCell).set_resident_deme_id(vp_c.id) + state_vp.demes[vp_c.id] = vp_c + var vp_d := vp_b.duplicate_deme() + vp_d.id = "deme-rival-d" + vp_d.lineage_id = "lineage-rival-d" + vp_d.hex_id = "-2,1" + (state_vp.cells["-2,1"] as EvoTypes.HexCell).set_resident_deme_id(vp_d.id) + state_vp.demes[vp_d.id] = vp_d + state_vp.active_pacts[EvoTypes.make_pair_key(vp_a.id, vp_b.id)] = 4 + state_vp.active_pacts[EvoTypes.make_pair_key(vp_b.id, vp_c.id)] = -3 + state_vp.active_pacts[EvoTypes.make_pair_key(vp_a.id, vp_d.id)] = -2 + state_vp.active_pacts[EvoTypes.make_pair_key(vp_b.id, vp_d.id)] = -4 + EvoDemographyMigration._merge_deme_into(state_vp, vp_a, vp_b, state_vp.cells["0,0"], {}, {}, true, "en", {}) + var pk_ac := EvoTypes.make_pair_key(vp_a.id, vp_c.id) + var pk_ad := EvoTypes.make_pair_key(vp_a.id, vp_d.id) + _assert(int(state_vp.active_pacts.get(pk_ac, 0)) == -3, "Merge transfers joiner vendetta (-3) to host when host had no prior entry") + _assert(int(state_vp.active_pacts.get(pk_ad, 0)) == -4, "Merge preserves deeper vendetta mini(-2, -4) == -4 when both had vendettas") + var has_zero_or_stale_pact := false + for pk_k in state_vp.active_pacts.keys(): + if int(state_vp.active_pacts[pk_k]) == 0 or str(pk_k).contains(vp_b.id): + has_zero_or_stale_pact = true + break + _assert(not has_zero_or_stale_pact, "Merge leaves no 0 values or stale joiner keys in state.active_pacts") + + # 9. EvoRoadNetworkRenderer.collect_roads selects higher-priority war/merge/raid flows over baseline dispute/exchange on same hex pair + var packed_rd: PackedScene = load("res://scenes/main.tscn") + var main_rd: EvoMainController = packed_rd.instantiate() as EvoMainController + main_rd.quit_on_close_enabled = false + root.add_child(main_rd) + if main_rd.camera == null: + main_rd._ready() + var state_rd := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-war", "strat-cooperation") + var rd_w: EvoTypes.Deme = state_rd.demes["deme-rift-west"] + var rd_e: EvoTypes.Deme = state_rd.demes["deme-rift-east"] + (state_rd.cells[rd_w.hex_id] as EvoTypes.HexCell).deme_ids.erase(rd_w.id) + (state_rd.cells[rd_e.hex_id] as EvoTypes.HexCell).deme_ids.erase(rd_e.id) + rd_w.hex_id = "0,0" + rd_e.hex_id = "1,0" + (state_rd.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(rd_w.id) + (state_rd.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(rd_e.id) + for high_type in ["war", "merge", "raid"]: + state_rd.active_flows.clear() + var low_fl := EvoTypes.HexEdgeFlow.new() + low_fl.from_hex_id = "0,0" + low_fl.to_hex_id = "1,0" + low_fl.flow_type = "dispute" if high_type != "merge" else "exchange" + low_fl.from_deme_id = rd_w.id + low_fl.to_deme_id = rd_e.id + state_rd.active_flows.append(low_fl) + var high_fl := EvoTypes.HexEdgeFlow.new() + high_fl.from_hex_id = "0,0" + high_fl.to_hex_id = "1,0" + high_fl.flow_type = high_type + high_fl.from_deme_id = rd_w.id + high_fl.to_deme_id = rd_e.id + state_rd.active_flows.append(high_fl) + main_rd.world_renderer.set_world_state(state_rd) + var collected_rds := EvoRoadNetworkRenderer.collect_roads(main_rd.world_renderer) + var matched_high := false + for rd_item in collected_rds: + if (rd_item["hex_a"] == "0,0" and rd_item["hex_b"] == "1,0") or (rd_item["hex_a"] == "1,0" and rd_item["hex_b"] == "0,0"): + if str(rd_item["flow_type"]) == high_type: + matched_high = true + break + _assert(matched_high, "EvoRoadNetworkRenderer.collect_roads selects higher-priority '%s' flow over baseline '%s' flow on same hex pair" % [high_type, low_fl.flow_type]) + main_rd.free() + + # 10. EvoHeuristicScorer.build_compact_system_one_state emits BorderCompetition: adjRivals=..., adjKin=... and Phase 4 lookahead prefetch hits signature cache in Phase 2 after season flip + var state_pf := EvoPresets.create_preset_world("preset-rift-savanna", "en", "strat-cooperation", "strat-war") + var pf_w: EvoTypes.Deme = state_pf.demes["deme-rift-west"] + var pf_e: EvoTypes.Deme = state_pf.demes["deme-rift-east"] + (state_pf.cells[pf_w.hex_id] as EvoTypes.HexCell).deme_ids.erase(pf_w.id) + (state_pf.cells[pf_e.hex_id] as EvoTypes.HexCell).deme_ids.erase(pf_e.id) + pf_w.hex_id = "0,0" + pf_e.hex_id = "1,0" + (state_pf.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(pf_w.id) + (state_pf.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(pf_e.id) + var compact_pf := EvoHeuristicScorer.build_compact_system_one_state(pf_w, state_pf) + _assert( + compact_pf.contains("BorderCompetition: adjRivals=1, adjKin=0"), + "build_compact_system_one_state emits 'BorderCompetition: adjRivals=1, adjKin=0'" + ) + EvoTurnEngine.step_phase_1_environment(state_pf, "en") + EvoTurnEngine.step_phase_2_decisions(state_pf, "en") + EvoTurnEngine.step_phase_3_ecology_borders(state_pf, "en") + EvoTurnEngine.step_phase_4_demography_milestones(state_pf, "en") + # Simulate Phase 4 lookahead prefetch seeding the signature for the upcoming flipped season + var cur_season_pf := state_pf.season + state_pf.season = "dry_cold" if cur_season_pf == "wet_warm" else "wet_warm" + EvoDecisionModel.invalidate_state_caches(state_pf) + var lookahead_sig := EvoDecisionModel.compute_deme_evaluation_signature(pf_w, state_pf) + state_pf.season = cur_season_pf + EvoDecisionModel.invalidate_state_caches(state_pf) + EvoOllamaClient.seed_signature_cache(lookahead_sig, { + "probabilities": { + "dec-cooperate-store": 0.92, + "dec-conservative-forage": 0.08 + }, + "dominant_decision_id": "dec-cooperate-store", + "confidence": 0.96 + }) + # Next turn Phase 1 flips season; Phase 2 must hit the lookahead signature cache + EvoTurnEngine.step_phase_1_environment(state_pf, "en") + var next_turn_sig := EvoDecisionModel.compute_deme_evaluation_signature(pf_w, state_pf) + _assert(next_turn_sig == lookahead_sig, "Next turn Phase 2 signature after season flip matches Phase 4 lookahead prefetch signature") + EvoTurnEngine.step_phase_2_decisions(state_pf, "en") + _assert(is_equal_approx(pf_w.last_confidence, 0.96), "Phase 2 evaluation hits Phase 4 lookahead signature cache (confidence == 0.96)") + func _test_social_memory_defense_tribute_and_synoecism() -> void: print("-- Testing Social Memory, Multi-Tier Defense, Vendettas, Tribute, Synoecism & New Sprites --") @@ -1439,16 +1752,18 @@ func _test_social_memory_defense_tribute_and_synoecism() -> void: ) _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 + # 4. City Unification (Synoecism) & LLM Request Reduction Across Adjacent Hexes + # 4a. Inter-cell synoecism (-2,1 and -1,1) 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.hex_id = "-2,1" + s1_b.hex_id = "-1,1" + (state_syn1.cells["-2,1"] as EvoTypes.HexCell).set_resident_deme_id(s1_a.id) + (state_syn1.cells["-1,1"] as EvoTypes.HexCell).set_resident_deme_id(s1_b.id) + _assert_single_deme_per_cell_invariant(state_syn1, "synoecism_syn1_setup") s1_a.population = 125 s1_b.population = 115 s1_a.energy_reserves = 120.0 @@ -1464,7 +1779,7 @@ func _test_social_memory_defense_tribute_and_synoecism() -> void: } 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(state_syn1.demes.size() == 1, "Inter-cell synoecism (-2,1 and -1,1, 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) @@ -1473,8 +1788,9 @@ func _test_social_memory_defense_tribute_and_synoecism() -> void: 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" ) + _assert_single_deme_per_cell_invariant(state_syn1, "synoecism_syn1_after") - # 4b. Adjacent-hex synoecism with active_pacts >= 4 + # 4b. Adjacent-hex synoecism (0,0 and 1,0) 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"] @@ -1482,8 +1798,8 @@ func _test_social_memory_defense_tribute_and_synoecism() -> void: (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) + (state_syn2.cells["0,0"] as EvoTypes.HexCell).set_resident_deme_id(s2_a.id) + (state_syn2.cells["1,0"] as EvoTypes.HexCell).set_resident_deme_id(s2_b.id) s2_a.population = 105 s2_b.population = 85 s2_a.energy_reserves = 110.0 @@ -1516,28 +1832,13 @@ func _test_social_memory_defense_tribute_and_synoecism() -> void: _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") + _assert_single_deme_per_cell_invariant(state_syn2, "synoecism_syn2_after") - # 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) + # 5. Phase 3 Procedural UI Icons & Settlement Morphology Verification + EvoPixelArtGen.ensure_all_assets() + for skey in ["icon_defense", "icon_merge", "icon_tribute", "icon_siege"]: 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) + _assert(tex != null and tex.get_width() >= 16 and tex.get_height() >= 16, "In-memory Phase 3 UI icon 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) diff --git a/tests/test_visual_capture.gd b/tests/test_visual_capture.gd index 6a8b9d4..225cbf5 100644 --- a/tests/test_visual_capture.gd +++ b/tests/test_visual_capture.gd @@ -15,10 +15,10 @@ func _wait_and_save(main: EvoMainController, filename: String) -> void: if main.hud._missing_models_modal != null: main.hud._missing_models_modal.visible = false main.world_renderer.refresh() - for _i in range(6): - await process_frame - RenderingServer.force_draw(false) + main.world_renderer._process(0.016) await process_frame + await process_frame + RenderingServer.force_draw(false) var img: Image = root.get_texture().get_image() var full_path: String = "%s/%s" % [OUTPUT_DIR, filename] var err: Error = img.save_png(full_path) @@ -79,7 +79,7 @@ func _run_captures() -> void: if main.hud._legend_panel.visible: main.hud._on_legend_toggle_pressed() - # Advance Finite Island a few more turns and trigger a Baldwin & Speciation event so Chronicle and Lineage Tree are rich + # Advance Finite Island a few more turns and trigger a Baldwin & Innovation event so Chronicle and Lineage Tree are rich for _g in range(4): main.step_generation() if not main.world_state.demes.is_empty(): @@ -102,8 +102,12 @@ func _run_captures() -> void: var f_deme: EvoTypes.Deme = main.world_state.demes["deme-darwin-founder"] if main.world_state.cells.has(f_deme.hex_id): (main.world_state.cells[f_deme.hex_id] as EvoTypes.HexCell).deme_ids.erase(f_deme.id) + var target_cell: EvoTypes.HexCell = main.world_state.cells["0,-2"] + var prev_occ := target_cell.get_resident_deme_id() + if prev_occ != "" and prev_occ != f_deme.id and main.world_state.demes.has(prev_occ): + main.world_state.demes.erase(prev_occ) f_deme.hex_id = "0,-2" - (main.world_state.cells["0,-2"] as EvoTypes.HexCell).deme_ids.append(f_deme.id) + target_cell.set_resident_deme_id(f_deme.id) f_deme.isolation_turns = 10 EvoMilestonePhase.run_milestone_phase(main.world_state, main.lang) main.hud.open_tree_modal()