diff --git a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd index dfbce0aa..2d28ec75 100644 --- a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd +++ b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd @@ -31,6 +31,25 @@ const MILITARY_COMBAT_TYPES: Array[String] = [ "melee", "ranged", "cavalry", "siege", ] const INF_DISTANCE: int = 1 << 30 +## Dominance push thresholds — when all three conditions hold simultaneously +## the AI commits to a capital assault rather than holding at parity. +## DOMINANCE_FACTOR: own_mil must be >= this multiple of enemy_mil to commit. +const DOMINANCE_FACTOR: float = 2.0 +## Gold floor required before the AI spends on a rush-buy assault unit. +## Prevents committing on a turn when the treasury cannot afford the purchase. +const DOMINANCE_GOLD_FLOOR: int = 200 +## Hex radius within which the AI will rush-buy an assault unit to reinforce +## the push force when it is thin (fewer than DOMINANCE_PUSH_FLOOR units +## within RUSH_BUY_PROXIMITY_HEX of the enemy capital). +const RUSH_BUY_PROXIMITY_HEX: int = 8 +## Minimum push-force size before a rush-buy is triggered near the capital. +## Rush-buy fires when our units within RUSH_BUY_PROXIMITY_HEX < this value. +const DOMINANCE_PUSH_FLOOR: int = 2 +## HP fraction below which retreat is still blocked when the unit is adjacent +## to the enemy capital — prevents bleeding turns healing beside the objective. +## Distinct from RETREAT_HP_FRACTION (general retreat threshold) so each can +## be tuned independently. +const CAPITAL_SIEGE_NO_RETREAT_HP: float = 0.0 ## Generate this turn's actions for `player`. Returns an Array of action @@ -69,6 +88,40 @@ static func process_player(player: RefCounted) -> Array: _rush_buy_defenders(player, {"spawn_city": null, "threatens_city": true, "count": 3, "total_count": 3}) + # Dominance-window rush-buy: when we have 2× enemy military AND sufficient + # gold AND an enemy capital exists, reinforce the push force if it is thin. + # Strategic gate is respected via _pick_buildable_military_unit_id. + var enemy_mil_total: int = 0 + for eu: Variant in enemy_units: + if int(eu.get("attack")) > 0 or int(eu.get("ranged_attack")) > 0: + enemy_mil_total += 1 + var in_dominance_window: bool = ( + mil_now >= int(DOMINANCE_FACTOR * maxi(1, enemy_mil_total)) + and player.gold >= DOMINANCE_GOLD_FLOOR + and not enemy_city_positions.is_empty() + ) + if in_dominance_window: + var nearest_cap: Vector2i = _nearest_enemy_capital(player) + if nearest_cap != Vector2i(-1, -1): + var push_count: int = _count_units_within( + player, nearest_cap, RUSH_BUY_PROXIMITY_HEX + ) + if push_count < DOMINANCE_PUSH_FLOOR and not player.cities.is_empty(): + var spawn_city: RefCounted = player.cities[0] as RefCounted + var assault_id: String = _pick_buildable_military_unit_id( + spawn_city, player + ) + if not assault_id.is_empty() and player.gold >= 50: + var nu: RefCounted = UnitScript.new( + assault_id, player.index, spawn_city.position + ) + nu.id = "dom_%d_%d" % [GameState.turn_number, push_count] + nu.display_name = assault_id.capitalize() + player.units.append(nu) + GameState.get_primary_layer().get("units", []).append(nu) + player.gold -= 50 + EventBus.unit_created.emit(nu, player.index) + # Units: founders first (expansion), then military. for idx: int in player.units.size(): var unit: Variant = player.units[idx] @@ -397,10 +450,15 @@ static func _decide_military_action( # Retreat if wounded — but not while committed to a capture push (enemy # city within 4). Letting p1 retreat from a stalled siege is why 0 captures # across 3 seeds despite 10x kill ratio in the field. + # CAPITAL_SIEGE_NO_RETREAT_HP=0.0 fully suppresses retreat when adjacent + # to objective (city_dist<=1) — unit fights to zero rather than healing. var city_dist: int = INF_DISTANCE if not enemy_city_positions.is_empty(): city_dist = _min_distance(unit.position, enemy_city_positions) - if hp_frac <= RETREAT_HP_FRACTION and nearest_enemy != null and city_dist > 4: + var retreat_hp_threshold: float = ( + CAPITAL_SIEGE_NO_RETREAT_HP if city_dist <= 1 else RETREAT_HP_FRACTION + ) + if hp_frac <= retreat_hp_threshold and nearest_enemy != null and city_dist > 4: return _move_action( idx, unit.position, diff --git a/src/game/engine/tests/unit/test_tile_tooltip.gd b/src/game/engine/tests/unit/test_tile_tooltip.gd index 331eeedd..81ae9280 100644 --- a/src/game/engine/tests/unit/test_tile_tooltip.gd +++ b/src/game/engine/tests/unit/test_tile_tooltip.gd @@ -1,10 +1,12 @@ extends GutTest -## Tests for tile_info_panel collectibles display logic. -## Uses the static build_collectibles_text helper — no scene tree required. +## Tests for tile_info_panel: collectibles display logic + hover show/hide state. const TileInfoPanelScript: GDScript = preload( "res://engine/scenes/world_map/tile_info_panel.gd" ) +const WorldMapHoverScript: GDScript = preload( + "res://engine/scenes/world_map/world_map_hover.gd" +) func before_all() -> void: @@ -64,3 +66,43 @@ func test_dataloader_get_biome_collectibles_returns_array() -> void: func test_unknown_biome_returns_empty_array() -> void: var entries: Array = DataLoader.get_biome_collectibles("void_realm_does_not_exist") assert_eq(entries.size(), 0, "unknown biome must return empty array") + + +# --------------------------------------------------------------------------- +# Hover show/hide state tests — panel instantiated as a child for _ready(). +# --------------------------------------------------------------------------- + + +func test_panel_starts_hidden() -> void: + var panel: PanelContainer = TileInfoPanelScript.new() + add_child(panel) + assert_false(panel.visible, "tile_info_panel must start hidden") + panel.queue_free() + + +func test_hide_panel_resets_current_axial() -> void: + var panel: PanelContainer = TileInfoPanelScript.new() + add_child(panel) + panel._current_axial = Vector2i(3, 4) + panel.hide_panel() + assert_eq( + panel._current_axial, + Vector2i(-9999, -9999), + "hide_panel must reset _current_axial sentinel" + ) + assert_false(panel.visible, "panel must be hidden after hide_panel()") + panel.queue_free() + + +func test_show_tile_same_axial_is_noop() -> void: + var panel: PanelContainer = TileInfoPanelScript.new() + add_child(panel) + panel._current_axial = Vector2i(2, 5) + panel.show_tile({}, Vector2i(2, 5)) + assert_false(panel.visible, "show_tile with duplicate axial must not make panel visible") + panel.queue_free() + + +func test_hover_interval_constant_is_20hz() -> void: + var interval: float = WorldMapHoverScript.HOVER_INTERVAL_SEC + assert_almost_eq(interval, 0.05, 0.001, "HOVER_INTERVAL_SEC must be 0.05 s (20 Hz)") diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index f693240b..d18fd136 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -1748,6 +1748,8 @@ impl GdGameState { capital_position: Some((city_col, city_row)), culture_total: 0, arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), }); pi as i64 } @@ -1855,6 +1857,8 @@ impl GdGameState { capital_position: None, culture_total: 0, arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), }); pi as i64 } diff --git a/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs b/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs index f4fef0db..9151152e 100644 --- a/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs +++ b/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs @@ -114,6 +114,8 @@ fn dense_bench_state(seed: u64, map_size: i32) -> GameState { capital_position: None, culture_total: 0, arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), }], grid: Some(grid), } diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index eb68b333..99744d34 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -4,8 +4,9 @@ use mc_ai::evaluator::ScoringWeights; use mc_city::CityState; use mc_core::grid::GridState; +use mc_trade::relation::RelationState; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; /// Top-level headless game state. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -56,6 +57,16 @@ pub struct PlayerState { pub culture_total: i64, /// One-time flag: has the arcane-lore population cost already been paid? pub arcane_lore_pop_deducted: bool, + /// Luxury IDs received via active trade agreements this turn. + /// Merged into `owned_luxuries` before happiness calculation. + /// Cleared and rebuilt each turn by `process_trade_phase`. + #[serde(default)] + pub traded_luxuries: BTreeSet, + /// Diplomatic relation states keyed by canonical pair `(min_idx, max_idx)`. + /// Shared across all players — only player_index 0 carries the authoritative + /// copy; `process_trade_phase` syncs it from the ledger. + #[serde(default)] + pub relations: BTreeMap<(u8, u8), RelationState>, } /// Ambient fauna-presence pressure accumulated per city. diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index f6c07d21..dc7c5315 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -28,8 +28,9 @@ use crate::spatial_index::LairIndex; use mc_city::CityState; use mc_combat::{CombatBonuses, CombatParams, CombatResolver, CombatType, UnitStats}; use mc_combat::CombatOutcome; +use mc_trade::{advance_relations, evaluate_trades, PlayerTradeInput}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; // ── PvP / Siege Constants ────────────────────────────────────────────────── @@ -179,6 +180,11 @@ impl TurnProcessor { state.turn += 1; let mut result = TurnResult::default(); + // Phase 0: trade — evaluate luxury swaps and advance relation states. + // Must run before economy/happiness so traded_luxuries are available + // when GDScript calls happiness.gd this turn. + self.process_trade_phase(state); + // Phase 1-4: per-player economy, production, founding, unit spawn, // culture accumulation, science/tech progression. let n_players = state.players.len(); @@ -261,6 +267,59 @@ impl TurnProcessor { result } + // ── Phase 0: Trade ──────────────────────────────────────────────────── + + /// Evaluate luxury trades and advance relation states for all player pairs. + /// Populates each player's `traded_luxuries` so GDScript happiness.gd can + /// include them when calling `calculate_happiness`. + fn process_trade_phase(&self, state: &mut GameState) { + // Collect per-player inputs. tile_luxuries from strategic_axes proxy + // (real game populates this from owned tile collectibles via GDScript). + let inputs: Vec = state + .players + .iter() + .map(|p| { + let willingness = *p.strategic_axes.get("trade_willingness").unwrap_or(&5); + PlayerTradeInput { + player_index: p.player_index, + tile_luxuries: p.traded_luxuries.iter().cloned().collect(), // bench uses stub + trade_willingness: willingness, + } + }) + .collect(); + + // Pull relations from player 0 (canonical store). + let mut relations = state + .players + .first() + .map(|p| p.relations.clone()) + .unwrap_or_default(); + + // Collect combat pairs from the last turn's PvP log (none in bench stub). + let combat_pairs: BTreeSet<(u8, u8)> = BTreeSet::new(); + + let all_pairs: Vec<(u8, u8)> = { + let n = state.players.len() as u8; + let mut pairs = Vec::new(); + for a in 0..n { + for b in (a + 1)..n { + pairs.push((a, b)); + } + } + pairs + }; + + // Advance relation states. + let ledger = evaluate_trades(&inputs, &relations, state.turn); + advance_relations(&mut relations, &ledger, &combat_pairs, &all_pairs); + + // Write traded_luxuries per player from ledger. + for player in &mut state.players { + player.traded_luxuries = ledger.incoming_luxuries(player.player_index); + player.relations = relations.clone(); + } + } + // ── Phase 1: Economy ─────────────────────────────────────────────────── fn process_economy(&self, state: &mut GameState, pi: usize) {