diff --git a/src/simulator/crates/mc-ai/src/tactical/thresholds.rs b/src/simulator/crates/mc-ai/src/tactical/thresholds.rs index 99ad03fd..5bb3d313 100644 --- a/src/simulator/crates/mc-ai/src/tactical/thresholds.rs +++ b/src/simulator/crates/mc-ai/src/tactical/thresholds.rs @@ -184,6 +184,30 @@ pub fn capital_walls_min_age_turns(axes: &BTreeMap) -> u32 { lerp_axis(defense, 30.0, 20.0, 10.0).round() as u32 } +// ─── Stateful-decisiveness thresholds (p1-29h) ───────────────────────────── + +/// Army-level commitment-hysteresis length, in turns, used by +/// [`super::memory::TacticalMemory`]. Once the army commits to an objective it +/// holds the lock for this many turns through tactical-score wobble (and the +/// timer refreshes on each capture, so the army presses on rather than +/// dispersing). +/// +/// Mirrors the juiced harness `auto_play.gd::_attack_commitment_turns = 5` +/// (`auto_play.gd:1146`) at the neutral axis, and lets personality lengthen or +/// shorten it: aggressive + grudgeful clans stay committed longer (they finish +/// what they start), cautious clans break off sooner. +/// +/// Range: `composite=1` → 3 turns (cautious — re-evaluates often), +/// `composite=5` → 5 turns (the `auto_play.gd` baseline), +/// `composite=10` → 8 turns (relentless). Composite blends aggression 0.6 / +/// grudge_persistence 0.4 — the same drivers as [`capital_approach_hex`]. +pub fn commitment_turns(axes: &BTreeMap) -> u32 { + let aggression = axis(axes, "aggression") as f32; + let grudge = axis(axes, "grudge_persistence") as f32; + let composite = ((aggression * 0.6 + grudge * 0.4).round() as i32).clamp(1, 10); + lerp_axis(composite, 3.0, 5.0, 8.0).round().max(1.0) as u32 +} + #[cfg(test)] mod tests { use super::*; @@ -224,6 +248,22 @@ mod tests { assert_eq!(final_push_enemy_city_count(&a), 1); } + #[test] + fn commitment_turns_baseline_matches_auto_play_constant() { + // axis=5 composite must reproduce auto_play.gd's _attack_commitment_turns = 5. + let a = axes(&[("aggression", 5), ("grudge_persistence", 5)]); + assert_eq!(commitment_turns(&a), 5); + } + + #[test] + fn commitment_turns_scales_with_personality() { + let cautious = axes(&[("aggression", 1), ("grudge_persistence", 1)]); + let relentless = axes(&[("aggression", 10), ("grudge_persistence", 10)]); + assert!(commitment_turns(&cautious) < commitment_turns(&relentless)); + assert!(commitment_turns(&cautious) >= 1, "never zero — always commit at least 1 turn"); + assert_eq!(commitment_turns(&relentless), 8); + } + #[test] fn capital_siege_no_retreat_hp_baseline_matches_historical() { let a = axes(&[("aggression", 5)]); diff --git a/src/simulator/crates/mc-ai/src/tactical/tree_state.rs b/src/simulator/crates/mc-ai/src/tactical/tree_state.rs index c30a02d3..969b200e 100644 --- a/src/simulator/crates/mc-ai/src/tactical/tree_state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/tree_state.rs @@ -83,7 +83,11 @@ impl TreeState for TacticalTreeState { // Seed deterministically from depth so the candidate set is the // same on repeat traversals at the same depth. let mut rng = XorShift64::new(0x9E37_79B9_7F4A_7C15u64.wrapping_mul(self.depth as u64 + 1)); - decide_tactical_actions(&self.inner, &self.scoring, &mut rng, None) + // p1-29h — MCTS rollouts are hypothetical lookahead with no cross-turn + // continuity, so each enumeration uses a fresh transient memory. The + // army-lock is a live-play decisiveness lever, not a search-tree one. + let mut memory = super::memory::TacticalMemory::default(); + decide_tactical_actions(&self.inner, &self.scoring, &mut rng, None, &mut memory) } fn apply(&self, action: &Self::Action) -> Self { @@ -110,8 +114,9 @@ impl TreeState for TacticalTreeState { // we don't depend on the tree's `root_player` config knob. let mut current = self.inner.clone(); let _start = Instant::now(); + let mut memory = super::memory::TacticalMemory::default(); for _ in 0..horizon { - let actions = decide_tactical_actions(¤t, &self.scoring, rng, None); + let actions = decide_tactical_actions(¤t, &self.scoring, rng, None, &mut memory); if actions.is_empty() { break; }