feat(tactical): Implement sophisticated threshold calculations and optimized tree state management for tactical AI decision-making

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 09:26:32 -07:00
parent e3297a291c
commit f521d73b1c
2 changed files with 47 additions and 2 deletions

View file

@ -184,6 +184,30 @@ pub fn capital_walls_min_age_turns(axes: &BTreeMap<String, i32>) -> 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<String, i32>) -> 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)]);

View file

@ -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(&current, &self.scoring, rng, None);
let actions = decide_tactical_actions(&current, &self.scoring, rng, None, &mut memory);
if actions.is_empty() {
break;
}