From eeab08af75b6add1f9d4c8115384ec21e3a71318 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 31 Oct 2025 16:21:56 -0700 Subject: [PATCH] Full game --- .gitignore | 2 +- build-wasm.sh | 2 +- rust/Cargo.lock | 37 +- rust/Cargo.toml | 2 +- rust/rhai-codemirror/Cargo.toml | 8 +- rust/{script-runner => runner}/Cargo.toml | 4 +- rust/{script-runner => runner}/package.json | 0 rust/runner/src/ansi.rs | 9 + rust/runner/src/human.rs | 184 +++++ rust/runner/src/lib.rs | 652 ++++++++++++++++++ rust/script-runner/README.md | 54 -- rust/script-runner/src/colors.rs | 4 - rust/script-runner/src/lib.rs | 317 --------- webui/src/app/page.tsx | 9 +- webui/src/components/Editor.tsx | 26 +- webui/src/components/Playground.tsx | 39 +- webui/src/components/Terminal.tsx | 141 ++-- webui/src/lib/runner.ts | 28 +- .../{script-runner.worker.ts => worker.ts} | 46 +- webui/src/utils/wasmLoader.ts | 121 ++-- 20 files changed, 1125 insertions(+), 560 deletions(-) rename rust/{script-runner => runner}/Cargo.toml (93%) rename rust/{script-runner => runner}/package.json (100%) create mode 100644 rust/runner/src/ansi.rs create mode 100644 rust/runner/src/human.rs create mode 100644 rust/runner/src/lib.rs delete mode 100644 rust/script-runner/README.md delete mode 100644 rust/script-runner/src/colors.rs delete mode 100644 rust/script-runner/src/lib.rs rename webui/src/lib/{script-runner.worker.ts => worker.ts} (62%) diff --git a/.gitignore b/.gitignore index 4b53225..a5a48de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules *.ignore target .DS_Store -webui/src/lib/wasm +webui/src/wasm diff --git a/build-wasm.sh b/build-wasm.sh index 0bc52b1..63a733c 100644 --- a/build-wasm.sh +++ b/build-wasm.sh @@ -87,4 +87,4 @@ for crate in "${wasm_crates[@]}"; do ;; esac echo -e " • $crate → $output_dir" -done \ No newline at end of file +done diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fca27f2..bb60805 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -339,6 +339,25 @@ dependencies = [ "syn", ] +[[package]] +name = "runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "getrandom", + "itertools", + "js-sys", + "minimax", + "rand", + "rhai", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", + "web-sys", + "wee_alloc", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -357,24 +376,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "script-runner" -version = "0.1.0" -dependencies = [ - "anyhow", - "console_error_panic_hook", - "getrandom", - "js-sys", - "minimax", - "rand", - "rhai", - "serde", - "serde-wasm-bindgen", - "wasm-bindgen", - "web-sys", - "wee_alloc", -] - [[package]] name = "serde" version = "1.0.228" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index aad3872..fc0ab8d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["script-runner", "minimax", "rhai-codemirror"] +members = ["runner", "minimax", "rhai-codemirror"] resolver = "2" [workspace.dependencies] diff --git a/rust/rhai-codemirror/Cargo.toml b/rust/rhai-codemirror/Cargo.toml index 1bd1b22..655e608 100644 --- a/rust/rhai-codemirror/Cargo.toml +++ b/rust/rhai-codemirror/Cargo.toml @@ -12,11 +12,15 @@ codegen-units = 1 opt-level = 's' [dependencies] -rhai = { workspace = true, features = ["internals", "wasm-bindgen"] } +rhai = { workspace = true, features = ["internals"] } + wasm-bindgen = { workspace = true } js-sys = { workspace = true } web-sys = { workspace = true, features = ["console"] } console_error_panic_hook = { workspace = true } wee_alloc = { workspace = true } serde = { workspace = true } -serde-wasm-bindgen = { workspace = true } \ No newline at end of file +serde-wasm-bindgen = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +rhai = { workspace = true, features = ["wasm-bindgen"] } diff --git a/rust/script-runner/Cargo.toml b/rust/runner/Cargo.toml similarity index 93% rename from rust/script-runner/Cargo.toml rename to rust/runner/Cargo.toml index 2c99394..fbc9c5c 100644 --- a/rust/script-runner/Cargo.toml +++ b/rust/runner/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "script-runner" +name = "runner" version = "0.1.0" edition = "2021" @@ -9,9 +9,9 @@ crate-type = ["cdylib"] [dependencies] minimax = { workspace = true } - anyhow = { workspace = true } rand = { workspace = true } +itertools = { workspace = true } wasm-bindgen = { workspace = true } js-sys = { workspace = true } diff --git a/rust/script-runner/package.json b/rust/runner/package.json similarity index 100% rename from rust/script-runner/package.json rename to rust/runner/package.json diff --git a/rust/runner/src/ansi.rs b/rust/runner/src/ansi.rs new file mode 100644 index 0000000..131d928 --- /dev/null +++ b/rust/runner/src/ansi.rs @@ -0,0 +1,9 @@ +pub const RESET: &str = "\x1b[0m"; +pub const RED: &str = "\x1b[31m"; +pub const BLUE: &str = "\x1b[34m"; +pub const MAGENTA: &str = "\x1b[35m"; + +pub const UP: &str = "\x1b[A"; +pub const DOWN: &str = "\x1b[B"; +pub const LEFT: &str = "\x1b[D"; +pub const RIGHT: &str = "\x1b[C"; diff --git a/rust/runner/src/human.rs b/rust/runner/src/human.rs new file mode 100644 index 0000000..e2a39ba --- /dev/null +++ b/rust/runner/src/human.rs @@ -0,0 +1,184 @@ +use itertools::Itertools; +use minimax::{ + board::{Board, PlayerAction}, + util::Symb, +}; + +use crate::ansi::{DOWN, LEFT, RESET, RIGHT, UP}; + +struct SymbolSelector { + symbols: Vec, + cursor: usize, +} + +impl SymbolSelector { + fn new(symbols: Vec) -> Self { + Self { symbols, cursor: 0 } + } + + fn current(&self) -> char { + self.symbols[self.cursor] + } + + fn check(&mut self, board: &Board) { + while board.contains(Symb::from_char(self.current()).unwrap()) { + if self.cursor == 0 { + self.cursor = self.symbols.len() - 1; + } else { + self.cursor -= 1; + } + } + } + + fn down(&mut self, board: &Board) { + if self.cursor == 0 { + self.cursor = self.symbols.len() - 1; + } else { + self.cursor -= 1; + } + + while board.contains(Symb::from_char(self.current()).unwrap()) { + if self.cursor == 0 { + self.cursor = self.symbols.len() - 1; + } else { + self.cursor -= 1; + } + } + } + + fn up(&mut self, board: &Board) { + if self.cursor == self.symbols.len() - 1 { + self.cursor = 0; + } else { + self.cursor += 1; + } + + while board.contains(Symb::from_char(self.current()).unwrap()) { + if self.cursor == self.symbols.len() - 1 { + self.cursor = 0; + } else { + self.cursor += 1; + } + } + } +} + +pub struct HumanInput { + player_color: String, + cursor: usize, + symbol_selector: SymbolSelector, + + /// Set to Some() when the player selects an action. + /// Should be cleared and applied immediately. + queued_action: Option, +} + +impl HumanInput { + pub fn new(player_color: String) -> Self { + Self { + cursor: 0, + queued_action: None, + + player_color, + symbol_selector: SymbolSelector::new(vec![ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '+', '-', '×', '÷', + ]), + } + } + + pub fn pop_action(&mut self) -> Option { + self.queued_action.take() + } + + pub fn print_state(&mut self, board: &Board, minimize: bool) -> String { + let cursor_max = board.size() - 1; + self.symbol_selector.check(board); + + let board_label = format!( + "{}{:<6}{RESET}", + self.player_color, + if minimize { "Min" } else { "Max" }, + ); + + return format!( + "\r{}╙{}{}{}{RESET}{}╜ {}", + board_label, + // Cursor + " ".repeat(self.cursor), + self.player_color, + if board.is_full() { + ' ' + } else { + self.symbol_selector.current() + }, + // RESET + " ".repeat(cursor_max - self.cursor), + self.symbol_selector + .symbols + .iter() + .map(|x| { + if board.contains(Symb::from_char(*x).unwrap()) { + " ".to_string() + } else if *x == self.symbol_selector.current() { + format!("{}{x}{RESET}", self.player_color,) + } else { + format!("{x}",) + } + }) + .join("") + ); + } + + pub fn process_input(&mut self, board: &Board, data: String) { + let cursor_max = board.size() - 1; + self.symbol_selector.check(board); + + match &data[..] { + RIGHT => { + self.cursor = cursor_max.min(self.cursor + 1); + } + + LEFT => { + if self.cursor != 0 { + self.cursor -= 1; + } + } + + UP => { + self.symbol_selector.up(board); + } + + DOWN => { + self.symbol_selector.down(board); + } + + " " | "\n" => { + let symb = Symb::from_char(self.symbol_selector.current()); + if let Some(symb) = symb { + let action = PlayerAction { + symb, + pos: self.cursor, + }; + + if board.can_play(&action) { + self.queued_action = Some(action); + } + } + } + + c => { + let symb = Symb::from_str(c); + if let Some(symb) = symb { + let action = PlayerAction { + symb, + pos: self.cursor, + }; + + if board.can_play(&action) { + self.queued_action = Some(action); + } + } + } + }; + } +} diff --git a/rust/runner/src/lib.rs b/rust/runner/src/lib.rs new file mode 100644 index 0000000..b0116c2 --- /dev/null +++ b/rust/runner/src/lib.rs @@ -0,0 +1,652 @@ +use minimax::{ + agents::{Agent, Rhai}, + board::Board, +}; +use rand::{rngs::StdRng, SeedableRng}; +use rhai::ParseError; +use wasm_bindgen::prelude::*; + +use crate::human::HumanInput; + +mod ansi; +mod human; + +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +#[wasm_bindgen] +pub fn init_panic_hook() { + console_error_panic_hook::set_once(); +} + +// +// MARK: GameState +// + +#[wasm_bindgen] +pub struct GameState { + max_agent: Rhai, + max_name: String, + + min_agent: Rhai, + min_name: String, + + board: Board, + max_turn: bool, + + game_state_callback: Box, +} + +#[wasm_bindgen] +impl GameState { + #[wasm_bindgen(constructor)] + pub fn new( + max_script: &str, + max_name: &str, + max_print_callback: js_sys::Function, + max_debug_callback: js_sys::Function, + + min_script: &str, + min_name: &str, + min_print_callback: js_sys::Function, + min_debug_callback: js_sys::Function, + + game_state_callback: js_sys::Function, + ) -> Result { + Self::new_native( + max_script, + max_name, + move |s| { + let _ = max_print_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + move |s| { + let _ = max_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + min_script, + min_name, + move |s| { + let _ = min_print_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + move |s| { + let _ = min_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + move |s| { + let _ = game_state_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + ) + .map_err(|x| format!("Error at {}: {}", x.1, x.0)) + } + + fn new_native( + max_script: &str, + max_name: &str, + max_print_callback: impl Fn(&str) + 'static, + max_debug_callback: impl Fn(&str) + 'static, + + min_script: &str, + min_name: &str, + min_print_callback: impl Fn(&str) + 'static, + min_debug_callback: impl Fn(&str) + 'static, + + game_state_callback: impl Fn(&str) + 'static, + ) -> Result { + console_error_panic_hook::set_once(); + + let mut seed1 = [0u8; 32]; + let mut seed2 = [0u8; 32]; + getrandom::getrandom(&mut seed1).unwrap(); + getrandom::getrandom(&mut seed2).unwrap(); + + Ok(GameState { + board: Board::new(), + max_turn: true, + + max_name: max_name.to_owned(), + max_agent: Rhai::new( + max_script, + StdRng::from_seed(seed1), + max_print_callback, + max_debug_callback, + )?, + + min_name: min_name.to_owned(), + min_agent: Rhai::new( + min_script, + StdRng::from_seed(seed2), + min_print_callback, + min_debug_callback, + )?, + + game_state_callback: Box::new(game_state_callback), + }) + } + + #[wasm_bindgen] + pub fn is_done(&self) -> bool { + self.board.is_full() + } + + /// If true, it is the max player's turn. + /// If false, it is the min player's turn. + #[wasm_bindgen] + pub fn is_max_turn(&self) -> bool { + self.max_turn + } + + fn format_board_display(&self) -> String { + let mut result = String::new(); + + // Board label with player name in gray + let current_player = if self.max_turn { + format!("{:<6}", self.max_name) + } else { + format!("{:<6}", self.min_name) + }; + + let colored_player = if self.max_turn { + format!("{}{}{}", ansi::RED, current_player, ansi::RESET) + } else { + format!("{}{}{}", ansi::BLUE, current_player, ansi::RESET) + }; + result.push_str(&format!("\r{}║", colored_player)); + + for (i, symbol) in self.board.get_board().iter().enumerate() { + match symbol { + Some(s) => { + // Highlight the last placed symbol in magenta, everything else normal + let symbol_str = s.to_string(); + if Some(i) == self.board.get_last_placed() { + result.push_str(&format!("{}{}{}", ansi::MAGENTA, symbol_str, ansi::RESET)); + } else { + result.push_str(&symbol_str); + } + } + None => result.push('_'), + } + } + result.push('║'); + + result + } + + // Play one turn + #[wasm_bindgen] + pub fn step(&mut self) -> Result, String> { + if self.is_done() { + return Ok(None); + } + + let action = match self.is_max_turn() { + true => self.max_agent.step_max(&self.board), + false => self.min_agent.step_min(&self.board), + }; + + let action = match action { + Ok(x) => x, + Err(err) => { + return Err(format!("{err:?}")); + } + }; + + if !self.board.play( + action, + self.max_turn + .then_some(&self.max_name) + .unwrap_or(&self.min_name) + .to_owned(), + ) { + let error_msg = format!( + "{} {} ({}) made an invalid move {}!", + format!("{}ERROR:{}", ansi::RED, ansi::RESET), + self.max_turn + .then_some(&self.max_name) + .unwrap_or(&self.min_name), + self.max_turn + .then_some(self.max_agent.name()) + .unwrap_or(self.min_agent.name()), + action + ); + + // Print error to game state callback + (self.game_state_callback)(&error_msg); + + return Ok(None); + } + + self.max_turn = !self.max_turn; + + // Print board state after move to terminal (via game state callback) + let board_display = self.format_board_display(); + (self.game_state_callback)(&board_display); + + // Show final score if game is complete + if self.is_done() { + if let Some(score) = self.board.evaluate() { + let score_msg = format!("\nFinal score: {:.2}", score); + (self.game_state_callback)(&score_msg); + } + } + + return Ok(Some( + self.max_turn + .then_some(&self.min_name) + .unwrap_or(&self.max_name) + .to_owned(), + )); + } +} + +// +// MARK: GameStateHuman +// + +#[wasm_bindgen] +pub struct GameStateHuman { + human: HumanInput, + + /// If true, human goes first and maximizes score. + /// If false, human goes second and minimizes score. + human_is_maximizer: bool, + + agent: Rhai, + agent_name: String, + + board: Board, + is_human_turn: bool, + is_first_turn: bool, + is_error: bool, + red_score: Option, + + game_state_callback: Box, +} + +#[wasm_bindgen] +impl GameStateHuman { + #[wasm_bindgen(constructor)] + pub fn new( + human_is_maximizer: bool, + max_script: &str, + max_name: &str, + max_print_callback: js_sys::Function, + max_debug_callback: js_sys::Function, + + game_state_callback: js_sys::Function, + ) -> Result { + Self::new_native( + human_is_maximizer, + max_script, + max_name, + move |s| { + let _ = max_print_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + move |s| { + let _ = max_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + move |s| { + let _ = game_state_callback.call1(&JsValue::null(), &JsValue::from_str(s)); + }, + ) + .map_err(|x| format!("Error at {}: {}", x.1, x.0)) + } + + fn new_native( + human_is_maximizer: bool, + max_script: &str, + max_name: &str, + max_print_callback: impl Fn(&str) + 'static, + max_debug_callback: impl Fn(&str) + 'static, + + game_state_callback: impl Fn(&str) + 'static, + ) -> Result { + console_error_panic_hook::set_once(); + + let mut seed1 = [0u8; 32]; + let mut seed2 = [0u8; 32]; + getrandom::getrandom(&mut seed1).unwrap(); + getrandom::getrandom(&mut seed2).unwrap(); + + Ok(Self { + human_is_maximizer, + board: Board::new(), + is_human_turn: human_is_maximizer, + is_first_turn: true, + is_error: false, + red_score: None, + + human: HumanInput::new(ansi::RED.to_string()), + agent_name: max_name.to_owned(), + agent: Rhai::new( + max_script, + StdRng::from_seed(seed1), + max_print_callback, + max_debug_callback, + )?, + + game_state_callback: Box::new(game_state_callback), + }) + } + + #[wasm_bindgen] + pub fn is_done(&self) -> bool { + (self.board.is_full() && self.red_score.is_some()) || self.is_error + } + + #[wasm_bindgen] + pub fn is_error(&self) -> bool { + self.is_error + } + + #[wasm_bindgen] + pub fn human_is_maximizer(&self) -> bool { + self.human_is_maximizer + } + + #[wasm_bindgen] + pub fn print_start(&mut self) { + self.print_board("", ""); + (self.game_state_callback)("\r\n"); + + if !self.is_human_turn { + let action = { + if self.human_is_maximizer { + self.agent.step_min(&self.board) + } else { + self.agent.step_max(&self.board) + } + }; + + let action = match action { + Ok(x) => x, + Err(err) => { + let error_msg = format!( + "{}ERROR:{} Error while computing next move: {:?}", + ansi::RED, + ansi::RESET, + err + ); + + (self.game_state_callback)(&error_msg); + self.is_error = true; + return; + } + }; + + if !self.board.play(action, "Blue") { + let error_msg = format!( + "{}ERROR:{} {} made an invalid move {}!", + ansi::RED, + ansi::RESET, + self.agent_name, + action + ); + + (self.game_state_callback)(&error_msg); + } + + self.print_board( + self.is_human_turn + .then_some(ansi::RED) + .unwrap_or(ansi::BLUE), + self.is_human_turn.then_some("Red").unwrap_or("Blue"), + ); + (self.game_state_callback)("\r\n"); + self.is_human_turn = true; + } + + self.print_ui(); + } + + #[wasm_bindgen] + pub fn print_board(&mut self, color: &str, player: &str) { + let board_label = format!("{}{:<6}{}", color, player, ansi::RESET); + + // Print board + (self.game_state_callback)(&format!( + "\r{}{}{}{}", + board_label, + if self.is_first_turn { '╓' } else { '║' }, + self.board.prettyprint(), + if self.is_first_turn { '╖' } else { '║' }, + )); + self.is_first_turn = false; + } + + #[wasm_bindgen] + pub fn print_ui(&mut self) { + (self.game_state_callback)( + &self + .human + .print_state(&self.board, !self.human_is_maximizer), + ); + } + + #[wasm_bindgen] + pub fn take_input(&mut self, data: String) { + self.human.process_input(&self.board, data); + self.print_ui(); + + if let Some(action) = self.human.pop_action() { + if !self.board.play(action, "Red") { + let error_msg = format!( + "{}ERROR:{} {} made an invalid move {}!", + ansi::RED, + ansi::RESET, + "Human", + action + ); + + (self.game_state_callback)(&error_msg); + } + self.is_human_turn = false; + + if self.board.is_full() { + self.print_end(); + return; + } + self.print_board(ansi::RED, "Red"); + (self.game_state_callback)("\r\n"); + + let action = { + if self.human_is_maximizer { + self.agent.step_min(&self.board) + } else { + self.agent.step_max(&self.board) + } + }; + + let action = match action { + Ok(x) => x, + Err(err) => { + let error_msg = format!( + "{}ERROR:{} Error while computing next move: {:?}", + ansi::RED, + ansi::RESET, + err + ); + + (self.game_state_callback)(&error_msg); + self.is_error = true; + return; + } + }; + + if !self.board.play(action, "Blue") { + let error_msg = format!( + "{}ERROR:{} {} made an invalid move {}!", + ansi::RED, + ansi::RESET, + self.agent_name, + action + ); + + (self.game_state_callback)(&error_msg); + } + self.is_human_turn = true; + + if self.board.is_full() { + self.print_end(); + return; + } + self.print_board(ansi::BLUE, "Blue"); + (self.game_state_callback)("\r\n"); + + self.print_ui(); + } + } + + pub fn print_end(&mut self) { + let board_label = format!( + "{}{:<6}{}", + self.is_human_turn + .then_some(ansi::BLUE) + .unwrap_or(ansi::RED), + self.is_human_turn.then_some("Blue").unwrap_or("Red"), + ansi::RESET + ); + + (self.game_state_callback)(&format!("\r{}║{}║", board_label, self.board.prettyprint())); + (self.game_state_callback)("\r\n"); + + (self.game_state_callback)(&format!( + "\r{}╙{}╜", + " ", + " ".repeat(self.board.size()) + )); + (self.game_state_callback)("\r\n"); + + let score = self.board.evaluate().unwrap(); + (self.game_state_callback)(&format!( + "\r\n{}{} score:{} {:.2}\r\n", + self.human_is_maximizer + .then_some(ansi::RED) + .unwrap_or(ansi::BLUE), + self.human_is_maximizer.then_some("Red").unwrap_or("Blue"), + ansi::RESET, + score + )); + (self.game_state_callback)("\r\n"); + + match self.red_score { + // Start second round + None => { + let mut seed1 = [0u8; 32]; + let mut seed2 = [0u8; 32]; + getrandom::getrandom(&mut seed1).unwrap(); + getrandom::getrandom(&mut seed2).unwrap(); + + self.human_is_maximizer = !self.human_is_maximizer; + self.board = Board::new(); + self.is_human_turn = !self.human_is_maximizer; + self.is_first_turn = true; + self.is_error = false; + self.human = HumanInput::new(ansi::RED.to_string()); + self.red_score = Some(score); + + self.print_start(); + } + + // End game + Some(red_score) => { + if red_score == score { + (self.game_state_callback)(&format!("Tie! Score: {:.2}\r\n", score)); + return; + } + + let red_wins = red_score > score; + (self.game_state_callback)(&format!( + "{}{} wins!{}", + red_wins.then_some(ansi::RED).unwrap_or(ansi::BLUE), + red_wins.then_some("Red").unwrap_or("Blue"), + ansi::RESET, + )); + } + } + } +} + +// +// MARK: tests +// +// TODO: +// - infinite loop +// - random is different +// - incorrect return type +// - globals + +#[test] +fn full_random() { + const SCRIPT: &str = r#" + fn random_action(board) { + let symb = rand_symb(); + let pos = rand_int(0, 10); + let action = Action(symb, pos); + + while !board.can_play(action) { + let symb = rand_symb(); + let pos = rand_int(0, 10); + action = Action(symb, pos); + } + + return action + } + + fn step_min(board) { + random_action(board) + } + + fn step_max(board) { + random_action(board) + } + "#; + + let mut game = GameState::new_native( + &SCRIPT, + "max", + |_| {}, + |_| {}, + &SCRIPT, + "min", + |_| {}, + |_| {}, + |_| {}, + ); + + let mut n = 0; + while !game.is_done() { + println!("{:?}", game.step()); + println!("{:?}", game.board); + + n += 1; + assert!(n < 10); + } +} + +#[test] +fn infinite_loop() { + const SCRIPT: &str = r#" + fn step_min(board) { + loop {} + } + + fn step_max(board) { + loop {} + } + "#; + + let mut game = GameState::new_native( + &SCRIPT, + "max", + |_| {}, + |_| {}, + &SCRIPT, + "min", + |_| {}, + |_| {}, + |_| {}, + ); + + while !game.is_done() { + println!("{:?}", game.step()); + println!("{:?}", game.board); + } +} diff --git a/rust/script-runner/README.md b/rust/script-runner/README.md deleted file mode 100644 index e70e0d7..0000000 --- a/rust/script-runner/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Script Runner WASM - -A placeholder Rust script runner using wasm-bindgen. - -## Building - -```bash -# Install wasm-pack if not already installed -cargo install wasm-pack - -# Build for web -npm run build - -# Build for different targets -npm run build:nodejs # For Node.js -npm run build:bundler # For bundlers like webpack -npm run dev # Development build -``` - -## Usage - -```javascript -import init, { ScriptRunner, greet, add } from './pkg/script_runner.js'; - -async function run() { - await init(); - - // Basic functions - greet("World"); - console.log(add(1, 2)); // 3 - - // Script runner - const runner = new ScriptRunner(); - runner.set_context("my_context"); - console.log(runner.get_context()); // "my_context" - - const result = runner.run_script("console.log('Hello from script')"); - console.log(result); - - // Evaluate expressions - console.log(runner.evaluate("hello")); // "Hello from Rust!" - console.log(runner.evaluate("version")); // "0.1.0" -} - -run(); -``` - -## Features - -- Basic WASM initialization with panic hooks -- ScriptRunner class for managing execution context -- Simple expression evaluation -- Console logging integration -- Multiple build targets (web, nodejs, bundler) \ No newline at end of file diff --git a/rust/script-runner/src/colors.rs b/rust/script-runner/src/colors.rs deleted file mode 100644 index 9c0c9f2..0000000 --- a/rust/script-runner/src/colors.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub const RESET: &str = "\x1b[0m"; -pub const RED: &str = "\x1b[31m"; -pub const BLUE: &str = "\x1b[34m"; -pub const MAGENTA: &str = "\x1b[35m"; \ No newline at end of file diff --git a/rust/script-runner/src/lib.rs b/rust/script-runner/src/lib.rs deleted file mode 100644 index 7908971..0000000 --- a/rust/script-runner/src/lib.rs +++ /dev/null @@ -1,317 +0,0 @@ -use minimax::{ - agents::{Agent, Rhai}, - board::Board, -}; -use rand::{rngs::StdRng, SeedableRng}; -use wasm_bindgen::prelude::*; - -mod colors; - -#[global_allocator] -static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; - -#[wasm_bindgen] -pub fn init_panic_hook() { - console_error_panic_hook::set_once(); -} - -#[wasm_bindgen] -pub struct GameState { - max_agent: Rhai, - max_name: String, - - min_agent: Rhai, - min_name: String, - - board: Board, - max_turn: bool, - - game_state_callback: Box, -} - -#[wasm_bindgen] -impl GameState { - #[wasm_bindgen(constructor)] - pub fn new( - max_script: &str, - max_name: &str, - max_print_callback: js_sys::Function, - max_debug_callback: js_sys::Function, - - min_script: &str, - min_name: &str, - min_print_callback: js_sys::Function, - min_debug_callback: js_sys::Function, - - game_state_callback: js_sys::Function, - ) -> GameState { - Self::new_native( - max_script, - max_name, - move |s| { - let _ = max_print_callback.call1(&JsValue::null(), &JsValue::from_str(s)); - }, - move |s| { - let _ = max_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s)); - }, - min_script, - min_name, - move |s| { - let _ = min_print_callback.call1(&JsValue::null(), &JsValue::from_str(s)); - }, - move |s| { - let _ = min_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s)); - }, - move |s| { - let _ = game_state_callback.call1(&JsValue::null(), &JsValue::from_str(s)); - }, - ) - } - - fn new_native( - max_script: &str, - max_name: &str, - max_print_callback: impl Fn(&str) + 'static, - max_debug_callback: impl Fn(&str) + 'static, - - min_script: &str, - min_name: &str, - min_print_callback: impl Fn(&str) + 'static, - min_debug_callback: impl Fn(&str) + 'static, - - game_state_callback: impl Fn(&str) + 'static, - ) -> GameState { - console_error_panic_hook::set_once(); - - let mut seed1 = [0u8; 32]; - let mut seed2 = [0u8; 32]; - getrandom::getrandom(&mut seed1).unwrap(); - getrandom::getrandom(&mut seed2).unwrap(); - - GameState { - board: Board::new(), - max_turn: true, - - max_name: max_name.to_owned(), - max_agent: Rhai::new( - max_script, - StdRng::from_seed(seed1), - max_print_callback, - max_debug_callback, - ), - - min_name: min_name.to_owned(), - min_agent: Rhai::new( - min_script, - StdRng::from_seed(seed2), - min_print_callback, - min_debug_callback, - ), - - game_state_callback: Box::new(game_state_callback), - } - } - - #[wasm_bindgen] - pub fn is_done(&self) -> bool { - self.board.is_full() - } - - /// If true, it is the max player's turn. - /// If false, it is the min player's turn. - #[wasm_bindgen] - pub fn is_max_turn(&self) -> bool { - self.max_turn - } - - fn format_board_display(&self) -> String { - let mut result = String::new(); - - // Board label with player name in gray - let current_player = if self.max_turn { - format!("{:<6}", self.max_name) - } else { - format!("{:<6}", self.min_name) - }; - - let colored_player = if self.max_turn { - format!("{}{}{}", colors::RED, current_player, colors::RESET) - } else { - format!("{}{}{}", colors::BLUE, current_player, colors::RESET) - }; - result.push_str(&format!("\r{}║", colored_player)); - - for (i, symbol) in self.board.get_board().iter().enumerate() { - match symbol { - Some(s) => { - // Highlight the last placed symbol in magenta, everything else normal - let symbol_str = s.to_string(); - if Some(i) == self.board.get_last_placed() { - result.push_str(&format!( - "{}{}{}", - colors::MAGENTA, - symbol_str, - colors::RESET - )); - } else { - result.push_str(&symbol_str); - } - } - None => result.push('_'), - } - } - result.push('║'); - - result - } - - // Play one turn - #[wasm_bindgen] - pub fn step(&mut self) -> Result, String> { - if self.is_done() { - return Ok(None); - } - - let action = match self.is_max_turn() { - true => self.max_agent.step_max(&self.board), - false => self.min_agent.step_min(&self.board), - }; - - let action = match action { - Ok(x) => x, - Err(err) => { - return Err(format!("{err:?}")); - } - }; - - if !self.board.play( - action, - self.max_turn - .then_some(&self.max_name) - .unwrap_or(&self.min_name) - .to_owned(), - ) { - let error_msg = format!( - "{} {} ({}) made an invalid move {}!", - format!("{}ERROR:{}", colors::RED, colors::RESET), - self.max_turn - .then_some(&self.max_name) - .unwrap_or(&self.min_name), - self.max_turn - .then_some(self.max_agent.name()) - .unwrap_or(self.min_agent.name()), - action - ); - - // Print error to game state callback - (self.game_state_callback)(&error_msg); - - return Ok(None); - } - - self.max_turn = !self.max_turn; - - // Print board state after move to terminal (via game state callback) - let board_display = self.format_board_display(); - (self.game_state_callback)(&board_display); - - // Show final score if game is complete - if self.is_done() { - if let Some(score) = self.board.evaluate() { - let score_msg = format!("\nFinal score: {:.2}", score); - (self.game_state_callback)(&score_msg); - } - } - - return Ok(Some( - self.max_turn - .then_some(&self.min_name) - .unwrap_or(&self.max_name) - .to_owned(), - )); - } -} - -// TODO: tests -// - infinite loop -// - random is different -// - incorrect return type -// - globals - -#[test] -fn full_random() { - const SCRIPT: &str = r#" - fn random_action(board) { - let symb = rand_symb(); - let pos = rand_int(0, 10); - let action = Action(symb, pos); - - while !board.can_play(action) { - let symb = rand_symb(); - let pos = rand_int(0, 10); - action = Action(symb, pos); - } - - return action - } - - fn step_min(board) { - random_action(board) - } - - fn step_max(board) { - random_action(board) - } - "#; - - let mut game = GameState::new_native( - &SCRIPT, - "max", - |_| {}, - |_| {}, - &SCRIPT, - "min", - |_| {}, - |_| {}, - |_| {}, - ); - - let mut n = 0; - while !game.is_done() { - println!("{:?}", game.step()); - println!("{:?}", game.board); - - n += 1; - assert!(n < 10); - } -} - -#[test] -fn infinite_loop() { - const SCRIPT: &str = r#" - fn step_min(board) { - loop {} - } - - fn step_max(board) { - loop {} - } - "#; - - let mut game = GameState::new_native( - &SCRIPT, - "max", - |_| {}, - |_| {}, - &SCRIPT, - "min", - |_| {}, - |_| {}, - |_| {}, - ); - - while !game.is_done() { - println!("{:?}", game.step()); - println!("{:?}", game.board); - } -} diff --git a/webui/src/app/page.tsx b/webui/src/app/page.tsx index bdb40d8..bd55fbf 100644 --- a/webui/src/app/page.tsx +++ b/webui/src/app/page.tsx @@ -63,14 +63,7 @@ export default function Home() { }} >
- Loading{" "} - - Rhai Playground - - ... + Loading WASM...
); diff --git a/webui/src/components/Editor.tsx b/webui/src/components/Editor.tsx index ec332a8..b61a4b9 100644 --- a/webui/src/components/Editor.tsx +++ b/webui/src/components/Editor.tsx @@ -1,6 +1,12 @@ "use client"; -import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from "react"; +import { + useEffect, + useRef, + forwardRef, + useImperativeHandle, + useState, +} from "react"; import styles from "@/styles/Editor.module.css"; import { loadRhaiWasm, initRhaiMode } from "@/utils/wasmLoader"; @@ -28,12 +34,12 @@ if (typeof window !== "undefined") { await loadRhaiWasm(); initRhaiMode(CodeMirror); - console.log('✅ WASM-based Rhai mode initialized successfully'); + console.log("✅ WASM-based Rhai mode initialized successfully"); isCodeMirrorReady = true; }) - .catch(error => { - console.error('Failed to load CodeMirror:', error); + .catch((error) => { + console.error("Failed to load CodeMirror:", error); }); } @@ -45,8 +51,8 @@ interface EditorProps { } export const Editor = forwardRef(function Editor( - { initialValue = "", onChange, onReady }, - ref, + { initialValue = "", onChange, onReady }, + ref ) { const textareaRef = useRef(null); const editorRef = useRef(null); @@ -56,7 +62,13 @@ export const Editor = forwardRef(function Editor( // Initialize editor only once useEffect(() => { - if (!isCodeMirrorReady || !CodeMirror || !textareaRef.current || editorRef.current) return; + if ( + !isCodeMirrorReady || + !CodeMirror || + !textareaRef.current || + editorRef.current + ) + return; const editor = CodeMirror.fromTextArea(textareaRef.current, { lineNumbers: true, diff --git a/webui/src/components/Playground.tsx b/webui/src/components/Playground.tsx index d345194..d5c6cd5 100644 --- a/webui/src/components/Playground.tsx +++ b/webui/src/components/Playground.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/Button"; import { Dropdown } from "@/components/ui/Dropdown"; import { Editor } from "@/components/Editor"; import { Terminal, TerminalRef } from "@/components/Terminal"; -import { runScript, stopScript } from "@/lib/runner"; +import { sendDataToScript, startScript, stopScript } from "@/lib/runner"; import styles from "@/styles/Playground.module.css"; const initialCode = ` @@ -50,17 +50,21 @@ export default function Playground() { const stopDisabled = !isScriptRunning; const requestRun = useCallback(async () => { + if (resultRef.current) { + resultRef.current.value = ""; + } + if (runDisabled || !editorRef.current) return; setIsScriptRunning(true); try { terminalRef.current?.clear(); + terminalRef.current?.focus(); - await runScript( + await startScript( editorRef.current.getValue(), (line: string) => { - // Only script prints go to output text area if (resultRef.current) { let v = resultRef.current.value + line + "\n"; if (v.length > 10000) { @@ -72,18 +76,19 @@ export default function Playground() { resultRef.current.clientHeight; } }, + (line: string) => { - // Game state and debug info go to terminal - terminalRef.current?.write(line + '\r\n'); + terminalRef.current?.write(line); } ); - } catch (ex) { const errorMsg = `\nEXCEPTION: "${ex}"\n`; if (resultRef.current) { resultRef.current.value += errorMsg; } - terminalRef.current?.write('\r\n\x1B[1;31mEXCEPTION:\x1B[0m ' + String(ex) + '\r\n'); + terminalRef.current?.write( + "\r\n\x1B[1;31mEXCEPTION:\x1B[0m " + String(ex) + "\r\n" + ); } setIsScriptRunning(false); @@ -102,9 +107,6 @@ export default function Playground() { variant="success" iconLeft="play" onClick={() => { - if (resultRef.current) { - resultRef.current.value = ""; - } requestRun(); }} loading={isScriptRunning} @@ -113,6 +115,18 @@ export default function Playground() { Run + +