ai-hex 0.1.0

A small Hex rules and Monte Carlo tree search engine
Documentation
use crate::{
    BoardSize, Color, GameResult, KnowledgePolicy, MctsOptions, MctsReport, MctsSearcher,
    MctsStrategy, Move, Position, RolloutPolicy, Seat, SwapRule,
};
use std::fmt::Write;
use std::str::FromStr;

pub struct Engine {
    position: Position,
    history: Vec<Move>,
    searcher: MctsSearcher,
    last_decision: Option<MctsReport>,
}

impl Default for Engine {
    fn default() -> Self {
        Self::new()
    }
}

impl Engine {
    pub fn new() -> Self {
        Self {
            position: Position::new(BoardSize::default(), SwapRule::Enabled),
            history: Vec::new(),
            searcher: MctsSearcher::new(),
            last_decision: None,
        }
    }

    pub fn command(&mut self, line: &str) -> Vec<String> {
        let words = line.split_whitespace().collect::<Vec<_>>();
        let Some(command) = words.first().copied() else {
            return vec![error("empty command")];
        };
        match command {
            "gai" => vec![
                "id name ai-hex".to_owned(),
                "id game hex".to_owned(),
                "gaiok".to_owned(),
            ],
            "isready" => vec!["readyok".to_owned()],
            "newgame" => self.new_game(&words[1..]),
            "position" => self.set_position(&words[1..]),
            "mcts" => self.search(&words[1..]),
            "legal" => vec![format!(
                "legalmoves {}",
                self.position
                    .legal_moves()
                    .iter()
                    .map(Move::to_string)
                    .collect::<Vec<_>>()
                    .join(" ")
            )],
            "state" => vec![format!("state {}", self.snapshot_json())],
            "quit" => Vec::new(),
            _ => vec![error("unknown command")],
        }
    }

    pub fn snapshot_json(&self) -> String {
        let mut json = String::with_capacity(8_192);
        write!(
            json,
            "{{\"size\":{},\"board\":[",
            self.position.size().get()
        )
        .expect("writing to String cannot fail");
        for dense in 0..self.position.size().cell_count() {
            if dense > 0 {
                json.push(',');
            }
            let cell = crate::Cell::from_dense(dense, self.position.size());
            match self.position.color_at(cell) {
                Some(Color::Red) => json.push_str("\"R\""),
                Some(Color::Blue) => json.push_str("\"B\""),
                None => json.push_str("null"),
            }
        }
        write!(
            json,
            "],\"seatToMove\":\"{}\",\"colorToMove\":\"{}\",\"seatColors\":[\"{}\",\"{}\"],\"colorsSwapped\":{},\"swapAvailable\":{},\"result\":\"{}\",\"winnerSeat\":{},\"winnerColor\":{},",
            self.position.seat_to_move().as_str(),
            color_code(self.position.color_to_move()),
            color_code(self.position.color_for_seat(Seat::One)),
            color_code(self.position.color_for_seat(Seat::Two)),
            self.position.colors_swapped(),
            self.position.swap_available(),
            result_name(self.position.result()),
            winner_seat_json(self.position.result()),
            winner_color_json(self.position),
        )
        .expect("writing to String cannot fail");

        json.push_str("\"winningPath\":[");
        for (index, cell) in self.position.winning_path().iter().enumerate() {
            if index > 0 {
                json.push(',');
            }
            write!(json, "\"{cell}\"").expect("writing to String cannot fail");
        }
        json.push_str("],\"legalMoves\":[");
        for (index, mv) in self.position.legal_moves().iter().enumerate() {
            if index > 0 {
                json.push(',');
            }
            write!(json, "\"{mv}\"").expect("writing to String cannot fail");
        }
        json.push_str("],\"history\":[");
        for (index, mv) in self.history.iter().enumerate() {
            if index > 0 {
                json.push(',');
            }
            write!(json, "\"{mv}\"").expect("writing to String cannot fail");
        }
        json.push_str("],\"lastMove\":");
        if let Some(last) = self.history.last() {
            write!(json, "\"{last}\"").expect("writing to String cannot fail");
        } else {
            json.push_str("null");
        }
        json.push_str(",\"decision\":");
        match &self.last_decision {
            Some(report) => write_report(&mut json, report),
            None => json.push_str("null"),
        }
        json.push('}');
        json
    }

    fn new_game(&mut self, words: &[&str]) -> Vec<String> {
        match parse_setup(words) {
            Ok((size, rule, consumed)) if consumed == words.len() => {
                self.position = Position::new(size, rule);
                self.history.clear();
                self.last_decision = None;
                Vec::new()
            }
            Ok(_) => vec![error("newgame supports size and swap")],
            Err(message) => vec![error(message)],
        }
    }

