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:
parent
fd0097b838
commit
0230833324
2 changed files with 140 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
138
src/simulator/crates/mc-ai/src/mcts_tree.rs
Normal file
138
src/simulator/crates/mc-ai/src/mcts_tree.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue