feat(tactical): ✨ add unit catalog support for production logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
7c6d922719
commit
b5d67f77ff
6 changed files with 178 additions and 4 deletions
|
|
@ -352,6 +352,7 @@ mod tests {
|
|||
turn: 10,
|
||||
map,
|
||||
players,
|
||||
unit_catalog: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ mod tests {
|
|||
turn: 1,
|
||||
map,
|
||||
players,
|
||||
unit_catalog: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue