ai-connect4 0.1.1

A compact Connect Four rules and alpha-beta search engine
Documentation
use ai_connect4::{
    Algorithm, GameResult, Move, Position, SEARCH_PRESETS, SearchLimits, Side, iterative_search,
    search_preset,
};
use std::env;
use std::process::ExitCode;
use std::time::{Duration, Instant};

#[derive(Clone)]
struct Player {
    name: String,
    limits: SearchLimits,
}

#[derive(Default)]
struct Stats {
    moves: u64,
    nodes: u64,
    depth: u64,
    elapsed: Duration,
}

struct Config {
    a: Player,
    b: Player,
    games: usize,
    opening_plies: usize,
    seed: u64,
    show_games: bool,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(message) => {
            eprintln!("error: {message}\n\n{}", usage());
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<(), String> {
    let config = parse_config()?;
    println!(
        "A: {} (depth {}, {} nodes)\nB: {} (depth {}, {} nodes)\n{} games · {} random opening plies · paired colors · seed {}\n",
        config.a.name,
        config.a.limits.depth,
        config.a.limits.nodes.unwrap_or_default(),
        config.b.name,
        config.b.limits.depth,
        config.b.limits.nodes.unwrap_or_default(),
        config.games,
        config.opening_plies,
        config.seed,
    );

    let mut a_wins = 0;
    let mut b_wins = 0;
    let mut draws = 0;
    let mut total_plies = 0_u64;
    let mut a_stats = Stats::default();
    let mut b_stats = Stats::default();

    for game in 0..config.games {
        let opening_seed = paired_opening_seed(config.seed, game);
        let (mut position, mut history) = opening(opening_seed, config.opening_plies);
        let a_is_red = a_has_first_color(game);

        while position.result() == GameResult::Ongoing {
            let a_to_move = (position.side_to_move() == Side::Red) == a_is_red;
            let (player, stats) = if a_to_move {
                (&config.a, &mut a_stats)
            } else {
                (&config.b, &mut b_stats)
            };
            let started = Instant::now();
            let report = iterative_search(position, Algorithm::TranspositionTable, player.limits);
            stats.elapsed += started.elapsed();
            stats.moves += 1;
            stats.nodes += report.total_nodes;
            stats.depth += u64::from(report.completed_depth);
            let mv = report
                .result
                .best_move
                .ok_or_else(|| "search returned no move in an ongoing game".to_owned())?;
            position
                .make_move(mv)
                .map_err(|error| format!("search returned illegal move {mv}: {error}"))?;
            history.push(mv);
        }

        total_plies += history.len() as u64;
        let outcome = match position.result() {
            GameResult::Win(winner) if (winner == Side::Red) == a_is_red => {
                a_wins += 1;
                "A wins"
            }
            GameResult::Win(_) => {
                b_wins += 1;
                "B wins"
            }
            GameResult::Draw => {
                draws += 1;
                "draw"
            }
            GameResult::Ongoing => unreachable!(),
        };
        if config.show_games {
            let moves = history
                .iter()
                .map(Move::to_string)
                .collect::<Vec<_>>()
                .join(" ");
            println!(
                "{:>3}. A as {} · {:>6} · {:>2} plies · {moves}",
                game + 1,
                if a_is_red { "R" } else { "Y" },
                outcome,
                history.len(),
            );
        } else {
            println!(
                "{:>3}. A as {} · {:>6} · {:>2} plies",
                game + 1,
                if a_is_red { "R" } else { "Y" },
                outcome,
                history.len(),
            );
        }
    }

    println!(
        "\nscore  A {a_wins} · B {b_wins} · draws {draws}\naverage game {:.1} plies",
        total_plies as f64 / config.games as f64,
    );
    print_stats("A", &a_stats);
    print_stats("B", &b_stats);
    Ok(())
}

fn opening(seed: u64, plies: usize) -> (Position, Vec<Move>) {
    let mut random = SplitMix64(seed);
    let mut position = Position::start();
    let mut history = Vec::with_capacity(42);
    for _ in 0..plies {
        let moves: Vec<_> = position.legal_moves().into_iter().collect();
        if moves.is_empty() {
            break;
        }
        let mv = moves[(random.next() as usize) % moves.len()];
        position
            .make_move(mv)
            .expect("opening move was generated as legal");
        history.push(mv);
    }
    (position, history)
}

fn paired_opening_seed(seed: u64, game: usize) -> u64 {
    seed.wrapping_add((game / 2) as u64)
}

fn a_has_first_color(game: usize) -> bool {
    game % 2 == 0
}

fn print_stats(label: &str, stats: &Stats) {
    let moves = stats.moves.max(1);
    println!(
        "{label}      {:>8.0} nodes/move · depth {:>4.1} · {:>6.2} ms/move · {:.2} Mnps",
        stats.nodes as f64 / moves as f64,
        stats.depth as f64 / moves as f64,
        stats.elapsed.as_secs_f64() * 1_000.0 / moves as f64,
        stats.nodes as f64 / stats.elapsed.as_secs_f64().max(0.000_001) / 1_000_000.0,
    );
}

fn parse_config() -> Result<Config, String> {
    parse_config_from(env::args().skip(1).collect())
}

fn parse_config_from(args: Vec<String>) -> Result<Config, String> {
    let mut a_name = "maximum".to_owned();
    let mut b_name = "maximum".to_owned();
    let mut a_depth = None;
    let mut b_depth = None;
    let mut a_nodes = None;
    let mut b_nodes = None;
    let mut games = 8;
    let mut opening_plies = 4;
    let mut seed = 1;
    let mut show_games = false;
    let mut index = 0;
    while index < args.len() {
        if args[index] == "--show-games" {
            show_games = true;
            index += 1;
            continue;
        }
        if args[index] == "--help" || args[index] == "-h" {
            println!("{}", usage());
            std::process::exit(0);
        }
        let value = args
            .get(index + 1)
            .ok_or_else(|| format!("{} needs a value", args[index]))?;
        match args[index].as_str() {
            "--a" => a_name = value.clone(),
            "--b" => b_name = value.clone(),
            "--a-depth" => a_depth = Some(parse(value, "A depth")?),
            "--b-depth" => b_depth = Some(parse(value, "B depth")?),
            "--a-nodes" => a_nodes = Some(parse(value, "A nodes")?),
            "--b-nodes" => b_nodes = Some(parse(value, "B nodes")?),
            "--games" => games = parse(value, "games")?,
            "--opening-plies" => opening_plies = parse(value, "opening plies")?,
            "--seed" => seed = parse(value, "seed")?,
            option => return Err(format!("unknown option {option}")),
        }
        index += 2;
    }
    if !(2..=1_000).contains(&games) || games % 2 != 0 {
        return Err("games must be an even number from 2 through 1000".to_owned());
    }
    if opening_plies > 20 {
        return Err("opening plies must be from 0 through 20".to_owned());
    }

    let mut a = profile(&a_name)?;
    let mut b = profile(&b_name)?;
    if let Some(depth) = a_depth {
        a.limits.depth = depth;
    }
    if let Some(depth) = b_depth {
        b.limits.depth = depth;
    }
    if let Some(nodes) = a_nodes {
        a.limits.nodes = Some(nodes);
    }
    if let Some(nodes) = b_nodes {
        b.limits.nodes = Some(nodes);
    }
    validate(&a)?;
    validate(&b)?;
    Ok(Config {
        a,
        b,
        games,
        opening_plies,
        seed,
        show_games,
    })
}

fn profile(name: &str) -> Result<Player, String> {
    let limits = search_preset(name)
        .ok_or_else(|| format!("unknown profile {name}; use {}", preset_names()))?
        .limits;
    Ok(Player {
        name: name.to_owned(),
        limits,
    })
}

fn preset_names() -> String {
    SEARCH_PRESETS
        .iter()
        .map(|preset| preset.name)
        .collect::<Vec<_>>()
        .join(", ")
}

fn validate(player: &Player) -> Result<(), String> {
    if !(1..=42).contains(&player.limits.depth) {
        return Err(format!("{} depth must be from 1 through 42", player.name));
    }
    if !matches!(player.limits.nodes, Some(1..=10_000_000)) {
        return Err(format!(
            "{} nodes must be from 1 through 10000000",
            player.name
        ));
    }
    Ok(())
}

fn parse<T: std::str::FromStr>(value: &str, label: &str) -> Result<T, String> {
    value
        .parse()
        .map_err(|_| format!("{label} must be a number"))
}

fn usage() -> &'static str {
    "Usage: selfplay [options]\n\n  --a PROFILE          beginner, easy, medium, hard, expert, or maximum\n  --b PROFILE          beginner, easy, medium, hard, expert, or maximum\n  --a-depth N          override A's maximum depth\n  --b-depth N          override B's maximum depth\n  --a-nodes N          override A's node budget\n  --b-nodes N          override B's node budget\n  --games N            even number of games (default 8)\n  --opening-plies N    randomized plies shared by each color pair (default 4)\n  --seed N             deterministic opening seed (default 1)\n  --show-games         print complete move lists"
}

struct SplitMix64(u64);

impl SplitMix64 {
    fn next(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
        let mut value = self.0;
        value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
        value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
        value ^ (value >> 31)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn args(values: &[&str]) -> Vec<String> {
        values.iter().map(|value| (*value).to_owned()).collect()
    }

    fn parse_error(values: &[&str]) -> String {
        parse_config_from(args(values))
            .err()
            .expect("arguments should be rejected")
    }

    #[test]
    fn profiles_and_overrides_are_parsed_without_changing_the_other_player() {
        let config = parse_config_from(args(&[
            "--a",
            "hard",
            "--b",
            "easy",
            "--a-depth",
            "8",
            "--a-nodes",
            "12345",
            "--games",
            "6",
            "--opening-plies",
            "7",
            "--seed",
            "99",
            "--show-games",
        ]))
        .expect("valid arguments");

        assert_eq!(config.a.name, "hard");
        assert_eq!(config.a.limits, SearchLimits::with_nodes(8, 12_345));
        assert_eq!(config.b.name, "easy");
        assert_eq!(config.b.limits, SearchLimits::with_nodes(5, 2_000));
        assert_eq!(config.games, 6);
        assert_eq!(config.opening_plies, 7);
        assert_eq!(config.seed, 99);
        assert!(config.show_games);
    }

    #[test]
    fn invalid_match_shapes_and_search_limits_are_rejected() {
        assert!(parse_error(&["--games", "3"]).contains("even number"));
        assert!(parse_error(&["--opening-plies", "21"]).contains("0 through 20"));
        assert!(parse_error(&["--a-depth", "43"]).contains("1 through 42"));
        assert!(parse_error(&["--a-nodes", "0"]).contains("1 through 10000000"));
        assert!(parse_error(&["--a", "missing"]).contains("unknown profile"));
        assert!(parse_error(&["--missing", "1"]).contains("unknown option"));
    }

    #[test]
    fn each_opening_is_deterministic_and_shared_by_a_color_pair() {
        assert_eq!(paired_opening_seed(41, 0), 41);
        assert_eq!(paired_opening_seed(41, 1), 41);
        assert_eq!(paired_opening_seed(41, 2), 42);
        assert_eq!(paired_opening_seed(u64::MAX, 2), 0);
        assert!(a_has_first_color(0));
        assert!(!a_has_first_color(1));
        let first = opening(41, 8).1;
        let second = opening(41, 8).1;
        assert_eq!(first, second);
    }
}