feat(ai): ✨ expand unit gate filters to race and resource checks
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1dc3c3f215
commit
f01d045003
7 changed files with 204 additions and 22 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -577,6 +577,8 @@ mod tests {
|
|||
researched_techs: Vec::new(),
|
||||
relations,
|
||||
strategic_axes: ::std::collections::BTreeMap::new(),
|
||||
race_id: None,
|
||||
strategic_resources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue