feat(@projects/@magic-civilization): add strategic resource validation logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-16 21:48:10 -07:00
parent c9084a1099
commit 601226d2a2
6 changed files with 181 additions and 5 deletions

View file

@ -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,

View file

@ -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)")

View file

@ -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
}

View file

@ -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),
}

View file

@ -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<String>,
/// 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.

View file

@ -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<PlayerTradeInput> = 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) {