use std::{path::PathBuf, time::Duration};
use clap::{Args, Parser, Subcommand};
use crate::{board::Outcome, claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer, network::Network, neural_player::NeuralPlayer, player::Player};
mod board;
mod player;
mod game;
#[path = "board-view.rs"]
mod board_view;
#[path = "local-player.rs"]
mod local_player;
#[path = "claude-player.rs"]
mod claude_player;
mod bench;
mod network;
#[path = "neural-player.rs"]
mod neural_player;
mod train;
/// Tigers and Sheep, a hunt game on a 5x5 board. With no command, plays a game in the terminal.
#[derive(Parser)]
#[command(version, args_conflicts_with_subcommands = true)]
struct Cli
{
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
play: PlayArgs,
}
#[derive(Subcommand)]
enum Command
{
/// Play a game in the terminal (the default)
Play(PlayArgs),
/// Time the computer player's search on fixed positions, without the TUI
Bench
{
/// Search depth
#[arg(default_value_t = 6)]
depth: usize,
},
/// Train a network by self-play
///
/// Starts from `--from`, or else the newest gen-NNN.bin in the out dir, or else random weights.
Train(TrainArgs),
}
#[derive(Args)]
struct PlayArgs
{
/// Who plays the sheep: `human`, `claude`, or a weights file for a neural player
#[arg(short = 'S', long, default_value = "claude", value_parser = parse_player)]
sheep: PlayerKind,
/// Who plays the tigers: `human`, `claude`, or a weights file for a neural player
#[arg(short = 'T', long, default_value = "claude", value_parser = parse_player)]
tiger: PlayerKind,
/// Seconds each computer player may think per move
#[arg(short, long, default_value_t = 2.0)]
time: f64,
}
#[derive(Clone)]
enum PlayerKind
{
Human,
Claude,
Neural(PathBuf),
}
fn parse_player(s: &str) -> Result<PlayerKind, String>
{
match s
{
"human" => Ok(PlayerKind::Human),
"claude" => Ok(PlayerKind::Claude),
path if PathBuf::from(path).is_file() => Ok(PlayerKind::Neural(path.into())),
_ => Err(format!("expected `human`, `claude` or a weights file, but `{s}` is none of these")),
}
}
#[derive(Args)]
struct TrainArgs
{
/// Generations to train
#[arg(default_value_t = train::Config::default().generations)]
generations: usize,
/// Self-play games per generation
#[arg(default_value_t = train::Config::default().games_per_generation)]
games: usize,
/// Where each generation's weights are saved
#[arg(default_value = "weights")]
out_dir: PathBuf,
/// Start from this weights file
#[arg(long)]
from: Option<PathBuf>,
/// Search depth for every move in self-play and matches
#[arg(long, default_value_t = train::Config::default().depth)]
depth: usize,
/// A game this many plies long is a draw
#[arg(long, default_value_t = train::Config::default().ply_cap)]
ply_cap: usize,
/// Plies of random moves at the start of each self-play game
#[arg(long, default_value_t = train::Config::default().random_plies)]
random_plies: usize,
/// Chance of a random move after that
#[arg(long, default_value_t = train::Config::default().epsilon)]
epsilon: f32,
/// TD(λ): 1 trains on each game's result alone, smaller trusts later search scores more
#[arg(long, default_value_t = train::Config::default().lambda)]
lambda: f32,
/// Train on the samples of this many of the latest generations
#[arg(long, default_value_t = train::Config::default().buffer_generations)]
buffer_generations: usize,
/// Don't balance positions from sheep-win and tiger-win games
#[arg(long)]
no_balance: bool,
/// Passes over the samples per generation
#[arg(long, default_value_t = train::Config::default().epochs)]
epochs: usize,
/// Positions per optimiser step
#[arg(long, default_value_t = train::Config::default().batch_size)]
batch_size: usize,
/// Adam's learning rate
#[arg(long, default_value_t = train::Config::default().learning_rate)]
learning_rate: f32,
/// Games against ClaudePlayer, and against the generation that last played them
#[arg(long, default_value_t = train::Config::default().match_games)]
match_games: usize,
/// Play those matches only every this many generations
#[arg(long, default_value_t = train::Config::default().match_every)]
match_every: usize,
}
impl From<TrainArgs> for train::Config
{
fn from(args: TrainArgs) -> Self
{
Self
{
generations: args.generations,
games_per_generation: args.games,
out_dir: args.out_dir,
from: args.from,
depth: args.depth,
ply_cap: args.ply_cap,
random_plies: args.random_plies,
epsilon: args.epsilon,
lambda: args.lambda,
buffer_generations: args.buffer_generations,
balance_outcomes: !args.no_balance,
epochs: args.epochs,
batch_size: args.batch_size,
learning_rate: args.learning_rate,
match_games: args.match_games,
match_every: args.match_every,
}
}
}
fn main() -> std::io::Result<()>
{
let cli = Cli::parse();
match cli.command
{
Some(Command::Bench { depth }) =>
{
bench::run(depth);
Ok(())
}
Some(Command::Train(args)) => train::run(&args.into()),
Some(Command::Play(args)) => play(args),
None => play(cli.play),
}
}
fn make_player(kind: PlayerKind, time_limit: Duration) -> std::io::Result<Box<dyn Player>>
{
Ok(match kind
{
PlayerKind::Human => Box::new(LocalPlayer::default()),
PlayerKind::Claude => Box::new(ClaudePlayer::new(time_limit)),
PlayerKind::Neural(path) => Box::new(NeuralPlayer::new(Network::load(&path)?, time_limit, 20)),
})
}
fn play(args: PlayArgs) -> std::io::Result<()>
{
let time_limit = Duration::from_secs_f64(args.time);
let mut sheep = make_player(args.sheep, time_limit)?;
let mut tiger = make_player(args.tiger, time_limit)?;
let mut game = GameHandler::default();
match game.play_game(sheep.as_mut(), tiger.as_mut())?
{
Some(Outcome::Tiger) => println!("Tigers win!"),
Some(Outcome::Sheep) => println!("Sheep win!"),
Some(Outcome::Draw) => println!("Draw by repetition."),
None => (),
}
Ok(())
}