diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index 8826869d..e216f75d 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -226,6 +226,7 @@ fn pick_for_city( player.race_id.as_deref(), &player.strategic_resources, unit_catalog, + &player.clan_id, ).unwrap_or(ids::WARRIOR); let early_mil_floor = if turn <= EARLY_MIL_FLOOR_CUTOFF_TURN { EARLY_MIL_FLOOR @@ -362,6 +363,7 @@ fn pick_best_melee<'a>( race_id: Option<&str>, strategic_resources: &[String], catalog: &'a [super::state::TacticalUnitSpec], + clan_id: &str, ) -> Option<&'a str> { let is_ranged_specialty = |id: &str| -> bool { id.contains("archer") || id.contains("ranger") || id.contains("flying") @@ -383,10 +385,29 @@ fn pick_best_melee<'a>( None => true, Some(res) => strategic_resources.iter().any(|r| r == res), }) - .max_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id))) + // Score: clan_affinity weight (×100) + tier. Affinity matches dominate + // tier within the same eligibility band; ties broken by id sort order + // for determinism. Generic units (empty clan_affinity) score as + // neutral fallbacks, off-clan units fall to the bottom but remain + // selectable when nothing affinity-matched is buildable. (p1-37) + .max_by_key(|u| (clan_affinity_score(u, clan_id), u.tier, std::cmp::Reverse(u.id.clone()))) .map(|u| u.id.as_str()) } +/// Returns 2 if the unit's clan_affinity contains the player's clan, 1 if the +/// affinity list is empty (generic unit, neutral fallback), 0 otherwise +/// (off-clan — still buildable but ranked below affinity matches and +/// generics). p1-37. +fn clan_affinity_score(unit: &super::state::TacticalUnitSpec, clan_id: &str) -> u32 { + if unit.clan_affinity.is_empty() { + 1 + } else if unit.clan_affinity.iter().any(|c| c == clan_id) { + 2 + } else { + 0 + } +} + fn classify_posture( threatened: bool, own_mil: u32, @@ -560,6 +581,8 @@ mod tests { unit_type: unit_type.into(), requires_resource: None, race_required: None, + clan_affinity: vec![], + archetype: None, } } @@ -574,13 +597,32 @@ mod tests { unit_type: unit_type.into(), requires_resource: resource.map(Into::into), race_required: race.map(Into::into), + clan_affinity: vec![], + archetype: None, + } + } + + /// Test helper: builds a TacticalUnitSpec with explicit clan_affinity for + /// p1-37 tests. + fn unit_spec_clan( + id: &str, tier: u32, tech: Option<&str>, clan_affinity: Vec<&str>, + ) -> super::super::state::TacticalUnitSpec { + super::super::state::TacticalUnitSpec { + id: id.into(), + tier, + tech_required: tech.map(Into::into), + unit_type: "military".into(), + requires_resource: None, + race_required: None, + clan_affinity: clan_affinity.into_iter().map(String::from).collect(), + archetype: None, } } #[test] fn pick_best_melee_falls_back_to_none_on_empty_catalog() { let techs: Vec = vec![]; - assert_eq!(pick_best_melee(&techs, None, &[], &[]), None); + assert_eq!(pick_best_melee(&techs, None, &[], &[], "ironhold"), None); } #[test] @@ -590,7 +632,7 @@ mod tests { unit_spec("pikeman", 2, Some("bronze_working"), "military"), ]; let techs = vec!["mining".to_string()]; - assert_eq!(pick_best_melee(&techs, None, &[], &catalog), Some("warrior")); + assert_eq!(pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("warrior")); } #[test] @@ -601,9 +643,9 @@ mod tests { unit_spec("cavalry", 3, Some("steelworking"), "military"), ]; let techs = vec!["bronze_working".to_string()]; - assert_eq!(pick_best_melee(&techs, None, &[], &catalog), Some("pikeman")); + assert_eq!(pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("pikeman")); let techs2 = vec!["bronze_working".to_string(), "steelworking".to_string()]; - assert_eq!(pick_best_melee(&techs2, None, &[], &catalog), Some("cavalry")); + assert_eq!(pick_best_melee(&techs2, None, &[], &catalog, "ironhold"), Some("cavalry")); } #[test] @@ -613,7 +655,7 @@ mod tests { unit_spec("worker", 1, None, "worker"), unit_spec("founder", 1, None, "founder"), ]; - assert_eq!(pick_best_melee(&[], None, &[], &catalog), Some("warrior")); + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "ironhold"), Some("warrior")); } #[test] @@ -625,7 +667,7 @@ mod tests { ]; let techs = vec!["bronze_working".to_string()]; assert_eq!( - pick_best_melee(&techs, None, &[], &catalog), + pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("pikeman"), "ranged specialists must be excluded" ); @@ -644,18 +686,71 @@ mod tests { ]; let techs = vec!["bronze_working".to_string(), "steelworking".to_string()]; assert_eq!( - pick_best_melee(&techs, None, &[], &catalog), + pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("pikeman"), "cavalry blocked by missing iron_ore; pikeman is highest unblocked" ); // When iron_ore becomes available, cavalry unlocks. let resources = vec!["iron_ore".to_string()]; assert_eq!( - pick_best_melee(&techs, None, &resources, &catalog), + pick_best_melee(&techs, None, &resources, &catalog, "ironhold"), Some("cavalry"), ); } + // ── Clan affinity weighting (p1-37) ───────────────────────────────── + + #[test] + fn clan_affinity_match_outranks_off_clan_at_same_tier() { + // Two T2 units, same eligibility. Berserker → blackhammer; defender → + // ironhold. A blackhammer player picks berserker; an ironhold player + // picks defender. + let catalog = [ + unit_spec_clan("berserker", 2, None, vec!["blackhammer"]), + unit_spec_clan("defender", 2, None, vec!["ironhold"]), + ]; + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "blackhammer"), Some("berserker")); + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "ironhold"), Some("defender")); + } + + #[test] + fn clan_affinity_off_clan_still_buildable_when_no_alternative() { + // Only one military unit available; even if its clan_affinity excludes + // the player's clan, it must still be picked (off-clan score 0 >= None). + let catalog = [ + unit_spec_clan("doomsoul", 10, None, vec!["blackhammer"]), + ]; + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "ironhold"), Some("doomsoul")); + } + + #[test] + fn clan_affinity_generic_unit_neutral_fallback() { + // Generic warrior (empty clan_affinity) loses to clan-affinity match + // for the active clan, but beats off-clan units of the same tier. + let catalog = [ + unit_spec_clan("warrior", 1, None, vec![]), // generic + unit_spec_clan("doomsoul", 1, None, vec!["blackhammer"]), // matches blackhammer + unit_spec_clan("defender", 1, None, vec!["ironhold"]), // matches ironhold + ]; + // Blackhammer prefers their affinity match + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "blackhammer"), Some("doomsoul")); + // Goldvein has no match — falls to generic warrior over off-clan + // (warrior score=1, doomsoul score=0, defender score=0) + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "goldvein"), Some("warrior")); + } + + #[test] + fn clan_affinity_higher_tier_match_beats_lower_tier_match() { + // T6 affinity match should beat T1 affinity match (within same affinity + // tier, sort by .tier). + let catalog = [ + unit_spec_clan("shield_bearer", 1, None, vec!["ironhold"]), + unit_spec_clan("mountain_king", 10, None, vec!["ironhold", "deepforge"]), + ]; + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "ironhold"), Some("mountain_king")); + assert_eq!(pick_best_melee(&[], None, &[], &catalog, "deepforge"), Some("mountain_king")); + } + #[test] fn pick_best_melee_skips_unit_when_race_mismatched() { // Berserker requires dwarf race. A human-race player with dwarf_heritage @@ -665,10 +760,10 @@ mod tests { unit_spec_full("berserker", 2, Some("dwarf_heritage"), "military", None, Some("dwarf")), ]; let techs = vec!["dwarf_heritage".to_string()]; - assert_eq!(pick_best_melee(&techs, Some("human"), &[], &catalog), Some("warrior")); - assert_eq!(pick_best_melee(&techs, Some("dwarf"), &[], &catalog), Some("berserker")); + assert_eq!(pick_best_melee(&techs, Some("human"), &[], &catalog, "ironhold"), Some("warrior")); + assert_eq!(pick_best_melee(&techs, Some("dwarf"), &[], &catalog, "ironhold"), Some("berserker")); // No race id at all (pre-p0-39 fixture) → race-gated units filtered out. - assert_eq!(pick_best_melee(&techs, None, &[], &catalog), Some("warrior")); + assert_eq!(pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("warrior")); } #[test] diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index 1792603c..d3a0586c 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -135,7 +135,12 @@ pub struct TacticalPlayerState { pub struct TacticalUnit { /// Engine-assigned unique id — matches `Action::MoveUnit::unit_id`. pub id: u32, - /// Unit kind id (e.g. `"warrior"`, `"settler"`, `"scout"`). + /// Unit kind id (e.g. `"warrior"`, `"founder"`, `"scout"`, or race-prefixed + /// like `"dwarf_warrior"`). Both generic and race-prefixed kinds coexist; + /// see `data/units/.json` (generic) and `resources/units/_.json` + /// (race-specific). For founder detection prefer `is_founder()` over a + /// kind-string match — clan-themed founders like `"dwarf_tribe"` carry the + /// `can_found_city` flag and would be missed by string match. pub kind: String, /// Current axial `(col, row)` position. pub hex: (i32, i32), @@ -199,6 +204,17 @@ pub struct TacticalUnitSpec { /// means no race restriction. #[serde(default)] pub race_required: Option, + /// Clan IDs that prefer this unit (e.g. `["ironhold", "deepforge"]` for + /// `mountain_king`). Drives clan personality differentiation in the + /// production picker (p1-37). Empty vec = neutral / shared by all clans. + #[serde(default)] + pub clan_affinity: Vec, + /// Archetype label mirroring `units/*.json::archetype`: + /// `"light_melee"` | `"heavy_melee"` | `"anti_cavalry"` | `"ranged"` | + /// `"siege"` | `"cavalry_walker"` | `"wild"` | `"civilian"`. `None` for + /// fixtures predating p1-34. + #[serde(default)] + pub archetype: Option, } /// A city.