feat(combat): Update core CombatResolver and MCTS algorithm to enforce new combat rules and enhance AI decision-making logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 14:45:33 -07:00
parent fd0097b838
commit 0230833324
2 changed files with 140 additions and 0 deletions

View file

@ -121,6 +121,8 @@ func _apply_resolve_results(
defender.hp = defender_hp
elif defender is CityScript:
defender.hp = int(result.get("city_hp_remaining", defender.hp))
if int(result.get("city_damage", 0)) > 0:
(defender as CityScript).mark_attacked(GameState.turn_number)
var attacker_killed: bool = bool(
result.get("attacker_killed", attacker_hp <= 0)

View file

@ -0,0 +1,138 @@
//! Tree Monte Carlo Tree Search scaffold (Node + UCB1 select/expand/simulate/backpropagate).
//!
//! FOUNDATION ONLY. Not wired into gameplay. SimpleHeuristicAi continues to drive games.
//! `simulate()` is stubbed to return 0.5 until full Rust game simulation exists.
//!
//! Generic over a `TreeState` trait so callers plug in their own state + action types
//! without this module depending on mc-core / mc-turn.
use crate::mcts::XorShift64;
/// State + action interface the tree MCTS operates over.
pub trait TreeState: Clone {
type Action: Clone;
/// All legal actions from this state. Empty => terminal.
fn legal_actions(&self) -> Vec<Self::Action>;
/// Apply an action, producing the child state.
fn apply(&self, action: &Self::Action) -> Self;
/// Terminal check. Default: no legal actions.
fn is_terminal(&self) -> bool {
self.legal_actions().is_empty()
}
}
/// Tree node. `children` holds indices into the owning arena (`Tree::nodes`).
#[derive(Debug)]
pub struct Node<S: TreeState> {
pub state: S,
pub parent: Option<usize>,
pub action: Option<S::Action>,
pub children: Vec<usize>,
pub untried: Vec<S::Action>,
pub visits: u32,
pub wins: f32,
}
impl<S: TreeState> Node<S> {
fn new(state: S, parent: Option<usize>, action: Option<S::Action>) -> Self {
let untried = state.legal_actions();
Self { state, parent, action, children: Vec::new(), untried, visits: 0, wins: 0.0 }
}
fn is_fully_expanded(&self) -> bool {
self.untried.is_empty()
}
}
/// Arena-allocated tree. Index 0 is always the root.
pub struct Tree<S: TreeState> {
pub nodes: Vec<Node<S>>,
pub exploration_constant: f32,
}
impl<S: TreeState> Tree<S> {
pub fn new(root_state: S) -> Self {
Self {
nodes: vec![Node::new(root_state, None, None)],
exploration_constant: std::f32::consts::SQRT_2,
}
}
pub fn root(&self) -> &Node<S> {
&self.nodes[0]
}
/// Descend from root via UCB1 to a node that is not fully expanded or is terminal.
pub fn select(&self, mut idx: usize) -> usize {
while self.nodes[idx].is_fully_expanded() && !self.nodes[idx].children.is_empty() {
idx = self.best_ucb1_child(idx);
}
idx
}
fn best_ucb1_child(&self, idx: usize) -> usize {
let parent_visits = (self.nodes[idx].visits as f32).max(1.0);
let log_n = parent_visits.ln().max(0.0);
*self.nodes[idx]
.children
.iter()
.max_by(|&&a, &&b| {
let sa = self.ucb1(a, log_n);
let sb = self.ucb1(b, log_n);
sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("best_ucb1_child requires non-empty children")
}
fn ucb1(&self, idx: usize, log_parent: f32) -> f32 {
let n = &self.nodes[idx];
if n.visits == 0 {
return f32::INFINITY;
}
let avg = n.wins / n.visits as f32;
let explore = self.exploration_constant * (log_parent / n.visits as f32).sqrt();
avg + explore
}
/// Expand one untried action from `idx`, returning the new child index.
/// Returns `None` if already fully expanded.
pub fn expand(&mut self, idx: usize) -> Option<usize> {
let action = self.nodes[idx].untried.pop()?;
let child_state = self.nodes[idx].state.apply(&action);
let child = Node::new(child_state, Some(idx), Some(action));
let child_idx = self.nodes.len();
self.nodes.push(child);
self.nodes[idx].children.push(child_idx);
Some(child_idx)
}
/// Stubbed rollout. Returns 0.5 regardless of state until game simulation lands.
/// `_rng` is threaded so future random rollouts slot in without API changes.
pub fn simulate(&self, _idx: usize, _rng: &mut XorShift64) -> f32 {
0.5
}
/// Propagate `reward` from `idx` up to the root, incrementing visits and wins.
pub fn backpropagate(&mut self, mut idx: usize, reward: f32) {
loop {
let n = &mut self.nodes[idx];
n.visits += 1;
n.wins += reward;
match n.parent {
Some(p) => idx = p,
None => break,
}
}
}
/// Run one full MCTS iteration (select → expand → simulate → backpropagate).
pub fn iterate(&mut self, rng: &mut XorShift64) {
let leaf = self.select(0);
let target = self.expand(leaf).unwrap_or(leaf);
let reward = self.simulate(target, rng);
self.backpropagate(target, reward);
}
}