    fn set_position(&mut self, words: &[&str]) -> Vec<String> {
        let (size, rule, consumed) = match parse_setup(words) {
            Ok(setup) => setup,
            Err(message) => return vec![error(message)],
        };
        let remaining = &words[consumed..];
        let move_words = match remaining.first() {
            None => &[][..],
            Some(&"moves") => &remaining[1..],
            Some(_) => return vec![error("expected 'moves' after position setup")],
        };
        let moves = match move_words
            .iter()
            .map(|word| Move::from_str(word))
            .collect::<Result<Vec<_>, _>>()
        {
            Ok(moves) => moves,
            Err(message) => return vec![error(&message.to_string())],
        };
        match Position::from_moves(size, rule, &moves) {
            Ok(position) => {
                self.position = position;
                self.history = moves;
                self.last_decision = None;
                Vec::new()
            }
            Err(message) => vec![error(&message.to_string())],
        }
    }

    fn search(&mut self, words: &[&str]) -> Vec<String> {
        let mut options = MctsOptions::default();
        let mut index = 0;
        while index < words.len() {
            let Some(value) = words.get(index + 1) else {
                return vec![error("mcts options need a value")];
            };
            match words[index] {
                "simulations" => match value.parse::<u32>() {
                    Ok(simulations @ 1..=1_000_000) => options.max_simulations = simulations,
                    _ => return vec![error("simulations must be from 1 through 1000000")],
                },
                "softtime" => match value.parse::<u32>() {
                    Ok(time @ 0..=2_000) => options.soft_time_ms = time,
                    _ => return vec![error("softtime must be from 0 through 2000 milliseconds")],
                },
                "exploration" => match value.parse::<f64>() {
                    Ok(exploration)
                        if exploration.is_finite() && (0.0..=4.0).contains(&exploration) =>
                    {
                        options.exploration = exploration;
                    }
                    _ => return vec![error("exploration must be from 0 through 4")],
                },
                "strategy" => match *value {
                    "plain-uct" => options.strategy = MctsStrategy::PlainUct,
                    "uct-rave" => options.strategy = MctsStrategy::UctRave,
                    _ => return vec![error("strategy must be plain-uct or uct-rave")],
                },
                "rave" => match value.parse::<f64>() {
                    Ok(equivalence)
                        if equivalence.is_finite() && (1.0..=100_000.0).contains(&equivalence) =>
                    {
                        options.rave_equivalence = equivalence;
                    }
                    _ => return vec![error("rave must be from 1 through 100000")],
                },
                "rollout" => match *value {
                    "random" => options.rollout_policy = RolloutPolicy::Random,
                    "save-bridge" => options.rollout_policy = RolloutPolicy::SaveBridge,
                    _ => return vec![error("rollout must be random or save-bridge")],
                },
                "knowledge" => match *value {
                    "off" => options.knowledge_policy = KnowledgePolicy::Disabled,
                    value => match value.parse::<u32>() {
                        Ok(min_visits @ 0..=1_000_000) => {
                            options.knowledge_policy =
                                KnowledgePolicy::InferiorCells { min_visits };
                        }
                        _ => return vec![error("knowledge must be off or from 0 through 1000000")],
                    },
                },
                "connections" => match *value {
                    "on" => options.use_virtual_connections = true,
                    "off" => options.use_virtual_connections = false,
                    _ => return vec![error("connections must be on or off")],
                },
                "seed" => match value.parse::<u64>() {
                    Ok(seed) => options.seed = seed,
                    _ => return vec![error("seed must be an unsigned 64-bit number")],
                },
                _ => {
                    return vec![error(
                        "mcts supports simulations, softtime, exploration, strategy, rave, rollout, knowledge, connections, and seed",
                    )];
                }
            }
            index += 2;
        }
        let report = self.searcher.search(self.position, options);
        let best = report
            .best_move
            .map_or_else(|| "none".to_owned(), |mv| mv.to_string());
        let output = format!(
            "bestmove {best} simulations {} nodes {} score {:.4}",
            report.simulations, report.tree_nodes, report.expected_score,
        );
        self.last_decision = Some(report);
        vec![output]
    }
}

