diff --git a/src/game/engine/src/modules/combat/combat_resolver.gd b/src/game/engine/src/modules/combat/combat_resolver.gd index 015065a2..8b39209d 100644 --- a/src/game/engine/src/modules/combat/combat_resolver.gd +++ b/src/game/engine/src/modules/combat/combat_resolver.gd @@ -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) diff --git a/src/simulator/crates/mc-ai/src/mcts_tree.rs b/src/simulator/crates/mc-ai/src/mcts_tree.rs new file mode 100644 index 00000000..b145d6c9 --- /dev/null +++ b/src/simulator/crates/mc-ai/src/mcts_tree.rs @@ -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; + + /// 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 { + pub state: S, + pub parent: Option, + pub action: Option, + pub children: Vec, + pub untried: Vec, + pub visits: u32, + pub wins: f32, +} + +impl Node { + fn new(state: S, parent: Option, action: Option) -> 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 { + pub nodes: Vec>, + pub exploration_constant: f32, +} + +impl Tree { + 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 { + &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 { + 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); + } +}