diff --git a/src/simulator/crates/mc-ai/src/tactical/memory.rs b/src/simulator/crates/mc-ai/src/tactical/memory.rs index 94eab7ee..28991dea 100644 --- a/src/simulator/crates/mc-ai/src/tactical/memory.rs +++ b/src/simulator/crates/mc-ai/src/tactical/memory.rs @@ -34,295 +34,9 @@ //! This keeps the `mc-save` contract unchanged; a one-turn re-acquisition is //! invisible at the game-decision scale. -use serde::{Deserialize, Serialize}; - -/// Per-player cross-turn tactical intent. See the module docs for the full -/// rationale; in short this is the persistence channel that makes the AI's -/// war *decisive* (capture → elimination) instead of indecisive (capture → -/// disperse → opponent refounds). -#[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. - /// - /// This is the single per-turn entry point called from - /// [`super::movement::decide_movement`]. It folds the four cases of the - /// `auto_play.gd` state machine into one deterministic update: - /// - /// 1. **No live targets** — clear the lock; nothing to commit to. - /// 2. **Holding a still-alive target** while committed (`commitment > 0`) - /// — keep the lock; `tick` (caller) ages it. - /// 3. **Target fell** (locked hex no longer in `live_targets`) — press on - /// to the nearest remaining target, refreshing the timer (the decisive - /// "captured a city, immediately march on the next" behaviour). - /// 4. **Not committed** (timer expired or never set) but `want_attack` — - /// acquire a fresh lock on the nearest live target. - /// - /// `army_centroid` is the reference hex used to choose the *nearest* - /// target (the army's massing point — typically the mean of its military - /// units, or its capital when it has no army in the field). - /// `live_targets` is the set of still-valid enemy objective hexes - /// (enemy cities, optionally enemy stacks) this turn. - /// `want_attack` is the turn-level decision (computed by the caller from - /// force ratio / score) to *be* in the attack phase. - /// `commitment` is the personality-derived hysteresis length. - /// - /// Returns the hex the army should drive on this turn, if any. - 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() { - // Case 1 — nothing to attack; drop any stale lock. - self.locked_target = None; - self.commitment_turns = 0; - return None; - } - - // Is our current lock still a live target? - let lock_alive = self - .locked_target - .is_some_and(|t| live_targets.contains(&t)); - - if self.commitment_turns > 0 { - if lock_alive { - // Case 2 — hold the existing lock through score wobble. - return self.locked_target; - } - // Case 3 — the locked target fell while we were committed. - // Press on to the next nearest objective (decisive follow-through). - let next = nearest(army_centroid, live_targets); - self.press_on(next, commitment); - return self.locked_target; - } - - // Not committed. - if want_attack { - // Case 4 — (re)acquire a fresh lock and enter the attack phase. - if let Some(t) = nearest(army_centroid, live_targets) { - self.acquire(t, commitment); - return self.locked_target; - } - } - - // Not committed and not attacking — no army-level objective this turn. - // Leave any expired lock cleared. - 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 `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; - // Axial → cube distance. - ((dq.abs() + dr.abs() + (dq + dr).abs()) / 2).max(dq.abs().max(dr.abs())) -} - -#[cfg(test)] -mod tests { - use super::*; - - const CITY_A: (i32, i32) = (10, 10); - const CITY_B: (i32, i32) = (20, 5); - const ARMY: (i32, i32) = (8, 9); - const COMMIT: u32 = 5; - - #[test] - fn default_is_uncommitted() { - let m = TacticalMemory::default(); - assert!(!m.is_committed()); - assert_eq!(m.locked_target, None); - assert_eq!(m.commitment_turns, 0); - } - - #[test] - fn acquires_nearest_target_when_attacking() { - let mut m = TacticalMemory::default(); - let drive = m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT); - assert_eq!(drive, Some(CITY_A), "should lock the nearer city"); - assert_eq!(m.commitment_turns, COMMIT); - assert!(m.is_committed()); - } - - #[test] - fn no_lock_when_not_attacking_and_uncommitted() { - let mut m = TacticalMemory::default(); - let drive = m.resolve(ARMY, &[CITY_A, CITY_B], false, COMMIT); - assert_eq!(drive, None); - assert!(!m.is_committed()); - } - - #[test] - fn lock_persists_across_turns_through_score_wobble() { - // The core hysteresis property: once locked, the army holds the SAME - // target across subsequent turns even when `want_attack` flickers off - // (tactical-score noise), until the timer runs out. - let mut m = TacticalMemory::default(); - m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT); - assert_eq!(m.locked_target, Some(CITY_A)); - - for turn in 1..COMMIT { - m.tick(); - // want_attack flickers OFF — hysteresis must hold the lock anyway. - let drive = m.resolve(ARMY, &[CITY_A, CITY_B], false, COMMIT); - assert_eq!( - drive, - Some(CITY_A), - "turn {turn}: lock must hold through score wobble" - ); - assert_eq!(m.locked_target, Some(CITY_A)); - } - } - - #[test] - fn lock_expires_after_commitment_window() { - let mut m = TacticalMemory::default(); - m.acquire(CITY_A, COMMIT); - for _ in 0..COMMIT { - m.tick(); - } - assert_eq!(m.commitment_turns, 0); - assert_eq!(m.locked_target, None, "expired lock clears its target"); - // Next uncommitted, non-attacking turn yields no drive. - assert_eq!(m.resolve(ARMY, &[CITY_A], false, COMMIT), None); - } - - #[test] - fn presses_on_to_next_target_when_locked_target_falls() { - // press-on-capture: army is committed to CITY_A; CITY_A is captured - // (no longer a live target). The army must immediately re-lock onto - // the next objective AND refresh the timer, not disperse. - let mut m = TacticalMemory::default(); - m.acquire(CITY_A, COMMIT); - m.tick(); // simulate a turn passing; timer now COMMIT-1 - let before = m.commitment_turns; - assert!(before < COMMIT); - - // CITY_A fell; only CITY_B remains. - let drive = m.resolve(ARMY, &[CITY_B], true, COMMIT); - assert_eq!(drive, Some(CITY_B), "press on to the surviving objective"); - assert_eq!( - m.commitment_turns, COMMIT, - "press-on refreshes the hysteresis timer" - ); - } - - #[test] - fn press_on_even_when_want_attack_is_false() { - // The follow-through must NOT depend on re-deciding want_attack — a - // mid-siege score dip can't be allowed to abort the press the very - // turn the city falls. - let mut m = TacticalMemory::default(); - m.acquire(CITY_A, COMMIT); - m.tick(); - let drive = m.resolve(ARMY, &[CITY_B], false, COMMIT); - assert_eq!(drive, Some(CITY_B)); - assert_eq!(m.commitment_turns, COMMIT); - } - - #[test] - fn clears_when_all_targets_eliminated() { - // Last enemy city captured, none remain → lock clears (front won). - let mut m = TacticalMemory::default(); - m.acquire(CITY_A, COMMIT); - m.tick(); - let drive = m.resolve(ARMY, &[], true, COMMIT); - assert_eq!(drive, None); - assert!(!m.is_committed()); - assert_eq!(m.locked_target, None); - } - - #[test] - fn re_locks_nearest_after_expiry() { - let mut m = TacticalMemory::default(); - m.acquire(CITY_B, COMMIT); // committed to the far city - for _ in 0..COMMIT { - m.tick(); - } - assert!(!m.is_committed()); - // Fresh acquisition picks the nearest, not the previously-locked far one. - let drive = m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT); - assert_eq!(drive, Some(CITY_A)); - } - - #[test] - fn resolve_is_deterministic() { - let run = || { - let mut m = TacticalMemory::default(); - let mut out = Vec::new(); - out.push(m.resolve(ARMY, &[CITY_A, CITY_B], true, COMMIT)); - m.tick(); - out.push(m.resolve(ARMY, &[CITY_B], true, COMMIT)); - m.tick(); - out.push(m.resolve(ARMY, &[], true, COMMIT)); - out - }; - assert_eq!(run(), run(), "same inputs → identical lock trajectory"); - } -} +// p2-65 Phase 0c — `TacticalMemory` is pure data carried on +// `mc_turn::PlayerState`, so it relocated to `mc-core` (which the future +// `mc-state` crate can depend on without pulling `mc-ai`). Re-exported here so +// every `mc_ai::tactical::TacticalMemory` reference keeps compiling unchanged; +// the impl + unit tests now live in `mc_core::tactical_types`. +pub use mc_core::tactical_types::TacticalMemory; diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index faffb4f6..dd482386 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -21,7 +21,6 @@ use std::collections::BTreeMap; -use mc_core::BuildingId; use serde::{Deserialize, Serialize}; /// Top-level tactical state passed to [`super::decide_tactical_actions`]. @@ -182,29 +181,10 @@ fn default_promotion_weight() -> f32 { /// "the_great_forge": 2.0, "ancestral_forge": 1.5 /// } /// ``` -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub struct BuildingPriors { - /// Multiplier applied to a candidate building's final score when its - /// `category` field matches a key in this map. 1.0 = neutral. Keys are - /// lowercase category strings as authored in `buildings/*.json::category` - /// (`"production"`, `"infrastructure"`, `"military"`, `"knowledge"`, - /// `"religious"`, `"wonder"`, `"naval"`, etc.). - /// - /// Missing keys fall through to the legacy axis-driven multipliers in - /// `score_building` (preserves cycle-5 behaviour for fixtures predating - /// per-clan weights). - #[serde(default)] - pub building_category_weights: BTreeMap, - /// Multiplier applied on top of the category bias when the candidate is - /// a wonder (any non-null `wonder_type`) AND its id matches a key here. - /// 1.0 = neutral. Lets a `production`-axis clan rate `the_great_forge` - /// higher than `ancient_lighthouse` without inflating every wonder. - /// - /// Missing keys fall through to the flat `+5.0` wonder bonus already in - /// `score_building`. - #[serde(default)] - pub wonder_priorities: BTreeMap, -} +// p2-65 Phase 0c — `BuildingPriors` relocated to `mc-core` (pure data carried on +// `mc_turn::PlayerState`); re-exported so `crate::tactical::state::BuildingPriors` +// keeps resolving. +pub use mc_core::tactical_types::BuildingPriors; impl Default for TacticalPlayerState { /// Neutral test fixture. The three promotion weights default to 1.0 so @@ -301,50 +281,8 @@ impl TacticalUnit { /// 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, - /// Unit-type classification mirroring `units/*.json::unit_type`: - /// `"military"` | `"worker"` | `"founder"` | `"scout"` | … - pub unit_type: String, - /// Strategic resource gate — unit buildable only when the player owns at - /// least one tile providing this resource id (e.g. `"iron_ore"` for - /// cavalry). `None` means no resource requirement. Filtered by - /// `tactical::production::pick_best_melee` to avoid queueing units the - /// engine's strategic-gate check will reject. - #[serde(default)] - pub requires_resource: Option, - /// Race gate — unit buildable only when the player's race matches this id - /// (e.g. `"dwarf"` for berserker / ironwarden / forge_titan). `None` - /// means no race restriction. - #[serde(default)] - pub race_required: Option, - /// 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, - /// 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, - /// Building gate — unit is only buildable when the city has already - /// constructed this building id (e.g. `"harbor"` for naval units, - /// `"airfield"` for aerial units). `None` means no building requirement. - /// Populated from `units/*.json::requires_building`. (p1-33; retyped to - /// `Option` in p1-44 — the JSON wire format is unchanged - /// because `BuildingId` is `#[serde(transparent)]`.) - #[serde(default)] - pub requires_building: Option, -} +// p2-65 Phase 0c — relocated to `mc-core`; re-exported (see `BuildingPriors`). +pub use mc_core::tactical_types::TacticalUnitSpec; /// A city. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -422,114 +360,9 @@ impl TacticalEphemerals { /// it across the FFI as part of the per-turn ephemerals. The Rust scoring path /// (`tactical::production::pick_building_from_catalog`) consumes the catalog /// to evaluate every available building each turn rather than hardcoding 8 ids. -#[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 are scored higher when buildable. - #[serde(default = "default_tier")] - pub tier: u32, - /// Coarse role from JSON `category` field. Examples: - /// `production` | `infrastructure` | `military` | `research` | `religion` | - /// `wonder` | `defense` | `naval` | `magic` | `culture` | `food`. - #[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"`. Wonders are exclusive and weighted differently. - #[serde(default)] - pub wonder_type: Option, - /// Strategic resource gate (e.g. `"iron_ore"`). - #[serde(default)] - pub requires_resource: Option, - /// Stacking gate (p1-43): building requires the named building already - /// present in the same city. Distinct from `requires_buildings_all_cities` - /// which is a national-wonder constraint. - #[serde(default)] - pub requires_existing: Option, - /// Per-turn food yield (sum of `effects[*].type == "food"`). - #[serde(default)] - pub yield_food: i32, - /// Per-turn production yield. - #[serde(default)] - pub yield_production: i32, - /// Per-turn gold/trade yield (sum of `effects[*].type` in `gold` / `trade`). - #[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 (city walls, fortifications, +HP, etc.). - #[serde(default)] - pub yield_defense: i32, - /// Sum of GPP effects across all channels (used as a coarse "interesting" tag). - #[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, -} - -fn default_tier() -> u32 { - 1 -} - -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; the AI may legitimately queue a wonder another player - /// will finish first. - 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; - } - } - // Skip already-built buildings (most buildings are unique per city). - if city_buildings.iter().any(|b| b == &self.id) { - return false; - } - true - } -} +// p2-65 Phase 0c — relocated to `mc-core` (struct + `is_buildable` impl + +// `default_tier`); re-exported (see `BuildingPriors`). +pub use mc_core::tactical_types::TacticalBuildingSpec; #[cfg(test)] mod tests {