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:
parent
9ec70781a9
commit
d8a417e224
2 changed files with 124 additions and 13 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue