#![deny(clippy::expect_used)]
#![deny(clippy::indexing_slicing)]
#![deny(clippy::panic)]
#![deny(clippy::unwrap_used)]
use std::{
io::{BufRead, BufReader, Write},
net::{TcpStream, ToSocketAddrs as _},
};
use anyhow::Error;
use clap::{CommandFactory, Parser};
use hnefatafl_copenhagen::{
COPYRIGHT, LONG_VERSION, VERSION_ID,
ai::AI,
game::Game,
play::Plae,
role::Role,
status::Status,
tcp_keep_alive,
utils::{self, choose_ai},
};
use log::{debug, info, trace};
use socket2::{Domain, SockAddr, Socket, Type};
const PORT: &str = ":49152";
#[allow(clippy::struct_excessive_bools)]
#[derive(Parser, Debug)]
#[command(long_version = LONG_VERSION, about = "Copenhagen Hnefatafl AI")]
struct Args {
#[arg(long)]
username: String,
#[arg(default_value = "", long)]
password: String,
#[arg(default_value_t = Role::Attacker, long)]
role: Role,
#[arg(default_value = "hnefatafl.org", long)]
host: String,
#[arg(default_value = "basic", long)]
ai: String,
#[arg(long)]
debug: bool,
#[arg(long)]
seconds: Option<u64>,
#[arg(long)]
depth: Option<u8>,
#[arg(long)]
join_game: Option<u128>,
#[arg(long)]
sequential: bool,
#[arg(long)]
systemd: bool,
#[arg(long)]
man: bool,
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
utils::init_logger("hnefatafl_ai", args.debug, args.systemd);
if args.man {
let mut buffer: Vec<u8> = Vec::default();
let cmd = Args::command().name("hnefatafl-ai").long_version(None);
let man = clap_mangen::Man::new(cmd).date("2025-06-23");
man.render(&mut buffer)?;
write!(buffer, "{COPYRIGHT}")?;
std::fs::write("hnefatafl-ai.1", buffer)?;
return Ok(());
}
let mut username = "ai-".to_string();
username.push_str(&args.username);
let mut address_string = args.host.clone();
address_string.push_str(PORT);
let mut is_ipv6 = false;
let mut socket_address = None;
let socket_addresses = address_string.to_socket_addrs()?;
for address in socket_addresses.clone() {
if address.is_ipv6() {
socket_address = Some(address);
is_ipv6 = true;
break;
}
}
if !is_ipv6 {
for address in socket_addresses {
if address.is_ipv4() {
socket_address = Some(address);
break;
}
}
}
let socket_address = socket_address.ok_or_else(|| {
anyhow::Error::msg(format!(
"There is no IP address for the host: {address_string}"
))
})?;
let address: SockAddr = socket_address.into();
let keep_alive = tcp_keep_alive();
let domain_type = if is_ipv6 { Domain::IPV6 } else { Domain::IPV4 };
let socket = Socket::new(domain_type, Type::STREAM, None)?;
socket.set_tcp_keepalive(&keep_alive)?;
socket.connect(&address).unwrap_or_else(|error| {
eprintln!("socket.connect {address_string}: {error}");
});
info!("connected to {socket_address}");
let mut tcp: TcpStream = socket.into();
let mut reader = BufReader::new(tcp.try_clone()?);
tcp.write_all(format!("{VERSION_ID} login {username} {}\n", args.password).as_bytes())?;
let mut buf = String::new();
reader.read_line(&mut buf)?;
assert_eq!(buf, "= login\n");
buf.clear();
if let Some(game_id) = args.join_game {
tcp.write_all(format!("join_game_pending {game_id}\n").as_bytes())?;
let ai = choose_ai(&args.ai, args.seconds, args.depth, args.sequential)?;
handle_messages(ai, game_id, &mut reader, &mut tcp)?;
} else {
loop {
new_game(&mut tcp, args.role, &mut reader, &mut buf)?;
info!("{buf}");
let message: Vec<_> = buf.split_ascii_whitespace().collect();
let Some(message) = message.get(3) else {
return Err(anyhow::Error::msg("Expecting message[3] to be a game_id"));
};
let game_id = message.parse()?;
buf.clear();
wait_for_challenger(&mut reader, &mut buf, &mut tcp, game_id)?;
let ai = choose_ai(&args.ai, args.seconds, args.depth, args.sequential)?;
handle_messages(ai, game_id, &mut reader, &mut tcp)?;
}
}
Ok(())
}
fn new_game(
tcp: &mut TcpStream,
role: Role,
reader: &mut BufReader<TcpStream>,
buf: &mut String,
) -> anyhow::Result<()> {
tcp.write_all(format!("new_game {role} rated fischer 900000 10 11\n").as_bytes())?;
loop {
reader.read_line(buf)?;
if buf.trim().is_empty() {
return Err(Error::msg("the TCP stream has closed"));
}
let message: Vec<_> = buf.split_ascii_whitespace().collect();
if let Some(message) = message.get(1)
&& *message == "new_game"
{
return Ok(());
}
buf.clear();
}
}
fn wait_for_challenger(
reader: &mut BufReader<TcpStream>,
buf: &mut String,
tcp: &mut TcpStream,
game_id: u128,
) -> anyhow::Result<()> {
loop {
reader.read_line(buf)?;
if buf.trim().is_empty() {
return Err(Error::msg("the TCP stream has closed"));
}
let message: Vec<_> = buf.split_ascii_whitespace().collect();
if Some("challenge_requested") == message.get(1).copied() {
info!("{message:?}");
buf.clear();
break;
}
buf.clear();
}
tcp.write_all(format!("join_game {game_id}\n").as_bytes())?;
Ok(())
}
fn handle_messages(
mut ai: Box<dyn AI>,
game_id: u128,
reader: &mut BufReader<TcpStream>,
tcp: &mut TcpStream,
) -> anyhow::Result<()> {
let mut game = Game::default();
debug!("{game}\n");
let mut buf = String::new();
loop {
reader.read_line(&mut buf)?;
if buf.trim().is_empty() {
return Err(Error::msg("the TCP stream has closed"));
}
let mut message: Vec<_> = buf.split_ascii_whitespace().collect();
if Some("generate_move") == message.get(2).copied() {
let generate_move = ai.generate_move(&mut game)?;
tcp.write_all(format!("game {game_id} {}\n", generate_move.play).as_bytes())?;
debug!("{game}");
info!("{generate_move}");
trace!("{}", generate_move.heat_map);
if game.status != Status::Ongoing {
return Ok(());
}
} else if Some("play") == message.get(2).copied() {
let words = message.split_off(2);
let play = Plae::try_from(words)?;
ai.play(&mut game, &play)?;
debug!("{game}\n");
if game.status != Status::Ongoing {
return Ok(());
}
} else if Some("game_over") == message.get(1).copied() {
return Ok(());
}
buf.clear();
}
}