diff --git a/.cirrus.yml b/.cirrus.yml index ae990bd..6d83d84 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -3,6 +3,10 @@ task: container: image: rust:slim-bookworm + setup_script: + - apt-get update -y + - apt-get install -y --fix-missing build-essential + build_script: - cargo build --release diff --git a/Cargo.lock b/Cargo.lock index a92d319..6d0ee8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "btoi" version = "0.4.3" @@ -751,6 +761,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -1171,6 +1187,25 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lua-src" +version = "546.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da0daa7eee611a4c30c8f5ee31af55266e26e573971ba9336d2993e2da129b2" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.4+c525bcb" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a10ab4ed12d22cb50ef43ece4f6c5ca594b2d2480019e87facfd422225a9908" +dependencies = [ + "cc", + "which", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1182,9 +1217,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "mime" @@ -1223,6 +1258,32 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mlua" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069264935e816c85884b99e88c8b408d6d92e40ae8760f726c983526a53546b5" +dependencies = [ + "bstr", + "mlua-sys", + "num-traits", + "once_cell", + "rustc-hash", +] + +[[package]] +name = "mlua-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4655631a02e3739d014951291ecfa08db49c4da3f7f8c6f3931ed236af5dd78e" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "pkg-config", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -1500,6 +1561,12 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" @@ -1671,6 +1738,7 @@ dependencies = [ "futures-util", "handlebars", "log", + "mlua", "rand", "serde", "serde_json", @@ -2109,6 +2177,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf3ea8596f3a0dd5980b46430f2058dfe2c36a27ccfbb1845d6fbfcd9ba6e14" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 31c8027..1e730eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ env_logger = "0.10.1" futures-util = "0.3.30" clap = "3.1" webbrowser = "0.8.12" +mlua = { version = "0.9.4", features = ["vendored", "luajit52"] } diff --git a/Dockerfile b/Dockerfile index 6664ca1..42d52dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ # Use a Rust image to build the application FROM rust:slim-bookworm as builder +RUN apt-get update -y && \ + apt-get install -y --fix-missing \ + build-essential + # Copy your manifests COPY ./Cargo.toml ./Cargo.toml COPY ./Cargo.lock ./Cargo.lock @@ -12,6 +16,7 @@ RUN rm src/*.rs # Copy Deps COPY ./src ./src +COPY ./client ./client RUN touch ./src/main.rs RUN touch ./src/lib.rs diff --git a/client/choose_bot.html b/client/choose_bot.html new file mode 100644 index 0000000..44e0a97 --- /dev/null +++ b/client/choose_bot.html @@ -0,0 +1,39 @@ + + + + + + + Rustiator + + + +

Choose Which Bot To Play

+
+ + + + + \ No newline at end of file diff --git a/client/choose_bot_edit.html b/client/choose_bot_edit.html new file mode 100644 index 0000000..ef2d2f1 --- /dev/null +++ b/client/choose_bot_edit.html @@ -0,0 +1,39 @@ + + + + + + + Rustiator + + + +

Choose Which Bot To Edit

+
+ + + + \ No newline at end of file diff --git a/client/css/botButtons.css b/client/css/botButtons.css new file mode 100644 index 0000000..ecfb164 --- /dev/null +++ b/client/css/botButtons.css @@ -0,0 +1,14 @@ +#botButtons { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; /* This makes the div take the full viewport height */ +} +button { + margin: 10px; /* Adds spacing between buttons */ +} + +h2 { + text-align: center; +} \ No newline at end of file diff --git a/client/game.html b/client/game.html index 57b3741..6db8e45 100644 --- a/client/game.html +++ b/client/game.html @@ -1,10 +1,63 @@ + + + Rustiator - + + + +

{{bot_name}}

- + + - + + \ No newline at end of file diff --git a/client/index.html b/client/index.html index dc46417..10c739b 100644 --- a/client/index.html +++ b/client/index.html @@ -19,62 +19,20 @@
- - + + +
+ diff --git a/client/js/utils.js b/client/js/utils.js new file mode 100644 index 0000000..c12b225 --- /dev/null +++ b/client/js/utils.js @@ -0,0 +1,43 @@ + +// Start a new game +// mode: 'playerVsBot' | 'botVsBot' +// botName string +async function startGame(mode, botName) { + var body = { + mode, + botName + }; + console.log("input", JSON.stringify(body)); + var response = await fetch('/new_game', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + console.log("received response"); + + if (response.ok) { + const data = await response.json(); + const game_id = data.game_id; + + if (mode === 'playerVsBot') { + window.location.href = `/game/${game_id}`; + } else if (mode === 'botVsBot') { + console.log(`/spectate/${game_id}`); + window.location.href = `/spectate/${game_id}`; + } + } else { + console.error('Failed to fetch:', response.statusText); + } +} + + +function startBotGame(botName) { + startGame('botVsBot', botName) +} + +function startPlayerGame(botName) { + startGame('playerVsBot', botName) +} \ No newline at end of file diff --git a/client/spectate.html b/client/spectate.html index 0c19813..a358af4 100644 --- a/client/spectate.html +++ b/client/spectate.html @@ -2,9 +2,7 @@ Rustiator - + +
- +
- +
+ + + +
+ \ No newline at end of file diff --git a/src/chess_engine.rs b/src/chess_engine.rs index 7390aaf..ba5090b 100644 --- a/src/chess_engine.rs +++ b/src/chess_engine.rs @@ -1,19 +1,43 @@ use rand::Rng; -use shakmaty::{Move, MoveList, Position}; +use shakmaty::{Move, MoveList}; use std::sync::mpsc::Sender; use std::sync::{Arc, RwLock}; use std::thread; use std::time::Duration; +use anyhow::Result as AnyResult; + use log::{error, info}; use crate::chess_game::ChessGame; use crate::websocket::Notification; -pub trait ChooseMove { - fn choose_move(&self, fen: &str, legal_moves: &MoveList) -> Option; +pub trait ChooseMove: Send + Sync { + /// Choose move has the assumption that the bot can play a move. Handing it a game with no legal moves + /// is invalid. The user should check if the game is over before calling this + fn choose_move(&self, chess_game: &ChessGame, legal_moves: &MoveList) -> AnyResult; +} + +/// Allows us to distinguish between a saved bot and an active bot +/// In addition Clone is not allowed for dyn trait objects and this helps us get around that +pub trait ToChooseMove { + fn to_choose_move(&self) -> Box; } +impl ToChooseMove for C { + fn to_choose_move(&self) -> Box { + Box::new(self.clone()) + } +} + +impl ChooseMove for Box { + fn choose_move(&self, chess_game: &ChessGame, legal_moves: &MoveList) -> AnyResult { + // need to make sure we call the choose_move of the dyn object not the choose_move of the box + (**self).choose_move(chess_game, legal_moves) + } +} + +#[derive(Clone)] pub struct RandomEngine {} impl RandomEngine { @@ -23,15 +47,13 @@ impl RandomEngine { } impl ChooseMove for RandomEngine { - fn choose_move(&self, _chess_game: &str, legal_moves: &MoveList) -> Option { - if legal_moves.is_empty() { - None - } else { - thread::sleep(Duration::from_millis(250)); // Delay for 250 ms - let mut rng = rand::thread_rng(); - let random_index = rng.gen_range(0..legal_moves.len()); - legal_moves.get(random_index).cloned() - } + fn choose_move(&self, _chess_game: &ChessGame, legal_moves: &MoveList) -> AnyResult { + thread::sleep(Duration::from_millis(250)); // Delay for 250 ms + let mut rng = rand::thread_rng(); + let random_index = rng.gen_range(0..legal_moves.len()); + legal_moves.get(random_index).cloned().ok_or_else(|| { + anyhow::anyhow!("Random Bot could not make a move since there are no legal moves") + }) } } @@ -53,15 +75,18 @@ pub fn engine_vs_engine( // Alternate turns between Engine 1 and Engine 2 for engine in [&engine1, &engine2].iter() { - let (legal_moves, fen) = (game.get_legal_moves(), game.fen()); + let legal_moves = game.get_legal_moves(); - if let Some(m) = engine.choose_move(&fen, &legal_moves) { - game.make_move(&m); - send_notification(&sender_channel, game.fen()); - } else { - info!("Game over, other engine wins or stalemate"); - return; - } + let m = match engine.choose_move(&game, &legal_moves) { + Ok(m) => m, + Err(e) => { + error!("Bot threw error while choosing move: {e}"); + return; + } + }; + + game.make_move(&m); + send_notification(&sender_channel, game.fen()); if game.game_over() { return; diff --git a/src/http_server.rs b/src/http_server.rs index b5cd378..2cfe6b5 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -24,18 +24,22 @@ use uuid::Uuid; use shakmaty::uci::Uci; -use crate::chess_engine; +use crate::chess_engine::{RandomEngine, ToChooseMove}; use crate::player_vs_bot::PlayerGame; use crate::websocket::MyWebSocket; -use crate::{chess_engine::engine_vs_engine, chess_game::ChessGame}; +use crate::{chess_engine::engine_vs_engine, chess_game::ChessGame, lua::StatelessLuaBot}; pub type GameMap = DashMap>>; pub type Connection = Addr; pub type SharedState = Arc>>; +pub type SavedBots = DashMap>; +pub type SavedLuaBots = DashMap; #[derive(Deserialize, Debug)] struct NewGameArgs { mode: String, + #[serde(rename = "botName")] + bot_name: String, } #[get("/ping")] @@ -45,10 +49,7 @@ async fn ping() -> impl Responder { #[post("/new_game")] async fn new_game( - app_data: web::Data, - active_processes: web::Data>>>>, - active_player_games: web::Data>, - connections: web::Data>, + app_data: web::Data, req_body: Json, ) -> impl Responder { info!("recieved request!"); @@ -58,15 +59,19 @@ async fn new_game( match req_body.mode.as_str() { "playerVsBot" => { // TODO: allow bot id in request body to select bot to play here - let bot = chess_engine::RandomEngine::new(); - let game = PlayerGame::new(bot); + let Some(saved_bot) = app_data.saved_bots.get(&req_body.bot_name) else { + return HttpResponse::BadRequest() + .body(format!("No bot named {} found", &req_body.bot_name)); + }; + let game = PlayerGame::new(saved_bot.to_choose_move(), &req_body.bot_name); info!("Starting Player vs Bot Game: {new_game_id}"); - active_player_games.insert(new_game_id, game); + app_data.active_player_games.insert(new_game_id, game); } "botVsBot" => { let game = Arc::new(RwLock::new(ChessGame::new())); - let engine1 = Arc::new(crate::chess_engine::RandomEngine::new()); - let engine2 = Arc::new(crate::chess_engine::RandomEngine::new()); + // TODO: add option for second bot so player can select which 2 bots should play each other + let engine1 = Arc::new(RandomEngine::new()); + let engine2 = Arc::new(RandomEngine::new()); let game_clone = game.clone(); let engine1_clone = engine1.clone(); @@ -81,7 +86,9 @@ async fn new_game( }); let new_game_connections: SharedState = Arc::new(RwLock::new(Vec::new())); - connections.insert(new_game_id, new_game_connections.clone()); + app_data + .connections + .insert(new_game_id, new_game_connections.clone()); game_join_set.spawn_blocking(move || { loop { let result = match rx.recv() { @@ -101,11 +108,11 @@ async fn new_game( // not doing anything with set right now, but save in case in the future // we want to do some graceful shutdown logic - let mut active_tasks = active_processes.lock().unwrap(); + let mut active_tasks = app_data.active_processes.lock().unwrap(); active_tasks.insert(new_game_id, game_join_set); info!("inserted game {} into active tasks", new_game_id); - app_data.insert(new_game_id, game); + app_data.active_bot_bot_games.insert(new_game_id, game); } _ => { return HttpResponse::BadRequest().body("Invalid game mode"); @@ -119,14 +126,14 @@ async fn new_game( #[get("/spectate/{uuid}")] async fn spectate_game( - app_data: web::Data, + app_data: web::Data, hb: web::Data>, info: web::Path, ) -> impl Responder { let game_uuid = info.into_inner(); // Fetch the game data - let game_data = match app_data.get(&game_uuid) { + let game_data = match app_data.active_bot_bot_games.get(&game_uuid) { Some(game) => game, None => return HttpResponse::NotFound().body("Game not found"), }; @@ -137,15 +144,11 @@ async fn spectate_game( }; let position = gd_lock.fen(); - let css_content = std::fs::read_to_string("./client/css/chessboard-1.0.0.min.css").unwrap(); - let js_content = std::fs::read_to_string("./client/js/chessboard-1.0.0.js").unwrap(); // Create data to fill the template let data = json!({ "game_id": game_uuid.to_string(), "position": position, - "style": css_content, - "board_js":js_content }); // Render the template with the data @@ -161,10 +164,10 @@ pub async fn ws_index( req: HttpRequest, stream: web::Payload, uuid: web::Path, // Extract UUID from the path - connections: web::Data>, + app_data: web::Data, ) -> Result { info!("New Connection to Game: {}", &uuid); - match connections.get(&uuid) { + match app_data.connections.get(&uuid) { Some(game_conns) => { let game_conns: SharedState = game_conns.clone(); let ws = MyWebSocket::new(game_conns); @@ -202,11 +205,11 @@ struct PlayGameResponse { #[post("/play/{uuid}")] /// Play a given move against a bot pub async fn player_vs_bot( - active_player_games: web::Data>, + app_data: web::Data, req_body: Json, uuid: web::Path, ) -> actix_web::Result> { - let Some(mut game) = active_player_games.get_mut(&uuid) else { + let Some(mut game) = app_data.active_player_games.get_mut(&uuid) else { return Err(actix_web::error::ErrorBadRequest(format!( "No active game for {uuid}" ))); @@ -227,9 +230,9 @@ pub async fn player_vs_bot( match game.play_move(player_move) { Ok(_) => {} Err(e) => { - error!("Error playing move: {}", e); - return Err(actix_web::error::ErrorBadRequest(format!( - "Error Playing Move {}: {e}", + error!("Error playing move: {e:?}"); + return Err(actix_web::error::ErrorInternalServerError(format!( + "Error Playing Move {}: {e:?}", req_body.player_move ))); } @@ -242,25 +245,21 @@ pub async fn player_vs_bot( #[get("/game/{uuid}")] async fn play_game_entry( - active_player_games: web::Data>, + app_data: web::Data, hb: web::Data>, uuid: web::Path, ) -> impl Responder { - let Some(game) = active_player_games.get(&uuid) else { + let Some(game) = app_data.active_player_games.get(&uuid) else { return Err(actix_web::error::ErrorBadRequest(format!( "No active game for {uuid}" ))); }; - let css_content = std::fs::read_to_string("./client/css/chessboard-1.0.0.min.css").unwrap(); - let js_content = std::fs::read_to_string("./client/js/chessboard-1.0.0.js").unwrap(); - // Create data to fill the template let data = json!({ "game_id": uuid.to_string(), "position": game.fen(), - "style": css_content, - "board_js":js_content + "bot_name": game.bot_name, }); // Render the template with the data @@ -272,16 +271,107 @@ async fn play_game_entry( Ok(HttpResponse::Ok().content_type("text/html").body(body)) } +#[derive(Deserialize)] +struct NewBotRequest { + script: String, + #[serde(rename = "botName")] + bot_name: String, +} + +#[derive(Serialize)] +struct NewBotResponse {} + +#[post("/newBot")] +async fn new_bot( + request: Json, + app_data: web::Data, +) -> actix_web::Result> { + let request = request.0; + let bot = StatelessLuaBot::try_new(request.script) + .map_err(|e| actix_web::error::ErrorBadRequest(format!("Failed to initialize Bot: {e}")))?; + + // TODO: for now this will overwrite other saved bots of the same name. + // I think this is what we want for now. But later we will need to block this and + // then add an ability to edit already saved bots + app_data + .saved_bots + .insert(request.bot_name.clone(), Box::new(bot.clone())); + app_data.saved_lua_bots.insert(request.bot_name, bot); + + Ok(Json(NewBotResponse {})) +} + +// #[get("/editBot/{bot_name}")] +async fn edit_bot( + request: actix_web::HttpRequest, + hb: web::Data>, +) -> impl Responder { + // This will be "" in the case where they are making a new bot + let bot_name = request.match_info().query("bot_name"); + // Create data to fill the template + let data = json!({ + "bot_name": bot_name, + }); + + // Render the template with the data + let body = hb.render("editor_template", &data).unwrap_or_else(|err| { + error!("Template rendering error: {}", err); + "Template rendering error".to_string() + }); + + HttpResponse::Ok().content_type("text/html").body(body) +} + +#[get("script/{bot_name}")] +async fn get_script( + bot_name: web::Path, + app_data: web::Data, +) -> impl Responder { + let script = app_data + .saved_lua_bots + .get(&bot_name.to_string()) + .map(|bot| bot.script.clone()) + .unwrap_or_else(|| { + log::warn!( + r#"Tried to get script for nonexistant bot "{bot_name}" defaulting to template"# + ); + include_str!("../client/template.lua").to_string() + }); + script +} + +#[derive(Serialize)] +struct BotNamesResponse { + bot_names: Vec, +} + +#[get("/botNames")] +async fn get_bots(app_data: web::Data) -> Json { + let bot_names = app_data + .saved_bots + .iter() + .map(|entry| entry.key().to_string()) + .collect(); + Json(BotNamesResponse { bot_names }) +} + +pub struct GlobalAppData { + active_processes: Arc>>>, + active_player_games: DashMap, + connections: DashMap, + active_bot_bot_games: GameMap, + saved_bots: SavedBots, + saved_lua_bots: SavedLuaBots, +} + pub async fn start_server(hostname: String, port: u16) -> std::io::Result<()> { // Init an empty hashmap to store all the ongoing processes - let active = Arc::new(Mutex::new(HashMap::>::new())); - let active_tasks = web::Data::new(active); + let active_processes = Arc::new(Mutex::new(HashMap::>::new())); - let player_bot_games = web::Data::new(DashMap::::new()); + let active_player_games = DashMap::::new(); // Initialize an empty hashmap which maps UUID to ChessGame - let games: GameMap = DashMap::new(); - let games_data = web::Data::new(games); + let active_bot_bot_games: GameMap = DashMap::new(); let mut handlebars = Handlebars::new(); handlebars @@ -290,11 +380,27 @@ pub async fn start_server(hostname: String, port: u16) -> std::io::Result<()> { handlebars .register_template_file("game_template", "./client/game.html") .unwrap(); // lmao fix + handlebars + .register_template_file("editor_template", "./client/text_editor.html") + .unwrap(); // lol don't fix? let handlebars_ref = web::Data::new(handlebars); // Active Spectator connections let connections: DashMap = DashMap::new(); - let connections_data = web::Data::new(connections); + + let saved_bots: SavedBots = DashMap::new(); + saved_bots.insert(String::from("RustRandomBot"), Box::new(RandomEngine::new())); + + let saved_lua_bots = DashMap::new(); + + let app_data = web::Data::new(GlobalAppData { + connections, + active_processes, + active_bot_bot_games, + active_player_games, + saved_bots, + saved_lua_bots, + }); info!("Starting server on {}:{}", hostname, port); let allowed_origin = format!("http://{}:{}", &hostname, &port); @@ -314,18 +420,18 @@ pub async fn start_server(hostname: String, port: u16) -> std::io::Result<()> { // routes actually does matter here App::new() .wrap(cors) - .app_data(games_data.clone()) // Add the shared state to the app + .app_data(app_data.clone()) // Add the shared state to the app .app_data(handlebars_ref.clone()) - .app_data(active_tasks.clone()) - .app_data(connections_data.clone()) - .app_data(player_bot_games.clone()) .route("/ws/{uuid}", web::get().to(ws_index)) .service(spectate_game) .service(new_game) .service(player_vs_bot) .service(play_game_entry) + .service(new_bot) + .service(get_script) + .service(get_bots) + .service(web::resource(["/editBot", "/editBot/{bot_name}"]).to(edit_bot)) .service(fs::Files::new("/", "./client/").index_file("index.html")) - // .service(fs::Files::new("/img", "./client/img")) .service( web::scope("/img") .wrap( diff --git a/src/lib.rs b/src/lib.rs index e5b731d..1bb0e5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod browser; pub mod chess_engine; pub mod chess_game; pub mod http_server; +pub mod lua; pub mod player_vs_bot; pub mod types; pub mod websocket; diff --git a/src/lua.rs b/src/lua.rs new file mode 100644 index 0000000..1eaec5c --- /dev/null +++ b/src/lua.rs @@ -0,0 +1,76 @@ +use mlua::{Lua, StdLib}; + +use anyhow::{Context, Result as AnyResult}; + +use shakmaty::{uci::Uci, CastlingMode, MoveList}; + +use crate::{chess_engine::ChooseMove, chess_game::ChessGame}; + +/// Simplest implentation of a lua bot is stateless. +/// Each time a move is chosen this script is evaluated by a new lua vm to generate the bot +/// +/// This means that any state in the bot will not be preserved on successive calls to choose_move +#[derive(Clone)] +pub struct StatelessLuaBot { + pub script: String, +} + +impl StatelessLuaBot { + pub fn try_new(script: String) -> AnyResult { + validate_script(&script)?; + Ok(Self { script }) + } +} + +impl ChooseMove for StatelessLuaBot { + fn choose_move( + &self, + chess_game: &ChessGame, + legal_moves: &MoveList, + ) -> AnyResult { + let vm = init_vm(); + let chunk = vm.load(&self.script); + let bot = chunk + .eval() + .context("Error initializing bot while choosing move")?; + + let uci_legal_moves = legal_moves + .iter() + .map(|legal_move| legal_move.to_uci(CastlingMode::Standard).to_string()) + .collect::>(); + + let chosen_uci: Uci = invoke_bot(bot, &chess_game.fen(), &uci_legal_moves) + .context("Bot made an error while choosing its move")? + .parse() + // TODO: confirm that this error message will include the string the bot returned + .context("Error parsing bot response as uci")?; + + log::debug!("Bot trying to play move: {chosen_uci}"); + + let chosen_move = chosen_uci + .to_move(&chess_game.game) + .context("Bot returned illegal move")?; + + Ok(chosen_move) + } +} + +fn invoke_bot(bot: mlua::Table<'_>, chess_game: &str, legal_moves: &[String]) -> AnyResult { + let choose_move: mlua::Function = bot.get("chooseMove")?; + let chosen_move = choose_move.call((bot, chess_game, legal_moves))?; + Ok(chosen_move) +} + +fn init_vm() -> Lua { + // the Anti-Eamonn policy + let whitelisted_libs = StdLib::MATH | StdLib::TABLE | StdLib::STRING; + // SAFETY: This function only errors if we use StdLib::DEBUG or StdLib::FFI + Lua::new_with(whitelisted_libs, mlua::LuaOptions::default()).unwrap() +} + +/// TODO: +/// will probably want to try to generate the bot and check that chooseMove exists +/// valide it is a function and try passing it a starting position as a simple dummy check +fn validate_script(_script: &str) -> AnyResult<()> { + Ok(()) +} diff --git a/src/player_vs_bot.rs b/src/player_vs_bot.rs index 47f5544..c3f2f88 100644 --- a/src/player_vs_bot.rs +++ b/src/player_vs_bot.rs @@ -1,19 +1,20 @@ use anyhow::Result; -use log::{error, info}; use shakmaty::Move; use crate::{chess_engine::ChooseMove, chess_game::ChessGame}; pub struct PlayerGame { - bot: Box, + bot: Box, pub game: ChessGame, + pub bot_name: String, } impl PlayerGame { - pub fn new(bot: C) -> Self { + pub fn new(bot: C, name: impl ToString) -> Self { Self { bot: Box::new(bot), game: ChessGame::new(), + bot_name: name.to_string(), } } @@ -30,33 +31,9 @@ impl PlayerGame { return Ok(None); } - // FIXME: remove unwrap. What does `None` mean for a choose move? it ran out of time? - let bot_move = match self.bot.choose_move(&self.game.fen(), &legal_moves) { - Some(m) => m, - None => { - // not really sure what we are supposed to do here - // this is not a mistake by the player its a mistake by the bot - error!( - "Despite the game not being over, - the bot returned None for a move. Game FEN {}. - Defaulting to a random move", - self.game.fen() - ); - - // as mentioned above, the game not being over should - // guarantee that there are legal moves - if legal_moves.is_empty() { - let msg = format!( - "Despite the game not being over There are no legal moves. FEN {}", - self.game.fen() - ); - error!("{}", msg); - return Err(anyhow::anyhow!(msg)); - } - - legal_moves[0].clone() - } - }; + // Going to propagate error here from bot. For lua bots we have to assume that + // bugs are common and we want to propogate them to the client so bot creators can debug + let bot_move = self.bot.choose_move(&self.game, legal_moves)?; self.game.make_move(&bot_move);