fn parse_setup(words: &[&str]) -> Result<(BoardSize, SwapRule, usize), &'static str> {
    let mut size = BoardSize::default();
    let mut swap = SwapRule::Enabled;
    let mut index = 0;
    while index < words.len() {
        match words[index] {
            "moves" => break,
            "size" => {
                let Some(value) = words.get(index + 1) else {
                    return Err("size needs a value");
                };
                size = value
                    .parse::<u8>()
                    .ok()
                    .and_then(|value| BoardSize::new(value).ok())
                    .ok_or("size must be from 9 through 24")?;
            }
            "swap" => {
                let Some(value) = words.get(index + 1) else {
                    return Err("swap needs on or off");
                };
                swap = match *value {
                    "on" => SwapRule::Enabled,
                    "off" => SwapRule::Disabled,
                    _ => return Err("swap must be on or off"),
                };
            }
            _ => return Err("setup supports size and swap"),
        }
        index += 2;
    }
    Ok((size, swap, index))
}

fn write_report(json: &mut String, report: &MctsReport) {
    write!(
        json,
        "{{\"bestMove\":{},\"algorithm\":\"uct\",\"strategy\":\"{}\",\"raveEquivalence\":{:.1},\"rolloutPolicy\":\"{}\",\"knowledgeThreshold\":{},\"virtualConnectionsEnabled\":{},\"simulations\":{},\"treeNodes\":{},\"rootVisits\":{},\"rolloutMoves\":{},\"bridgeReplies\":{},\"knowledgeNodes\":{},\"prunedMoves\":{},\"mustPlayNodes\":{},\"rootPrunedMoves\":{},\"rootMustPlayMoves\":{},\"virtualConnections\":{},\"semiConnections\":{},\"connectionSearchTruncatedNodes\":{},\"provenNodes\":{},\"solverPropagations\":{},\"provenWinner\":{},\"proofDistance\":{},\"expectedScore\":{:.4},\"elapsedMs\":{},\"rootMoves\":[",
        report
            .best_move
            .map_or_else(|| "null".to_owned(), |mv| format!("\"{mv}\"")),
        report.strategy.as_str(),
        report.rave_equivalence,
        report.rollout_policy.as_str(),
        report
            .knowledge_policy
            .min_visits()
            .map_or_else(|| "null".to_owned(), |visits| visits.to_string()),
        report.virtual_connections_enabled,
        report.simulations,
        report.tree_nodes,
        report.root_visits,
        report.rollout_moves,
        report.bridge_replies,
        report.knowledge_nodes,
        report.pruned_moves,
        report.must_play_nodes,
        report.root_pruned_moves,
        report.root_must_play_moves,
        report.virtual_connections,
        report.semi_connections,
        report.connection_search_truncated_nodes,
        report.proven_nodes,
        report.solver_propagations,
        optional_seat_json(report.proven_winner),
        report
            .proof_distance
            .map_or_else(|| "null".to_owned(), |distance| distance.to_string()),
        report.expected_score,
        report.elapsed_ms,
    )
    .expect("writing to String cannot fail");
    for (index, stats) in report.root_moves.iter().enumerate() {
        if index > 0 {
            json.push(',');
        }
        write!(
            json,
            "{{\"move\":\"{}\",\"visits\":{},\"expectedScore\":{:.4},\"raveVisits\":{},\"raveExpectedScore\":{:.4},\"provenWinner\":{},\"proofDistance\":{}}}",
            stats.mv,
            stats.visits,
            stats.expected_score,
            stats.rave_visits,
            stats.rave_expected_score,
            optional_seat_json(stats.proven_winner),
            stats
                .proof_distance
                .map_or_else(|| "null".to_owned(), |distance| distance.to_string()),
        )
        .expect("writing to String cannot fail");
    }
    json.push_str("]}");
}

fn result_name(result: GameResult) -> &'static str {
    match result {
        GameResult::Ongoing => "ongoing",
        GameResult::Win(_) => "win",
    }
}

fn winner_seat_json(result: GameResult) -> &'static str {
    match result {
        GameResult::Win(Seat::One) => "\"one\"",
        GameResult::Win(Seat::Two) => "\"two\"",
        GameResult::Ongoing => "null",
    }
}

fn optional_seat_json(seat: Option<Seat>) -> &'static str {
    match seat {
        Some(Seat::One) => "\"one\"",
        Some(Seat::Two) => "\"two\"",
        None => "null",
    }
}

fn winner_color_json(position: Position) -> &'static str {
    match position.result() {
        GameResult::Win(seat) => match position.color_for_seat(seat) {
            Color::Red => "\"R\"",
            Color::Blue => "\"B\"",
        },
        GameResult::Ongoing => "null",
    }
}

const fn color_code(color: Color) -> &'static str {
    match color {
        Color::Red => "R",
        Color::Blue => "B",
    }
}

fn error(message: &str) -> String {
    format!("info string error {message}")
}