feat(tactical): add unit catalog support for production logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 17:10:58 -07:00
parent 7c6d922719
commit b5d67f77ff
6 changed files with 178 additions and 4 deletions

View file

@ -352,6 +352,7 @@ mod tests {
turn: 10,
map,
players,
unit_catalog: Vec::new(),
}
}

View file

@ -586,6 +586,7 @@ mod tests {
turn: 1,
map: empty_map(),
players,
unit_catalog: Vec::new(),
}
}
@ -606,6 +607,7 @@ mod tests {
turn: 0,
map: empty_map(),
players: Vec::new(),
unit_catalog: Vec::new(),
};
let actions = decide_movement(&s, &weights(), &mut rng());
assert!(actions.is_empty());

View file

@ -172,6 +172,7 @@ pub(crate) fn decide_production(
enemy_mil_max,
threatened,
&player.strategic_axes,
&state.unit_catalog,
);
out.push(Action::SetProduction {
city_id: city.id,
@ -191,12 +192,17 @@ fn pick_for_city(
enemy_mil_max: u32,
threatened: bool,
strategic_axes: &std::collections::BTreeMap<String, i32>,
unit_catalog: &[super::state::TacticalUnitSpec],
) -> String {
// Personality-emergent thresholds (p0-37).
let dominance_factor_t = super::thresholds::dominance_factor(strategic_axes);
let dominance_gold_floor_t = super::thresholds::dominance_gold_floor(strategic_axes);
let capital_walls_min_age_turns_t =
super::thresholds::capital_walls_min_age_turns(strategic_axes);
// Tier-progression unit selection (p0-39). Highest-tier buildable military
// unit for this player; falls back to `warrior` when the catalog is empty
// (fixtures predating p0-39) or no higher-tier gate is met.
let melee_id = pick_best_melee(&player.researched_techs, unit_catalog).unwrap_or(ids::WARRIOR);
let early_mil_floor = if turn <= EARLY_MIL_FLOOR_CUTOFF_TURN {
EARLY_MIL_FLOOR
} else {
@ -215,7 +221,7 @@ fn pick_for_city(
// 1. Threat preemption (GDScript Priority 0-A).
if posture == Posture::Threatened {
return ids::WARRIOR.into();
return melee_id.into();
}
// Capital walls interject (GDScript pre-Priority 0): non-threatened,
@ -235,7 +241,7 @@ fn pick_for_city(
// 2. Early mil floor (GDScript Priority 0).
if own_mil < early_mil_floor {
return ids::WARRIOR.into();
return melee_id.into();
}
// 3. Production bias — forge before full mil quota (Priority 4
@ -269,12 +275,12 @@ fn pick_for_city(
mil_target = enemy_mil_max + 1;
}
if own_mil < mil_target {
return ids::WARRIOR.into();
return melee_id.into();
}
// 6. Aggression offensive push: keep minting units when dominant.
if posture == Posture::Offensive {
return ids::WARRIOR.into();
return melee_id.into();
}
// Priority 5: forge (if we didn't take BuildUp fast path).
@ -308,6 +314,36 @@ fn pick_for_city(
ids::WORKER.into()
}
/// Highest-tier buildable melee-military unit given `researched_techs`.
///
/// 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.
///
/// 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],
catalog: &'a [super::state::TacticalUnitSpec],
) -> Option<&'a str> {
let is_ranged_specialty = |id: &str| -> bool {
id.contains("archer") || id.contains("ranger") || id.contains("flying")
};
catalog
.iter()
.filter(|u| u.unit_type == "military")
.filter(|u| !is_ranged_specialty(&u.id))
.filter(|u| match &u.tech_required {
None => true,
Some(tech) => researched_techs.iter().any(|t| t == tech),
})
.max_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id)))
.map(|u| u.id.as_str())
}
fn classify_posture(
threatened: bool,
own_mil: u32,
@ -440,6 +476,7 @@ mod tests {
turn,
map: empty_map(),
players,
unit_catalog: Vec::new(),
}
}
@ -465,6 +502,106 @@ mod tests {
assert_eq!(PRODUCTION_AXIS_BUILDING_BIAS, 8);
}
// ── Tier-progression unit selection (p0-39) ─────────────────────────
fn unit_spec(id: &str, tier: u32, tech: Option<&str>, unit_type: &str) -> super::super::state::TacticalUnitSpec {
super::super::state::TacticalUnitSpec {
id: id.into(),
tier,
tech_required: tech.map(Into::into),
unit_type: unit_type.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);
}
#[test]
fn pick_best_melee_selects_tier_1_when_no_higher_tech_researched() {
let catalog = [
unit_spec("warrior", 1, None, "military"),
unit_spec("pikeman", 2, Some("bronze_working"), "military"),
];
let techs = vec!["mining".to_string()];
assert_eq!(pick_best_melee(&techs, &catalog), Some("warrior"));
}
#[test]
fn pick_best_melee_climbs_tier_when_tech_available() {
let catalog = [
unit_spec("warrior", 1, None, "military"),
unit_spec("pikeman", 2, Some("bronze_working"), "military"),
unit_spec("cavalry", 3, Some("steelworking"), "military"),
];
let techs = vec!["bronze_working".to_string()];
assert_eq!(pick_best_melee(&techs, &catalog), Some("pikeman"));
let techs2 = vec!["bronze_working".to_string(), "steelworking".to_string()];
assert_eq!(pick_best_melee(&techs2, &catalog), Some("cavalry"));
}
#[test]
fn pick_best_melee_excludes_non_military_unit_types() {
let catalog = [
unit_spec("warrior", 1, None, "military"),
unit_spec("worker", 1, None, "worker"),
unit_spec("founder", 1, None, "founder"),
];
assert_eq!(pick_best_melee(&[], &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"),
unit_spec("pikeman", 2, Some("bronze_working"), "military"),
];
let techs = vec!["bronze_working".to_string()];
assert_eq!(
pick_best_melee(&techs, &catalog),
Some("pikeman"),
"ranged specialists must be excluded"
);
}
#[test]
fn tier_2_unit_selected_when_tech_researched() {
// Regression: player with bronze_working + catalog containing pikeman
// should get pikeman on every military-slot branch (threat preempt,
// early mil floor, steady mil, offensive push).
let catalog = vec![
unit_spec("warrior", 1, None, "military"),
unit_spec("pikeman", 2, Some("bronze_working"), "military"),
];
let mut p = player(0, "ironhold", Vec::new(), vec![city(10, (0, 0), 1, &[], &[], true)]);
p.researched_techs = vec!["bronze_working".into()];
// Build state with 1 enemy with 5 mil to trigger Posture::Threatened.
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(),
};
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",
"player with bronze_working + pikeman catalog must produce pikeman, not warrior");
}
// ── Entry point ──────────────────────────────────────────────────────
#[test]

