From f01d045003c047fe29cf36b3764ad7b2ed06e427 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 18 Apr 2026 18:01:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E2=9C=A8=20expand=20unit=20gate=20?= =?UTF-8?q?filters=20to=20race=20and=20resource=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/src/modules/ai/ai_turn_bridge.gd | 72 ++++++++-- .../crates/mc-ai/src/tactical/citizen.rs | 2 + .../crates/mc-ai/src/tactical/movement.rs | 2 + .../crates/mc-ai/src/tactical/production.rs | 134 ++++++++++++++++-- .../crates/mc-ai/src/tactical/settle.rs | 4 + .../crates/mc-ai/src/tactical/state.rs | 4 + .../mc-ai/tests/tactical_port_regression.rs | 8 ++ 7 files changed, 204 insertions(+), 22 deletions(-) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd index 061d215c..26c0b8e3 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -226,9 +226,10 @@ static func _build_tactical_state(focal: RefCounted) -> Dictionary: static func _build_unit_catalog() -> Array: # Emit the unit catalog for `tactical::production::pick_best_melee` (p0-39). - # Populated from DataLoader's unit pack; tier-2+ units carry - # `tech_required` which the Rust helper filters against each player's - # `researched_techs`. All unit kinds included — Rust filters by + # Populated from DataLoader's unit pack; tier-2+ units carry `tech_required`, + # `requires_resource`, and `race_required` gates the Rust helper filters + # against each player's `researched_techs`, `strategic_resources`, and + # `race_id`. All unit kinds included — Rust filters by # `unit_type == "military"` at selection time. var out: Array = [] var data: Dictionary = DataLoader.get_data("units") @@ -238,19 +239,26 @@ static func _build_unit_catalog() -> Array: var entry: Dictionary = data.get(uid, {}) if entry == null or entry.is_empty(): continue - var tier_raw: int = int(entry.get("tier", 1)) var tech_raw: String = String(entry.get("tech_required", "")) - var tech_dict_val: Dictionary = {} - if tech_raw.is_empty(): - tech_dict_val = {"tech_required": null} - else: - tech_dict_val = {"tech_required": tech_raw} + var resource_raw: String = String(entry.get("requires_resource", "")) + var race_raw: String = String(entry.get("race_required", "")) + var gate_fields: Dictionary = { + "tech_required": null, + "requires_resource": null, + "race_required": null, + } + if not tech_raw.is_empty(): + gate_fields["tech_required"] = tech_raw + if not resource_raw.is_empty(): + gate_fields["requires_resource"] = resource_raw + if not race_raw.is_empty(): + gate_fields["race_required"] = race_raw var item: Dictionary = { "id": String(entry.get("id", uid)), - "tier": tier_raw, + "tier": int(entry.get("tier", 1)), "unit_type": String(entry.get("unit_type", "military")), } - item.merge(tech_dict_val) + item.merge(gate_fields) out.append(item) return out @@ -343,6 +351,13 @@ static func _player_to_dict(p: RefCounted) -> Dictionary: if "strategic_axes" in p and not p.strategic_axes.is_empty() else _load_clan_axes(String(p.clan_id) if "clan_id" in p else "") ) + # Race id (for race-gated unit selection, p0-39). + var race_id: String = (String(p.race_id) if "race_id" in p else "") + # Strategic resources the player currently controls — collected by + # scanning owned tiles' `resource_id` for entries tagged as strategic in + # `resources.json`. Consumed by `tactical::production::pick_best_melee` + # to filter units like cavalry (requires iron_ore). + var strategic_resources: Array = _collect_strategic_resources(p) return { "index": slot, "clan_id": (String(p.clan_id) if "clan_id" in p else ""), @@ -351,9 +366,44 @@ static func _player_to_dict(p: RefCounted) -> Dictionary: "units": units, "cities": cities, "researched_techs": techs, "relations": relations, "strategic_axes": axes, + "race_id": (race_id if not race_id.is_empty() else null), + "strategic_resources": strategic_resources, } +static func _collect_strategic_resources(p: RefCounted) -> Array: + # Scan the player's owned tiles (via cities' worked + fat-cross tiles) and + # collect unique strategic resource ids. Lightweight; runs once per AI + # player per turn at JSON build time. + var seen: Dictionary = {} + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return [] + for city: RefCounted in p.cities: + if city == null: + continue + # Iterate the city's owned-tile set. Cities expose `owned_tiles` as + # Array[Vector2i] in Game 1 scope. + var owned: Array = [] + if "owned_tiles" in city and city.owned_tiles != null: + owned = Array(city.owned_tiles) + for coord: Vector2i in owned: + var tile: Resource = game_map.get_tile(coord) + if tile == null: + continue + var rid: String = String(tile.resource_id) + if rid.is_empty(): + continue + # Treat every resource on an owned tile as available. The + # engine-side strategic-gate check still enforces the real rule + # at unit-production time; this list is an AI-hint, not the gate. + seen[rid] = true + var out: Array = [] + for rid: String in seen.keys(): + out.append(rid) + return out + + static func _load_clan_axes(clan_id: String) -> Dictionary: if clan_id.is_empty(): return {} diff --git a/src/simulator/crates/mc-ai/src/tactical/citizen.rs b/src/simulator/crates/mc-ai/src/tactical/citizen.rs index b4baaab3..4948c9a9 100644 --- a/src/simulator/crates/mc-ai/src/tactical/citizen.rs +++ b/src/simulator/crates/mc-ai/src/tactical/citizen.rs @@ -343,6 +343,8 @@ mod tests { researched_techs: Vec::new(), relations: vec![0, 0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: 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 3d86c6b6..c19e8f3e 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -577,6 +577,8 @@ mod tests { researched_techs: Vec::new(), relations, strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), } } diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index 54facf08..aed47976 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -319,19 +319,29 @@ fn pick_for_city( ids::WORKER.into() } -/// Highest-tier buildable melee-military unit given `researched_techs`. +/// Highest-tier buildable melee-military unit given player tech, race, and +/// strategic resources. /// /// 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. +/// 2. Ranged / aerial specialists (`"archer"`, `"ranger"`, `"flying"` in id) excluded. +/// 3. `spec.tech_required` is `None` OR present in `researched_techs`. +/// 4. `spec.race_required` is `None` OR matches `race_id`. +/// 5. `spec.requires_resource` is `None` OR present in `strategic_resources`. +/// +/// Rule 4 + 5 prevent the AI from queueing units the engine's strategic-gate +/// check will reject (e.g. cavalry → iron_ore). Without them the AI cycles +/// the highest-tier unit every turn and every turn the engine rejects it, +/// so no tier-2+ unit ever gets built (empirically observed in +/// `.local/iter/apricot-20260418_174322/` — see p0-39). /// /// 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], + race_id: Option<&str>, + strategic_resources: &[String], catalog: &'a [super::state::TacticalUnitSpec], ) -> Option<&'a str> { let is_ranged_specialty = |id: &str| -> bool { @@ -345,6 +355,15 @@ fn pick_best_melee<'a>( None => true, Some(tech) => researched_techs.iter().any(|t| t == tech), }) + .filter(|u| match (&u.race_required, race_id) { + (None, _) => true, + (Some(_), None) => false, + (Some(required), Some(owned)) => required == owned, + }) + .filter(|u| match &u.requires_resource { + 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))) .map(|u| u.id.as_str()) } @@ -472,6 +491,8 @@ mod tests { researched_techs: Vec::new(), relations: vec![0, 0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), } } @@ -515,13 +536,29 @@ mod tests { tier, tech_required: tech.map(Into::into), unit_type: unit_type.into(), + requires_resource: None, + race_required: None, + } + } + + fn unit_spec_full( + id: &str, tier: u32, tech: Option<&str>, unit_type: &str, + resource: Option<&str>, race: Option<&str>, + ) -> super::super::state::TacticalUnitSpec { + super::super::state::TacticalUnitSpec { + id: id.into(), + tier, + tech_required: tech.map(Into::into), + unit_type: unit_type.into(), + requires_resource: resource.map(Into::into), + race_required: race.map(Into::into), } } #[test] fn pick_best_melee_falls_back_to_none_on_empty_catalog() { let techs: Vec = vec![]; - assert_eq!(pick_best_melee(&techs, &[]), None); + assert_eq!(pick_best_melee(&techs, None, &[], &[]), None); } #[test] @@ -531,7 +568,7 @@ mod tests { unit_spec("pikeman", 2, Some("bronze_working"), "military"), ]; let techs = vec!["mining".to_string()]; - assert_eq!(pick_best_melee(&techs, &catalog), Some("warrior")); + assert_eq!(pick_best_melee(&techs, None, &[], &catalog), Some("warrior")); } #[test] @@ -542,9 +579,9 @@ mod tests { unit_spec("cavalry", 3, Some("steelworking"), "military"), ]; let techs = vec!["bronze_working".to_string()]; - assert_eq!(pick_best_melee(&techs, &catalog), Some("pikeman")); + assert_eq!(pick_best_melee(&techs, None, &[], &catalog), Some("pikeman")); let techs2 = vec!["bronze_working".to_string(), "steelworking".to_string()]; - assert_eq!(pick_best_melee(&techs2, &catalog), Some("cavalry")); + assert_eq!(pick_best_melee(&techs2, None, &[], &catalog), Some("cavalry")); } #[test] @@ -554,12 +591,11 @@ mod tests { unit_spec("worker", 1, None, "worker"), unit_spec("founder", 1, None, "founder"), ]; - assert_eq!(pick_best_melee(&[], &catalog), Some("warrior")); + assert_eq!(pick_best_melee(&[], None, &[], &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"), @@ -567,12 +603,52 @@ mod tests { ]; let techs = vec!["bronze_working".to_string()]; assert_eq!( - pick_best_melee(&techs, &catalog), + pick_best_melee(&techs, None, &[], &catalog), Some("pikeman"), "ranged specialists must be excluded" ); } + #[test] + fn pick_best_melee_skips_unit_when_strategic_resource_missing() { + // Real-world case (p0-39 v1 regression): cavalry needs iron_ore; + // player has steelworking but no iron_ore → must fall back to + // pikeman (no resource requirement) instead of queueing cavalry + // every turn and hitting the engine's strategic_gate_rejected path. + let catalog = [ + unit_spec_full("warrior", 1, None, "military", None, None), + unit_spec_full("pikeman", 2, Some("bronze_working"), "military", None, None), + unit_spec_full("cavalry", 3, Some("steelworking"), "military", Some("iron_ore"), None), + ]; + let techs = vec!["bronze_working".to_string(), "steelworking".to_string()]; + assert_eq!( + pick_best_melee(&techs, None, &[], &catalog), + 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), + Some("cavalry"), + ); + } + + #[test] + fn pick_best_melee_skips_unit_when_race_mismatched() { + // Berserker requires dwarf race. A human-race player with dwarf_heritage + // researched (via diplomacy/trade/etc.) must NOT get berserker queued. + let catalog = [ + unit_spec_full("warrior", 1, None, "military", None, None), + 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")); + // No race id at all (pre-p0-39 fixture) → race-gated units filtered out. + assert_eq!(pick_best_melee(&techs, None, &[], &catalog), Some("warrior")); + } + #[test] fn tier_2_unit_selected_when_tech_researched() { // Regression: player with bronze_working + catalog containing pikeman @@ -599,6 +675,8 @@ mod tests { researched_techs: Vec::new(), relations: vec![0, 0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), }; let mut s = state(0, 10, vec![p, enemy]); s.unit_catalog = catalog; @@ -607,6 +685,40 @@ mod tests { "player with bronze_working + pikeman catalog must produce pikeman, not warrior"); } + #[test] + fn cavalry_not_queued_without_iron_ore() { + // Regression for p0-39 v2: post-v1 batch showed cavalry being queued + // every turn and rejected by strategic_gate because iron_ore missing. + // Now pick_best_melee must filter cavalry out and fall back to pikeman. + let catalog = vec![ + unit_spec_full("warrior", 1, None, "military", None, None), + unit_spec_full("pikeman", 2, Some("bronze_working"), "military", None, None), + unit_spec_full("cavalry", 3, Some("steelworking"), "military", Some("iron_ore"), None), + ]; + let mut p = player(0, "ironhold", Vec::new(), vec![city(10, (0, 0), 1, &[], &[], true)]); + p.researched_techs = vec!["bronze_working".into(), "steelworking".into()]; + p.strategic_resources = Vec::new(); // no iron_ore + 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(), + race_id: None, + strategic_resources: Vec::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", + "cavalry blocked on iron_ore → pikeman is highest unblocked tier"); + } + // ── 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 92cc828e..f9f045e6 100644 --- a/src/simulator/crates/mc-ai/src/tactical/settle.rs +++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs @@ -396,6 +396,8 @@ mod tests { researched_techs: Vec::new(), relations: vec![0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), } } @@ -604,6 +606,8 @@ mod tests { researched_techs: Vec::new(), relations: vec![-1, 0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), }; let state = base_state(map, vec![lone_player(vec![settler], Vec::new()), enemy_player]); let weights = ScoringWeights::default(); diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index ba818466..a79f2b0e 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -310,6 +310,8 @@ mod tests { researched_techs: vec!["bronze_working".into()], relations: vec![0, -1], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), }, TacticalPlayerState { index: 1, @@ -321,6 +323,8 @@ mod tests { researched_techs: Vec::new(), relations: vec![-1, 0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), }, ], unit_catalog: Vec::new(), 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 e8a13b37..24cd132c 100644 --- a/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs +++ b/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs @@ -83,6 +83,8 @@ fn player0_fixture() -> TacticalPlayerState { researched_techs: vec!["mining".into(), "masonry".into()], relations: vec![0, -1], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), } } @@ -147,6 +149,8 @@ fn player1_fixture() -> TacticalPlayerState { researched_techs: vec!["mining".into()], relations: vec![-1, 0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), } } @@ -186,6 +190,8 @@ fn settler_only_state() -> TacticalState { researched_techs: vec![], relations: vec![0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), }], unit_catalog: Vec::new(), } @@ -232,6 +238,8 @@ fn production_state() -> TacticalState { ], relations: vec![0], strategic_axes: ::std::collections::BTreeMap::new(), + race_id: None, + strategic_resources: Vec::new(), }], unit_catalog: Vec::new(), }