feat(tactical): Introduce Production and State modules for tactical AI decision-making logic in the simulator

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-27 06:05:57 -07:00
parent 9ec70781a9
commit d8a417e224
2 changed files with 124 additions and 13 deletions

View file

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

View file

@ -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/<id>.json` (generic) and `resources/units/<race>_<id>.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<String>,
/// 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<String>,
/// 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<String>,
}
/// A city.