View file

@ -405,6 +405,7 @@ mod tests {
turn: 1,
map,
players,
unit_catalog: Vec::new(),
}
}

View file

@ -34,6 +34,12 @@ pub struct TacticalState {
pub map: TacticalMap,
/// Per-player slots. Indexed by `TacticalPlayerState::index`.
pub players: Vec<TacticalPlayerState>,
/// Catalog of producible military units with tier + tech gate, populated
/// from `units/*.json` by the GDExtension bridge. Consumed by
/// `tactical::production::pick_best_melee` to select tier-N units as tech
/// unlocks (p0-39). Empty vec falls back to tier-1 `warrior` only.
#[serde(default)]
pub unit_catalog: Vec<TacticalUnitSpec>,
}
/// Hex map with row-major tile storage.
@ -128,6 +134,26 @@ pub struct TacticalUnit {
pub can_found_city: bool,
}
/// Specification for a producible military unit — carries enough data for the
/// production layer to select tier-appropriate units as tech unlocks (p0-39).
///
/// Populated from `public/games/age-of-dwarves/data/units/*.json` by the
/// GDExtension bridge and handed through on every `TacticalState`. Empty vec =
/// back-compat (tier-1 fallback only) for fixtures predating p0-39.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TacticalUnitSpec {
/// Unit id (e.g. `"warrior"`, `"pikeman"`).
pub id: String,
/// Tier on the 1..N content ladder.
pub tier: u32,
/// Tech gate — unit is buildable when the player has researched this id.
/// `None` means always available (tier-1 starting units).
pub tech_required: Option<String>,
/// Unit-type classification mirroring `units/*.json::unit_type`:
/// `"military"` | `"worker"` | `"founder"` | `"scout"` | …
pub unit_type: String,
}
/// A city.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TacticalCity {
@ -273,6 +299,7 @@ mod tests {
strategic_axes: ::std::collections::BTreeMap::new(),
},
],
unit_catalog: Vec::new(),
}
}
@ -299,6 +326,7 @@ mod tests {
tiles: Vec::new(),
},
players: Vec::new(),
unit_catalog: Vec::new(),
};
let json = serde_json::to_string(&empty).expect("serialize");
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");

View file

@ -157,6 +157,7 @@ fn two_player_state() -> TacticalState {
turn: 42,
map: small_map(),
players: vec![player0_fixture(), player1_fixture()],
unit_catalog: Vec::new(),
}
}
@ -186,6 +187,7 @@ fn settler_only_state() -> TacticalState {
relations: vec![0],
strategic_axes: ::std::collections::BTreeMap::new(),
}],
unit_catalog: Vec::new(),
}
}
@ -231,6 +233,7 @@ fn production_state() -> TacticalState {
relations: vec![0],
strategic_axes: ::std::collections::BTreeMap::new(),
}],
unit_catalog: Vec::new(),
}
}
@ -309,6 +312,7 @@ fn tactical_state_empty_roundtrip() {
turn: 0,
map: TacticalMap { width: 0, height: 0, tiles: vec![] },
players: vec![],
unit_catalog: Vec::new(),
};
let json = serde_json::to_string(&state).expect("serialize");
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");
@ -337,6 +341,7 @@ fn tactical_state_with_100_tile_map_roundtrip() {
turn: 150,
map: TacticalMap { width: 10, height: 10, tiles },
players: vec![player0_fixture(), player1_fixture()],
unit_catalog: Vec::new(),
};
let json = serde_json::to_string(&state).expect("serialize");
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");