diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 24356ad8..6ace11c0 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod production_origin; pub mod resources; pub mod scoring_weights; pub mod seed; +pub mod tactical_types; pub mod tech; pub mod units; pub mod wonder; @@ -52,6 +53,7 @@ pub use ids::{BuildingId, GreatPersonClass, HarvestPolicyId, ResourceId, Special pub use lair::{LairCombatMode, LairId, SiegeOutcome, SiegePressure, SiegeState}; pub use resources::{ResourceKind, ResourceStockpile, StockpileError as ResourceStockpileError}; pub use scoring_weights::{LoadError, PersonalityDef, ScoringWeights}; +pub use tactical_types::{BuildingPriors, TacticalBuildingSpec, TacticalMemory, TacticalUnitSpec}; pub use player::{HexCoord, PlayerPrologue}; pub use player_presentation::PresentationPlayer; pub use production_origin::ProductionOrigin; diff --git a/src/simulator/crates/mc-core/src/tactical_types.rs b/src/simulator/crates/mc-core/src/tactical_types.rs new file mode 100644 index 00000000..e5cf7659 --- /dev/null +++ b/src/simulator/crates/mc-core/src/tactical_types.rs @@ -0,0 +1,370 @@ +//! Tactical AI data shapes carried on the simulation state (p2-65 Phase 0c). +//! +//! These four types were authored in `mc-ai::tactical` but are pure data +//! (no AI logic): `mc_turn::GameState`/`PlayerState` carry them as fields, so +//! they must live in a crate that the future `mc-state` crate can depend on +//! WITHOUT pulling in `mc-ai`. `mc-core` is that crate (same precedent as +//! `ScoringWeights` and `CombatBalance`, both relocated here earlier). +//! +//! `mc-ai::tactical::{memory, state}` re-export these (`pub use +//! mc_core::tactical_types::…`) so every existing `mc_ai::tactical::…` +//! reference keeps compiling unchanged. Serde shapes are byte-identical to the +//! pre-relocation definitions — the save format is invariant (the JSON never +//! encoded module paths). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::BuildingId; + +/// Per-player cross-turn tactical intent (p1-29h). The persistence channel that +/// makes the AI's war *decisive* (capture → elimination) instead of indecisive +/// (capture → disperse → opponent refounds). Carried on +/// `mc_turn::PlayerState::tactical_memory` (`#[serde(skip)]` there); the +/// commitment hysteresis + army-wide target-lock that `mc-ai`'s +/// `decide_movement` mutates each turn. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TacticalMemory { + /// Army-wide locked objective hex `(col, row)`. `None` when the army is + /// not currently committed to a target. + pub locked_target: Option<(i32, i32)>, + /// Commitment hysteresis countdown. While `> 0`, the army holds + /// `locked_target` through tactical-score noise. Decremented once per turn + /// in [`Self::tick`]. + pub commitment_turns: u32, +} + +impl TacticalMemory { + /// True when the army is currently committed to a locked target. + pub fn is_committed(&self) -> bool { + self.commitment_turns > 0 && self.locked_target.is_some() + } + + /// Decrement the commitment timer by one turn (saturating at 0). Clears + /// `locked_target` when the timer reaches 0 so a lapsed commitment doesn't + /// leave a stale target around for the next acquisition pass. + pub fn tick(&mut self) { + if self.commitment_turns > 0 { + self.commitment_turns -= 1; + if self.commitment_turns == 0 { + self.locked_target = None; + } + } + } + + /// Acquire (or refresh) the lock onto `target` for `commitment` turns. + /// Used when the army first commits to an objective. + pub fn acquire(&mut self, target: (i32, i32), commitment: u32) { + self.locked_target = Some(target); + self.commitment_turns = commitment.max(1); + } + + /// Press-on-capture: the previously locked target fell (was captured or + /// destroyed). Refresh the timer and re-point at `next_target` so the army + /// drives onward to the next objective instead of dispersing. Pass `None` + /// for `next_target` when no further enemy objective remains — that clears + /// the lock (the war is effectively won on this front). + pub fn press_on(&mut self, next_target: Option<(i32, i32)>, commitment: u32) { + self.locked_target = next_target; + self.commitment_turns = if next_target.is_some() { + commitment.max(1) + } else { + 0 + }; + } + + /// Resolve the per-turn lock against the world. The single per-turn entry + /// point called from `mc_ai::tactical::movement::decide_movement`. Folds the + /// four `auto_play.gd` state-machine cases into one deterministic update: + /// + /// 1. **No live targets** — clear the lock; nothing to commit to. + /// 2. **Holding a still-alive target** while committed — keep the lock. + /// 3. **Target fell** — press on to the nearest remaining target. + /// 4. **Not committed** but `want_attack` — acquire a fresh lock. + /// + /// `army_centroid` is the army's massing point; `live_targets` the set of + /// still-valid enemy objective hexes; `want_attack` the turn-level attack + /// decision; `commitment` the personality-derived hysteresis length. + pub fn resolve( + &mut self, + army_centroid: (i32, i32), + live_targets: &[(i32, i32)], + want_attack: bool, + commitment: u32, + ) -> Option<(i32, i32)> { + if live_targets.is_empty() { + self.locked_target = None; + self.commitment_turns = 0; + return None; + } + + let lock_alive = self + .locked_target + .is_some_and(|t| live_targets.contains(&t)); + + if self.commitment_turns > 0 { + if lock_alive { + return self.locked_target; + } + let next = nearest(army_centroid, live_targets); + self.press_on(next, commitment); + return self.locked_target; + } + + if want_attack { + if let Some(t) = nearest(army_centroid, live_targets) { + self.acquire(t, commitment); + return self.locked_target; + } + } + + if self.commitment_turns == 0 { + self.locked_target = None; + } + None + } +} + +/// Nearest target hex to `from` by hex (axial) distance. Deterministic tie- +/// break: first in iteration order (the caller supplies a stable order). +fn nearest(from: (i32, i32), targets: &[(i32, i32)]) -> Option<(i32, i32)> { + targets.iter().copied().min_by_key(|&t| hex_distance(from, t)) +} + +/// Axial hex distance between two `(col, row)` pairs. Local copy to keep this +/// module dependency-free; matches `mc_ai::tactical::movement::hex_dist`. +fn hex_distance(a: (i32, i32), b: (i32, i32)) -> i32 { + let (aq, ar) = a; + let (bq, br) = b; + let dq = aq - bq; + let dr = ar - br; + ((dq.abs() + dr.abs() + (dq + dr).abs()) / 2).max(dq.abs().max(dr.abs())) +} + +/// Per-clan building-scoring biases. Empty maps = neutral (fall through to the +/// legacy axis-driven multipliers in `mc_ai::tactical::production::score_building`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BuildingPriors { + /// Multiplier applied to a candidate building's final score when its + /// `category` matches a key here. 1.0 = neutral. Keys are lowercase category + /// strings from `buildings/*.json::category`. + #[serde(default)] + pub building_category_weights: BTreeMap, + /// Multiplier on top of the category bias when the candidate is a wonder + /// AND its id matches a key here. 1.0 = neutral. + #[serde(default)] + pub wonder_priorities: BTreeMap, +} + +/// A buildable unit's gates + classification, flattened from `units/*.json` by +/// the GDExtension bridge and handed through on every `TacticalState`. +#[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 buildable when the player has researched this id. + pub tech_required: Option, + /// Unit-type classification: `"military"` | `"worker"` | `"founder"` | … + pub unit_type: String, + /// Strategic resource gate (e.g. `"iron_ore"`). `None` = no requirement. + #[serde(default)] + pub requires_resource: Option, + /// Race gate (e.g. `"dwarf"`). `None` = no restriction. + #[serde(default)] + pub race_required: Option, + /// Clan IDs that prefer this unit. Empty = neutral / shared. + #[serde(default)] + pub clan_affinity: Vec, + /// Archetype label mirroring `units/*.json::archetype`. `None` for fixtures + /// predating p1-34. + #[serde(default)] + pub archetype: Option, + /// Building gate — unit buildable only when the city has this building. + /// `None` = no requirement. `BuildingId` is `#[serde(transparent)]` so the + /// JSON wire format is unchanged. + #[serde(default)] + pub requires_building: Option, +} + +/// A buildable building's gates + summed yields, flattened from `buildings/*.json`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TacticalBuildingSpec { + /// Building id (e.g. `"forge"`, `"library"`, `"the_great_forge"`). + pub id: String, + /// Authoring tier (`1..N`). Higher tiers scored higher when buildable. + #[serde(default = "default_tier")] + pub tier: u32, + /// Coarse role from JSON `category`. + #[serde(default)] + pub category: String, + /// Production cost from JSON `cost`. + #[serde(default)] + pub cost: u32, + /// Tech gate — buildable only when player has researched this id. + #[serde(default)] + pub tech_required: Option, + /// Race gate — buildable only when player race matches. + #[serde(default)] + pub race_required: Option, + /// Wonder classification: `null` | `"national"` | `"world"` | `"wmd_mundane_world"`. + #[serde(default)] + pub wonder_type: Option, + /// Strategic resource gate (e.g. `"iron_ore"`). + #[serde(default)] + pub requires_resource: Option, + /// Stacking gate (p1-43): requires the named building already present. + #[serde(default)] + pub requires_existing: Option, + /// Per-turn food yield. + #[serde(default)] + pub yield_food: i32, + /// Per-turn production yield. + #[serde(default)] + pub yield_production: i32, + /// Per-turn gold/trade yield. + #[serde(default)] + pub yield_gold: i32, + /// Per-turn science yield. + #[serde(default)] + pub yield_science: i32, + /// Per-turn culture yield. + #[serde(default)] + pub yield_culture: i32, + /// Sum of authored defense effects. + #[serde(default)] + pub yield_defense: i32, + /// Sum of GPP effects across all channels. + #[serde(default)] + pub yield_gpp: i32, + /// Sum of great_work_slot capacities across all categories. + #[serde(default)] + pub great_work_slots: i32, + /// Happiness contribution from JSON effects. + #[serde(default)] + pub yield_happiness: i32, +} + +impl TacticalBuildingSpec { + /// True when every gate (tech, race, resource, requires_existing) is + /// satisfied for the given player + city. Wonder-uniqueness is NOT enforced + /// here — the engine's production-tick resolves wonder duplication. + pub fn is_buildable( + &self, + researched_techs: &[String], + race_id: Option<&str>, + strategic_resources: &[String], + city_buildings: &[String], + ) -> bool { + if let Some(tech) = &self.tech_required { + if !researched_techs.iter().any(|t| t == tech) { + return false; + } + } + if let Some(req_race) = &self.race_required { + match race_id { + None => return false, + Some(owned) if owned != req_race => return false, + _ => {} + } + } + if let Some(res) = &self.requires_resource { + if !strategic_resources.iter().any(|r| r == res) { + return false; + } + } + if let Some(prereq) = &self.requires_existing { + if !city_buildings.iter().any(|b| b == prereq) { + return false; + } + } + if city_buildings.iter().any(|b| b == &self.id) { + return false; + } + true + } +} + +fn default_tier() -> u32 { + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tactical_memory_acquire_hold_press_on() { + let army = (8, 9); + let a = (10, 10); + let b = (20, 5); + let mut m = TacticalMemory::default(); + assert!(!m.is_committed()); + assert_eq!(m.resolve(army, &[a, b], true, 5), Some(a), "locks nearer city"); + assert_eq!(m.commitment_turns, 5); + // Hysteresis: hold through want_attack=false. + m.tick(); + assert_eq!(m.resolve(army, &[a, b], false, 5), Some(a)); + // Press-on when the locked target falls. + let drive = m.resolve(army, &[b], true, 5); + assert_eq!(drive, Some(b)); + assert_eq!(m.commitment_turns, 5, "press-on refreshes timer"); + // Clears when all targets gone. + assert_eq!(m.resolve(army, &[], true, 5), None); + assert!(!m.is_committed()); + } + + #[test] + fn building_spec_gates() { + let spec = TacticalBuildingSpec { + id: "forge".into(), + tier: 1, + category: "production".into(), + cost: 60, + tech_required: Some("bronze_working".into()), + race_required: None, + wonder_type: None, + requires_resource: None, + requires_existing: None, + yield_food: 0, + yield_production: 2, + yield_gold: 0, + yield_science: 0, + yield_culture: 0, + yield_defense: 0, + yield_gpp: 0, + great_work_slots: 0, + yield_happiness: 0, + }; + assert!(!spec.is_buildable(&[], None, &[], &[]), "tech gate blocks"); + assert!(spec.is_buildable(&["bronze_working".into()], None, &[], &[])); + assert!( + !spec.is_buildable(&["bronze_working".into()], None, &[], &["forge".into()]), + "already built" + ); + } + + #[test] + fn specs_round_trip_serde() { + let u = TacticalUnitSpec { + id: "pikeman".into(), + tier: 2, + tech_required: Some("iron_working".into()), + unit_type: "military".into(), + requires_resource: None, + race_required: None, + clan_affinity: vec!["ironhold".into()], + archetype: Some("anti_cavalry".into()), + requires_building: None, + }; + let j = serde_json::to_string(&u).unwrap(); + assert_eq!(serde_json::from_str::(&j).unwrap(), u); + + let mut priors = BuildingPriors::default(); + priors.building_category_weights.insert("production".into(), 1.5); + let j2 = serde_json::to_string(&priors).unwrap(); + assert_eq!(serde_json::from_str::(&j2).unwrap(), priors); + } +}