From b5d67f77ff3995bd4317a3bf82c7c0aab892f3fe Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 18 Apr 2026 17:10:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(tactical):=20=E2=9C=A8=20add=20unit=20cata?= =?UTF-8?q?log=20support=20for=20production=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-ai/src/tactical/citizen.rs | 1 + .../crates/mc-ai/src/tactical/movement.rs | 2 + .../crates/mc-ai/src/tactical/production.rs | 145 +++++++++++++++++- .../crates/mc-ai/src/tactical/settle.rs | 1 + .../crates/mc-ai/src/tactical/state.rs | 28 ++++ .../mc-ai/tests/tactical_port_regression.rs | 5 + 6 files changed, 178 insertions(+), 4 deletions(-) diff --git a/src/simulator/crates/mc-ai/src/tactical/citizen.rs b/src/simulator/crates/mc-ai/src/tactical/citizen.rs index 8635b0ec..b4baaab3 100644 --- a/src/simulator/crates/mc-ai/src/tactical/citizen.rs +++ b/src/simulator/crates/mc-ai/src/tactical/citizen.rs @@ -352,6 +352,7 @@ mod tests { turn: 10, map, players, + unit_catalog: Vec::new(), } } diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index 6310ae30..3d86c6b6 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -586,6 +586,7 @@ mod tests { turn: 1, map: empty_map(), players, + unit_catalog: Vec::new(), } } @@ -606,6 +607,7 @@ mod tests { turn: 0, map: empty_map(), players: Vec::new(), + unit_catalog: Vec::new(), }; let actions = decide_movement(&s, &weights(), &mut rng()); assert!(actions.is_empty()); diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index e2d1c39a..1c9176aa 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -172,6 +172,7 @@ pub(crate) fn decide_production( enemy_mil_max, threatened, &player.strategic_axes, + &state.unit_catalog, ); out.push(Action::SetProduction { city_id: city.id, @@ -191,12 +192,17 @@ fn pick_for_city( enemy_mil_max: u32, threatened: bool, strategic_axes: &std::collections::BTreeMap, + unit_catalog: &[super::state::TacticalUnitSpec], ) -> String { // Personality-emergent thresholds (p0-37). let dominance_factor_t = super::thresholds::dominance_factor(strategic_axes); let dominance_gold_floor_t = super::thresholds::dominance_gold_floor(strategic_axes); let capital_walls_min_age_turns_t = super::thresholds::capital_walls_min_age_turns(strategic_axes); + // Tier-progression unit selection (p0-39). Highest-tier buildable military + // unit for this player; falls back to `warrior` when the catalog is empty + // (fixtures predating p0-39) or no higher-tier gate is met. + let melee_id = pick_best_melee(&player.researched_techs, unit_catalog).unwrap_or(ids::WARRIOR); let early_mil_floor = if turn <= EARLY_MIL_FLOOR_CUTOFF_TURN { EARLY_MIL_FLOOR } else { @@ -215,7 +221,7 @@ fn pick_for_city( // 1. Threat preemption (GDScript Priority 0-A). if posture == Posture::Threatened { - return ids::WARRIOR.into(); + return melee_id.into(); } // Capital walls interject (GDScript pre-Priority 0): non-threatened, @@ -235,7 +241,7 @@ fn pick_for_city( // 2. Early mil floor (GDScript Priority 0). if own_mil < early_mil_floor { - return ids::WARRIOR.into(); + return melee_id.into(); } // 3. Production bias — forge before full mil quota (Priority 4 @@ -269,12 +275,12 @@ fn pick_for_city( mil_target = enemy_mil_max + 1; } if own_mil < mil_target { - return ids::WARRIOR.into(); + return melee_id.into(); } // 6. Aggression offensive push: keep minting units when dominant. if posture == Posture::Offensive { - return ids::WARRIOR.into(); + return melee_id.into(); } // Priority 5: forge (if we didn't take BuildUp fast path). @@ -308,6 +314,36 @@ fn pick_for_city( ids::WORKER.into() } +/// Highest-tier buildable melee-military unit given `researched_techs`. +/// +/// Filter rules (all AND'd): +/// 1. `spec.unit_type == "military"` — only combat units (not workers/scouts/founders). +/// 2. `spec.tech_required` is `None` OR present in `researched_techs`. +/// 3. Ranged / domain-specialized units (spec.id contains `"archer"`, `"flying"`, +/// `"naval"`) excluded so we don't slot artillery into melee lines. +/// +/// Returns the qualifying unit with highest `tier`. Ties broken by id sort +/// order for determinism. `None` when the catalog is empty (pre-p0-39 +/// back-compat) → caller falls back to `ids::WARRIOR`. +fn pick_best_melee<'a>( + researched_techs: &[String], + catalog: &'a [super::state::TacticalUnitSpec], +) -> Option<&'a str> { + let is_ranged_specialty = |id: &str| -> bool { + id.contains("archer") || id.contains("ranger") || id.contains("flying") + }; + catalog + .iter() + .filter(|u| u.unit_type == "military") + .filter(|u| !is_ranged_specialty(&u.id)) + .filter(|u| match &u.tech_required { + None => true, + Some(tech) => researched_techs.iter().any(|t| t == tech), + }) + .max_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id))) + .map(|u| u.id.as_str()) +} + fn classify_posture( threatened: bool, own_mil: u32, @@ -440,6 +476,7 @@ mod tests { turn, map: empty_map(), players, + unit_catalog: Vec::new(), } } @@ -465,6 +502,106 @@ mod tests { assert_eq!(PRODUCTION_AXIS_BUILDING_BIAS, 8); } + // ── Tier-progression unit selection (p0-39) ───────────────────────── + + fn unit_spec(id: &str, tier: u32, tech: Option<&str>, unit_type: &str) -> super::super::state::TacticalUnitSpec { + super::super::state::TacticalUnitSpec { + id: id.into(), + tier, + tech_required: tech.map(Into::into), + unit_type: unit_type.into(), + } + } + + #[test] + fn pick_best_melee_falls_back_to_none_on_empty_catalog() { + let techs: Vec = vec![]; + assert_eq!(pick_best_melee(&techs, &[]), None); + } + + #[test] + fn pick_best_melee_selects_tier_1_when_no_higher_tech_researched() { + let catalog = [ + unit_spec("warrior", 1, None, "military"), + unit_spec("pikeman", 2, Some("bronze_working"), "military"), + ]; + let techs = vec!["mining".to_string()]; + assert_eq!(pick_best_melee(&techs, &catalog), Some("warrior")); + } + + #[test] + fn pick_best_melee_climbs_tier_when_tech_available() { + let catalog = [ + unit_spec("warrior", 1, None, "military"), + unit_spec("pikeman", 2, Some("bronze_working"), "military"), + unit_spec("cavalry", 3, Some("steelworking"), "military"), + ]; + let techs = vec!["bronze_working".to_string()]; + assert_eq!(pick_best_melee(&techs, &catalog), Some("pikeman")); + let techs2 = vec!["bronze_working".to_string(), "steelworking".to_string()]; + assert_eq!(pick_best_melee(&techs2, &catalog), Some("cavalry")); + } + + #[test] + fn pick_best_melee_excludes_non_military_unit_types() { + let catalog = [ + unit_spec("warrior", 1, None, "military"), + unit_spec("worker", 1, None, "worker"), + unit_spec("founder", 1, None, "founder"), + ]; + assert_eq!(pick_best_melee(&[], &catalog), Some("warrior")); + } + + #[test] + fn pick_best_melee_excludes_ranged_specialists() { + // Archers are tier-2 military but don't belong on a melee line. + let catalog = [ + unit_spec("warrior", 1, None, "military"), + unit_spec("archer", 2, Some("bronze_working"), "military"), + unit_spec("pikeman", 2, Some("bronze_working"), "military"), + ]; + let techs = vec!["bronze_working".to_string()]; + assert_eq!( + pick_best_melee(&techs, &catalog), + Some("pikeman"), + "ranged specialists must be excluded" + ); + } + + #[test] + fn tier_2_unit_selected_when_tech_researched() { + // Regression: player with bronze_working + catalog containing pikeman + // should get pikeman on every military-slot branch (threat preempt, + // early mil floor, steady mil, offensive push). + let catalog = vec![ + unit_spec("warrior", 1, None, "military"), + unit_spec("pikeman", 2, Some("bronze_working"), "military"), + ]; + let mut p = player(0, "ironhold", Vec::new(), vec![city(10, (0, 0), 1, &[], &[], true)]); + p.researched_techs = vec!["bronze_working".into()]; + // Build state with 1 enemy with 5 mil to trigger Posture::Threatened. + let enemy = TacticalPlayerState { + index: 1, + clan_id: "goldvein".into(), + gold: 50, + happiness_pool: 0, + units: (0..5).map(|i| crate::tactical::state::TacticalUnit { + id: 100 + i, kind: "warrior".into(), hex: (9, 9), + hp: 10, hp_max: 10, moves_left: 2, fortified: false, + can_found_city: false, + }).collect(), + cities: Vec::new(), + researched_techs: Vec::new(), + relations: vec![0, 0], + strategic_axes: ::std::collections::BTreeMap::new(), + }; + let mut s = state(0, 10, vec![p, enemy]); + s.unit_catalog = catalog; + let out = decide_production(&s, &weights(), &mut rng()); + assert_eq!(first_item(&out), "pikeman", + "player with bronze_working + pikeman catalog must produce pikeman, not warrior"); + } + // ── Entry point ────────────────────────────────────────────────────── #[test] diff --git a/src/simulator/crates/mc-ai/src/tactical/settle.rs b/src/simulator/crates/mc-ai/src/tactical/settle.rs index 28f6e7a2..92cc828e 100644 --- a/src/simulator/crates/mc-ai/src/tactical/settle.rs +++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs @@ -405,6 +405,7 @@ mod tests { turn: 1, map, players, + unit_catalog: Vec::new(), } } diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index 7ea7191f..36cdfa14 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -34,6 +34,12 @@ pub struct TacticalState { pub map: TacticalMap, /// Per-player slots. Indexed by `TacticalPlayerState::index`. pub players: Vec, + /// Catalog of producible military units with tier + tech gate, populated + /// from `units/*.json` by the GDExtension bridge. Consumed by + /// `tactical::production::pick_best_melee` to select tier-N units as tech + /// unlocks (p0-39). Empty vec falls back to tier-1 `warrior` only. + #[serde(default)] + pub unit_catalog: Vec, } /// Hex map with row-major tile storage. @@ -128,6 +134,26 @@ pub struct TacticalUnit { pub can_found_city: bool, } +/// Specification for a producible military unit — carries enough data for the +/// production layer to select tier-appropriate units as tech unlocks (p0-39). +/// +/// Populated from `public/games/age-of-dwarves/data/units/*.json` by the +/// GDExtension bridge and handed through on every `TacticalState`. Empty vec = +/// back-compat (tier-1 fallback only) for fixtures predating p0-39. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TacticalUnitSpec { + /// Unit id (e.g. `"warrior"`, `"pikeman"`). + pub id: String, + /// Tier on the 1..N content ladder. + pub tier: u32, + /// Tech gate — unit is buildable when the player has researched this id. + /// `None` means always available (tier-1 starting units). + pub tech_required: Option, + /// Unit-type classification mirroring `units/*.json::unit_type`: + /// `"military"` | `"worker"` | `"founder"` | `"scout"` | … + pub unit_type: String, +} + /// A city. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TacticalCity { @@ -273,6 +299,7 @@ mod tests { strategic_axes: ::std::collections::BTreeMap::new(), }, ], + unit_catalog: Vec::new(), } } @@ -299,6 +326,7 @@ mod tests { tiles: Vec::new(), }, players: Vec::new(), + unit_catalog: Vec::new(), }; let json = serde_json::to_string(&empty).expect("serialize"); let back: TacticalState = serde_json::from_str(&json).expect("deserialize"); diff --git a/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs b/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs index e48c36e3..e8a13b37 100644 --- a/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs +++ b/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs @@ -157,6 +157,7 @@ fn two_player_state() -> TacticalState { turn: 42, map: small_map(), players: vec![player0_fixture(), player1_fixture()], + unit_catalog: Vec::new(), } } @@ -186,6 +187,7 @@ fn settler_only_state() -> TacticalState { relations: vec![0], strategic_axes: ::std::collections::BTreeMap::new(), }], + unit_catalog: Vec::new(), } } @@ -231,6 +233,7 @@ fn production_state() -> TacticalState { relations: vec![0], strategic_axes: ::std::collections::BTreeMap::new(), }], + unit_catalog: Vec::new(), } } @@ -309,6 +312,7 @@ fn tactical_state_empty_roundtrip() { turn: 0, map: TacticalMap { width: 0, height: 0, tiles: vec![] }, players: vec![], + unit_catalog: Vec::new(), }; let json = serde_json::to_string(&state).expect("serialize"); let back: TacticalState = serde_json::from_str(&json).expect("deserialize"); @@ -337,6 +341,7 @@ fn tactical_state_with_100_tile_map_roundtrip() { turn: 150, map: TacticalMap { width: 10, height: 10, tiles }, players: vec![player0_fixture(), player1_fixture()], + unit_catalog: Vec::new(), }; let json = serde_json::to_string(&state).expect("serialize"); let back: TacticalState = serde_json::from_str(&json).expect("deserialize");