#![cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::game::{
io::{self, SaveInfo, SaveMeta},
moves::legal_moves,
rules::Variant,
state::GameState,
};
use crate::search::{beam, checkpoint, nrpa, systematic, SearchState};
#[derive(Parser)]
#[command(
name = "morpion-solitaire",
version,
about = "Morpion Solitaire — a GUI and a command-line solver",
subcommand_negates_reqs = true
)]
pub struct Cli {
#[arg(long, global = true, default_value = "5T", value_name = "5T|5D|4T|4D")]
variant: String,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)] enum Command {
Gui,
Search(SearchArgs),
Replay(ReplayArgs),
Convert(ConvertArgs),
Records(RecordsArgs),
Bench(BenchArgs),
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum AlgoArg {
Nrpa,
Systematic,
Perturbation,
Beam,
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum OverflowPolicy {
Stop,
Continue,
}
#[derive(Copy, Clone, ValueEnum)]
enum Format {
Ascii,
Msr,
Json,
Pentasol,
Svg,
Png,
}
#[derive(Args)]
struct SearchArgs {
#[arg(long, value_enum, default_value_t = AlgoArg::Nrpa)]
algo: AlgoArg,
#[arg(long, default_value_t = 3)]
level: usize,
#[arg(long, default_value_t = 64)]
width: usize,
#[arg(long, value_name = "FILE")]
warm: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
from: Option<PathBuf>,
#[arg(long)]
threads: Option<usize>,
#[arg(long)]
seed: Option<u64>,
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
time: Option<Duration>,
#[arg(long, value_name = "N")]
target_score: Option<u32>,
#[arg(long, value_name = "N")]
max_nodes: Option<u64>,
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
checkpoint_interval: Option<Duration>,
#[arg(long, value_name = "FILE")]
resume: Option<PathBuf>,
#[arg(long, short = 'o', value_name = "FILE")]
out: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = OverflowPolicy::Stop)]
on_overflow: OverflowPolicy,
#[arg(long)]
description: Option<String>,
#[arg(long)]
author: Option<String>,
#[arg(long = "tag", value_delimiter = ',')]
tags: Vec<String>,
#[arg(long, short = 'q')]
quiet: bool,
}
#[derive(Args)]
struct ReplayArgs {
file: PathBuf,
#[arg(long)]
numbers: bool,
#[arg(long, short = 'q')]
quiet: bool,
}
#[derive(Args)]
struct ConvertArgs {
file: PathBuf,
#[arg(long, value_enum, default_value_t = Format::Ascii)]
to: Format,
#[arg(long)]
numbers: bool,
#[arg(long, short = 'o', value_name = "FILE")]
out: Option<PathBuf>,
}
#[derive(Args)]
struct RecordsArgs {
#[arg(long)]
category: Option<String>,
}
#[derive(Args)]
struct BenchArgs {
#[arg(long, value_enum, default_value_t = AlgoArg::Nrpa)]
algo: AlgoArg,
#[arg(long, default_value_t = 3)]
level: usize,
#[arg(long, value_name = "DURATION", value_parser = parse_duration, default_value = "10s")]
time: Duration,
}
pub fn dispatch() -> Option<()> {
let cli = Cli::parse();
let variant = parse_variant_or_exit(&cli.variant);
match cli.command {
None | Some(Command::Gui) => None, Some(cmd) => {
let code = match run(cmd, variant) {
Ok(()) => 0,
Err(e) => {
eprintln!("error: {e}");
1
}
};
std::process::exit(code);
}
}
}
fn run(cmd: Command, variant: Variant) -> Result<(), String> {
match cmd {
Command::Gui => unreachable!(),
Command::Search(a) => cmd_search(a, variant),
Command::Replay(a) => cmd_replay(a, variant),
Command::Convert(a) => cmd_convert(a, variant),
Command::Records(a) => cmd_records(a),
Command::Bench(a) => cmd_bench(a, variant),
}
}
fn cmd_search(a: SearchArgs, cli_variant: Variant) -> Result<(), String> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
if let Some(n) = a.threads {
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(n.max(1))
.build_global();
}
let search = SearchState::new();
let t0 = Instant::now();
let interrupted = Arc::new(AtomicBool::new(false));
{
let flag = interrupted.clone();
let s = search.clone();
let _ = ctrlc::set_handler(move || {
flag.store(true, Ordering::Relaxed);
s.running.store(false, Ordering::Relaxed);
});
}
search.running.store(true, Ordering::Relaxed);
let (variant, method) = spawn_search(&a, cli_variant, &search)?;
if !a.quiet {
eprintln!(
"search {} — {}; stop: {}",
method,
variant.name(),
stop_criteria_desc(&a)
);
}
let mut last_ckpt = Instant::now();
loop {
let best = search.best_score.load(Ordering::Relaxed);
let nodes = search.nodes_explored.load(Ordering::Relaxed);
if crate::game::board::GRID_OVERFLOW.swap(false, Ordering::Relaxed) {
handle_overflow(&a, variant, best);
if matches!(a.on_overflow, OverflowPolicy::Stop) {
search.running.store(false, Ordering::Relaxed);
}
}
let stop = !search.running.load(Ordering::Relaxed)
|| interrupted.load(Ordering::Relaxed)
|| a.target_score.is_some_and(|t| best >= t)
|| a.max_nodes.is_some_and(|m| nodes >= m)
|| a.time.is_some_and(|d| t0.elapsed() >= d);
if stop {
break;
}
if let Some(iv) = a.checkpoint_interval {
if last_ckpt.elapsed() >= iv {
drive_checkpoint(a.algo, variant, &search);
last_ckpt = Instant::now();
}
}
if !a.quiet {
let secs = t0.elapsed().as_secs_f64().max(1e-9);
let line = format!(
"score={best:>3} nodes={nodes:>12} {:>8.0} n/s {:>5.0}s",
nodes as f64 / secs,
t0.elapsed().as_secs_f64()
);
eprint!("\r {line} ");
use std::io::Write as _;
let _ = std::io::stderr().flush();
}
std::thread::sleep(Duration::from_millis(200));
}
search.running.store(false, Ordering::Relaxed);
if !a.quiet {
eprintln!();
}
let best_seq = search.best_sequence.read().unwrap().clone();
if best_seq.is_empty() {
return Err("no game found".to_owned());
}
let mut state = GameState::new(variant);
for mv in &best_seq {
state.apply(*mv);
}
let meta = SaveMeta {
description: a.description.clone(),
author: a.author.clone(),
source: None,
transcribed_by: None,
tool: Some(env!("CARGO_PKG_NAME").to_owned()),
method: Some(method),
seed: a.seed,
nodes_explored: Some(search.nodes_explored.load(Ordering::Relaxed)),
elapsed_secs: Some(t0.elapsed().as_secs_f64()),
tags: a.tags.clone(),
};
let blob =
io::export_save_with_meta(&state, io::unix_now(), &meta).map_err(|e| e.to_string())?;
emit(a.out.as_deref(), &blob)?;
let score = state.score();
if interrupted.load(Ordering::Relaxed) {
eprintln!("best: {score} moves (interrupted)");
} else {
eprintln!("best: {score} moves");
}
Ok(())
}
fn spawn_search(
a: &SearchArgs,
cli_variant: Variant,
search: &Arc<SearchState>,
) -> Result<(Variant, String), String> {
let level = a.level;
let width = a.width;
if let Some(path) = &a.resume {
let text = read_to_string(path)?;
let cp = io::import_checkpoint(&text)?;
let variant = cp.variant;
let algo_name = cp.algo.clone();
let display = format!("resume:{algo_name}");
let s = search.clone();
std::thread::spawn(move || match algo_name.as_str() {
"systematic" => systematic::resume(s, cp),
"perturbation" => nrpa::resume_perturbation(s, level, cp.variant, cp.frontier),
_ => nrpa::resume(s, cp, level),
});
return Ok((variant, display));
}
let warm_seq = match &a.warm {
Some(p) => Some(load_game(p, cli_variant)?.0.history),
None => None,
};
let from_state = match &a.from {
Some(p) => Some(load_game(p, cli_variant)?.0),
None => None,
};
let variant = from_state
.as_ref()
.map(|s| s.variant)
.unwrap_or(cli_variant);
let s = search.clone();
let method = match a.algo {
AlgoArg::Systematic => "systematic".to_owned(),
AlgoArg::Beam => format!("beam w={width}"),
AlgoArg::Perturbation => format!("perturbation L{level}"),
AlgoArg::Nrpa if warm_seq.is_some() => format!("nrpa-seeded L{level}"),
AlgoArg::Nrpa => format!("nrpa L{level}"),
};
match a.algo {
AlgoArg::Perturbation => {
let seed = from_state.map(|st| st.history).unwrap_or_default();
std::thread::spawn(move || nrpa::run_perturbation(s, level, seed, variant));
}
AlgoArg::Systematic => {
let initial = from_state.unwrap_or_else(|| GameState::new(variant));
std::thread::spawn(move || systematic::run(&initial, s));
}
AlgoArg::Beam => {
let initial = from_state.unwrap_or_else(|| GameState::new(variant));
std::thread::spawn(move || beam::run(&initial, s, width));
}
AlgoArg::Nrpa => {
let initial = from_state.unwrap_or_else(|| GameState::new(variant));
match warm_seq {
Some(seq) => {
std::thread::spawn(move || {
nrpa::run_warm(&initial, s, level, &seq, nrpa::WARM_ITERS)
});
}
None => {
std::thread::spawn(move || nrpa::run(&initial, s, level));
}
}
}
}
Ok((variant, method))
}
fn cmd_replay(a: ReplayArgs, variant: Variant) -> Result<(), String> {
let (state, info) = load_game(&a.file, variant)?;
let mut check = GameState::new(state.variant);
for (i, mv) in state.history.iter().enumerate() {
let legal = legal_moves(&check);
if !legal.iter().any(|m| m.pos == mv.pos && m.line == mv.line) {
return Err(format!(
"illegal move #{} at ({},{}) — the game is not valid",
i + 1,
mv.pos.0,
mv.pos.1
));
}
check.apply(*mv);
}
if !a.quiet {
print_info(&state, &info);
print!("{}", ascii_board(&state, a.numbers));
}
let avail = legal_moves(&check).len();
let status = if avail == 0 {
"terminal".to_owned()
} else {
format!("non-terminal, {avail} moves available")
};
println!(
"OK — {} legal moves, {} ({status})",
check.score(),
state.variant.name()
);
Ok(())
}
fn cmd_convert(a: ConvertArgs, variant: Variant) -> Result<(), String> {
use crate::render::{embed_msr_png, embed_msr_svg, to_png, to_svg, RenderOpts};
let (state, info) = load_game(&a.file, variant)?;
let meta = SaveMeta {
description: info.description,
author: info.author,
source: info.source,
transcribed_by: info.transcribed_by,
tool: info.tool,
method: info.method,
seed: info.seed,
nodes_explored: info.nodes_explored,
elapsed_secs: info.elapsed_secs,
tags: info.tags,
};
let record =
io::export_save_with_meta(&state, io::unix_now(), &meta).map_err(|e| e.to_string());
let opts = RenderOpts { numbers: true };
let text = match a.to {
Format::Ascii => ascii_board(&state, a.numbers),
Format::Msr => record?,
Format::Json => {
io::export_json_with_meta(&state, io::unix_now(), &meta).map_err(|e| e.to_string())?
}
Format::Pentasol => {
if state.variant.len() != 5 {
return Err("the Pentasol format only covers 5T and 5D".to_owned());
}
io::export_pentasol(&state)
}
Format::Svg => embed_msr_svg(&to_svg(&state, &opts), &record?),
Format::Png => {
let path = a.out.as_deref().ok_or("PNG output requires -o <FILE>")?;
let png = embed_msr_png(&to_png(&state, &opts)?, &record?);
std::fs::write(path, png).map_err(|e| format!("writing {}: {e}", path.display()))?;
eprintln!("wrote: {}", path.display());
return Ok(());
}
};
emit(a.out.as_deref(), &text)
}
fn cmd_records(a: RecordsArgs) -> Result<(), String> {
let root = checkpoint::data_dir().join("records");
if !root.exists() {
println!("(no records saved under {})", root.display());
return Ok(());
}
let mut cats: Vec<PathBuf> = match &a.category {
Some(c) => vec![root.join(c)],
None => std::fs::read_dir(&root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_dir())
.collect(),
};
cats.sort();
for cat in cats {
let name = cat.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let mut files: Vec<(u32, PathBuf)> = std::fs::read_dir(&cat)
.into_iter()
.flatten()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "msr"))
.filter_map(|p| {
let txt = std::fs::read_to_string(&p).ok()?;
let (st, _) = io::import_save_with_info(&txt).ok()?;
Some((st.score() as u32, p))
})
.collect();
files.sort_by_key(|f| std::cmp::Reverse(f.0));
if files.is_empty() {
continue;
}
println!("{name} ({} files) — best: {}", files.len(), files[0].0);
for (score, p) in files.iter().take(5) {
println!(" {score:>3} {}", p.file_name().unwrap().to_string_lossy());
}
}
Ok(())
}
fn cmd_bench(a: BenchArgs, variant: Variant) -> Result<(), String> {
let search = SearchState::new();
let s = search.clone();
let level = a.level;
let initial = GameState::new(variant);
match a.algo {
AlgoArg::Systematic => std::thread::spawn(move || systematic::run(&initial, s)),
AlgoArg::Beam => std::thread::spawn(move || beam::run(&initial, s, 64)),
_ => std::thread::spawn(move || nrpa::run(&initial, s, level)),
};
let t0 = Instant::now();
std::thread::sleep(a.time);
search.running.store(false, Ordering::Relaxed);
let nodes = search.nodes_explored.load(Ordering::Relaxed);
let best = search.best_score.load(Ordering::Relaxed);
let secs = t0.elapsed().as_secs_f64();
let algo = match a.algo {
AlgoArg::Systematic => "systematic",
AlgoArg::Beam => "beam",
_ => "nrpa",
};
println!(
"{} {algo}: {nodes} nodes in {secs:.1}s = {:.0} n/s; best {best}",
variant.name(),
nodes as f64 / secs
);
Ok(())
}
fn handle_overflow(a: &SearchArgs, variant: Variant, best: u32) {
let grid = crate::game::board::GRID;
eprintln!("\n⚠ GRID OVERFLOW {grid}×{grid} (at {best} moves) — widen `Row` in board.rs.");
let _ = (a, variant);
}
fn drive_checkpoint(algo: AlgoArg, variant: Variant, search: &Arc<SearchState>) {
match algo {
AlgoArg::Systematic | AlgoArg::Perturbation => {
search.checkpoint_requested.store(true, Ordering::Relaxed)
}
AlgoArg::Nrpa => nrpa::save_checkpoint(variant, search),
AlgoArg::Beam => {}
}
}
fn stop_criteria_desc(a: &SearchArgs) -> String {
let mut parts = Vec::new();
if let Some(d) = a.time {
parts.push(format!("{}s", d.as_secs()));
}
if let Some(t) = a.target_score {
parts.push(format!("score≥{t}"));
}
if let Some(m) = a.max_nodes {
parts.push(format!("{m} nodes"));
}
if parts.is_empty() {
"Ctrl-C".to_owned()
} else {
parts.join(" or ")
}
}
fn load_game(path: &Path, variant: Variant) -> Result<(GameState, SaveInfo), String> {
let text = read_to_string(path)?;
let t = text.trim_start();
if t.starts_with("MS1:") || t.starts_with('{') {
io::import_save_with_info(&text)
} else {
io::import_pentasol(&text, variant).map(|s| (s, SaveInfo::default()))
}
}
fn print_info(state: &GameState, info: &SaveInfo) {
let avail = legal_moves(state).len();
let status = if avail == 0 {
"terminal".to_owned()
} else {
format!("{avail} moves available")
};
println!("variant: {}", state.variant.name());
println!("score: {} ({status})", state.score());
if let Some(p) = &info.producer {
println!("producer: {p}");
}
if let Some(d) = &info.saved_at {
println!("date: {d}");
}
if let Some(d) = &info.description {
println!("description: {d}");
}
if let Some(d) = &info.author {
println!("author: {d}");
}
if let Some(d) = &info.method {
println!("method: {d}");
}
if let Some(d) = &info.source {
println!("source: {d}");
}
if let Some(d) = info.seed {
println!("seed: {d}");
}
if let Some(d) = info.nodes_explored {
println!("nodes: {d}");
}
if let Some(d) = info.elapsed_secs {
println!("elapsed (s): {d:.1}");
}
if !info.tags.is_empty() {
println!("tags: {}", info.tags.join(", "));
}
}
fn ascii_board(state: &GameState, numbers: bool) -> String {
let Some((min_x, min_y, max_x, max_y)) = state.bounding_box() else {
return "(empty board)\n".to_owned();
};
let order: std::collections::HashMap<_, usize> = state
.history
.iter()
.enumerate()
.map(|(i, m)| (m.pos, i + 1))
.collect();
let played: std::collections::HashSet<_> = state.history.iter().map(|m| m.pos).collect();
let occupied: std::collections::HashSet<_> = state.board.cells.iter().copied().collect();
let last = state.history.last().map(|m| m.pos);
let mut out = String::new();
for y in (min_y - 1)..=(max_y + 1) {
for x in (min_x - 1)..=(max_x + 1) {
let cell = (x, y);
if numbers && played.contains(&cell) {
out.push_str(&format!("{:>3}", order[&cell]));
} else {
let c = if last == Some(cell) {
'@'
} else if played.contains(&cell) {
'O'
} else if occupied.contains(&cell) {
'+'
} else {
'.'
};
let cellstr = if numbers {
format!(" {c}")
} else {
format!("{c} ")
};
out.push_str(&cellstr);
}
}
out.push('\n');
}
out
}
fn read_to_string(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))
}
fn emit(out: Option<&Path>, content: &str) -> Result<(), String> {
match out {
Some(p) => {
std::fs::write(p, format!("{content}\n"))
.map_err(|e| format!("writing {}: {e}", p.display()))?;
eprintln!("wrote: {}", p.display());
Ok(())
}
None => {
println!("{content}");
Ok(())
}
}
}
fn parse_variant_or_exit(s: &str) -> Variant {
Variant::from_name(s).unwrap_or_else(|| {
eprintln!("unknown variant: {s} (expected 5T, 5D, 4T or 4D)");
std::process::exit(2);
})
}
fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
let (num, mult) = if let Some(n) = s.strip_suffix('h') {
(n, 3600)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60)
} else if let Some(n) = s.strip_suffix('s') {
(n, 1)
} else {
(s, 1)
};
num.trim()
.parse::<f64>()
.map(|v| Duration::from_secs_f64(v * mult as f64))
.map_err(|_| format!("invalid duration: {s}"))
}