feat(ai): expand unit gate filters to race and resource checks

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 18:01:27 -07:00
parent 1dc3c3f215
commit f01d045003
7 changed files with 204 additions and 22 deletions

View file

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

View file

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

View file

@ -577,6 +577,8 @@ mod tests {
researched_techs: Vec::new(),
relations,
strategic_axes: ::std::collections::BTreeMap::new(),
race_id: None,
strategic_resources: Vec::new(),
}
}

View file

@ -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<String> = 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]

View file

@ -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();

View file

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

View file

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