diff --git a/Cargo.lock b/Cargo.lock index 5574372..afc973a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,7 @@ dependencies = [ "pico-args", "private_poker", "rand", + "ratatui", ] [[package]] diff --git a/README.md b/README.md index 945e0be..dde0201 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ following artifacts: - [Library crate][7] - [Server crate][8] - [Client crate][9] +- [Bots create][10] - [All-in-one Docker image (recommended)][1] # Poker over `ssh` @@ -166,3 +167,4 @@ exercise to forkers: [7]: https://crates.io/crates/private_poker [8]: https://crates.io/crates/pp_server [9]: https://crates.io/crates/pp_client +[10]: https://crates.io/crates/pp_bots diff --git a/pp_bots/Cargo.toml b/pp_bots/Cargo.toml index 8b06328..73efce0 100644 --- a/pp_bots/Cargo.toml +++ b/pp_bots/Cargo.toml @@ -13,3 +13,4 @@ ctrlc = { version = "3.4.5", features = ["termination"] } pico-args = "0.5.0" private_poker = { version = "0.1.6", path = "../private_poker" } rand = "0.8.5" +ratatui = "0.28.1" diff --git a/pp_bots/src/app.rs b/pp_bots/src/app.rs new file mode 100644 index 0000000..32a49f7 --- /dev/null +++ b/pp_bots/src/app.rs @@ -0,0 +1,260 @@ +use std::{ + sync::{ + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, + }, + thread::{self, JoinHandle}, +}; + +use anyhow::Error; + +use ratatui::{ + crossterm::event::{self, Event, KeyCode}, + layout::{Alignment, Constraint, Flex, Layout, Position}, + style::{Style, Stylize}, + text::{Line, Text}, + widgets::{block, Cell, Clear, Padding, Paragraph, Row, Table, TableState}, + DefaultTerminal, Frame, +}; + +mod widgets; + +use widgets::UserInput; + +use crate::bot::{Bot, QLearning}; + +const EXIT: &str = "\ +exiting will remove all bots and erase their memory. + +are you sure you want to exit? + + leave go back +(Enter) (Esc) +"; + +enum PopupMenu { + BotCreation, + Error(String), + Exit, +} + +type Worker = (String, Sender<()>, JoinHandle>); + +fn worker( + mut env: Bot, + policy: Arc>, + interrupt: Receiver<()>, +) -> Result<(), Error> { + loop { + if interrupt.try_recv().is_ok() { + return Ok(()); + } + let (mut state1, mut masks1) = env.reset()?; + loop { + let action = { + let mut policy = policy.lock().expect("sample lock"); + policy.sample(state1.clone(), masks1.clone()) + }; + let (state2, masks2, reward, done) = env.step(action.clone())?; + if done { + let mut policy = policy.lock().expect("done lock"); + policy.update_done(state1.clone(), action.clone(), reward); + break; + } + { + let mut policy = policy.lock().expect("step lock"); + policy.update_step( + state1.clone(), + action.clone(), + reward, + state2.clone(), + masks2.clone(), + ); + } + state1.clone_from(&state2); + masks1.clone_from(&masks2); + } + } +} + +pub struct App { + addr: String, + policy: Arc>, + workers: Vec, + table_state: TableState, + user_input: UserInput, + popup_menu: Option, +} + +impl App { + pub fn new(addr: String, policy: Arc>) -> Self { + Self { + addr, + policy, + workers: Vec::new(), + table_state: TableState::new(), + user_input: UserInput::new(), + popup_menu: None, + } + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<(), Error> { + loop { + terminal.draw(|frame| self.draw(frame))?; + + if let Event::Key(key) = event::read()? { + match self.popup_menu { + Some(PopupMenu::BotCreation) => match key.code { + KeyCode::Char(to_insert) => self.user_input.input(to_insert), + KeyCode::Delete => self.user_input.delete(), + KeyCode::Backspace => self.user_input.backspace(), + KeyCode::Left => self.user_input.move_left(), + KeyCode::Right => self.user_input.move_right(), + KeyCode::Home => self.user_input.jump_to_first(), + KeyCode::End => self.user_input.jump_to_last(), + KeyCode::Enter if !self.user_input.value.is_empty() => { + let botname = self.user_input.submit(); + let addr = self.addr.clone(); + match Bot::new(&botname, &addr) { + Ok(env) => { + let policy = self.policy.clone(); + let (tx_server, rx_worker): (Sender<()>, Receiver<()>) = + channel(); + self.workers.push(( + botname.clone(), + tx_server, + thread::spawn(move || worker(env, policy, rx_worker)), + )); + self.table_state.select(Some(self.workers.len() - 1)); + self.popup_menu = None; + } + Err(msg) => { + self.popup_menu = Some(PopupMenu::Error(msg.to_string())) + } + } + } + KeyCode::Esc => { + self.user_input.clear(); + self.popup_menu = None; + } + _ => {} + }, + Some(PopupMenu::Error(_)) => self.popup_menu = None, + Some(PopupMenu::Exit) => match key.code { + KeyCode::Enter => return Ok(()), + KeyCode::Esc => self.popup_menu = None, + _ => {} + }, + None => match key.code { + KeyCode::Char('d') if self.table_state.selected().is_some() => { + if let Some(idx) = self.table_state.selected() { + let (_, tx_server, _) = self.workers.remove(idx); + tx_server.send(())?; + } + } + KeyCode::Char('i') => self.popup_menu = Some(PopupMenu::BotCreation), + KeyCode::Esc => self.popup_menu = Some(PopupMenu::Exit), + KeyCode::Down => self.table_state.select_next(), + KeyCode::Up => self.table_state.select_previous(), + _ => {} + }, + } + } + + // Only keep workers that're doing work. + self.workers.retain(|(.., handle)| !handle.is_finished()); + } + } + + fn draw(&mut self, frame: &mut Frame) { + let window = Layout::vertical([Constraint::Length(1), Constraint::Min(6)]); + let [help_area, table_area] = window.areas(frame.area()); + + // Render current bots. + let table = Table::new( + self.workers.iter().map(|(botname, ..)| { + let text = Text::raw(botname); + let cell = Cell::new(text); + Row::new([cell]) + }), + [Constraint::Fill(1)], + ) + .block(block::Block::bordered().padding(Padding::uniform(1))) + .highlight_style(Style::new().bg(ratatui::style::Color::White)); + frame.render_stateful_widget(table, table_area, &mut self.table_state); + + // Render user input help message. + let help_message = vec![ + "press ".into(), + "i".bold().white(), + " to create a bot, ".into(), + "up/down".bold().white(), + " to select a bot, ".into(), + "d".bold().white(), + " delete a bot, or press ".into(), + "Esc".bold().white(), + " to exit".into(), + ]; + let help_style = Style::default(); + let help_message = Text::from(Line::from(help_message)).patch_style(help_style); + let help_message = Paragraph::new(help_message); + frame.render_widget(help_message, help_area); + + // Render popup menus. + match self.popup_menu { + Some(PopupMenu::BotCreation) => { + let vertical = Layout::vertical([Constraint::Length(3)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Max(60)]).flex(Flex::Center); + let [user_input_area] = vertical.areas(frame.area()); + let [user_input_area] = horizontal.areas(user_input_area); + frame.render_widget(Clear, user_input_area); // clears out the background + + let user_input = Paragraph::new(self.user_input.value.as_str()) + .style(Style::default()) + .block(block::Block::bordered().title(" create a new bot ")); + frame.render_widget(user_input, user_input_area); + frame.set_cursor_position(Position::new( + // Draw the cursor at the current position in the input field. + // This position is can be controlled via the left and right arrow key + user_input_area.x + self.user_input.char_idx as u16 + 1, + // Move one line down, from the border to the input line + user_input_area.y + 1, + )); + } + Some(PopupMenu::Error(ref msg)) => { + let vertical = Layout::vertical([Constraint::Max(8)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Max(60)]).flex(Flex::Center); + let [error_menu_area] = vertical.areas(frame.area()); + let [error_menu_area] = horizontal.areas(error_menu_area); + frame.render_widget(Clear, error_menu_area); // clears out the background + + // Render error text. + let error_text = + Paragraph::new(format!("{}\n\n\npress any key to continue", msg.clone())) + .style(Style::default()) + .block( + block::Block::bordered() + .padding(Padding::uniform(1)) + .title(" error "), + ) + .alignment(Alignment::Center); + frame.render_widget(error_text, error_menu_area); + } + Some(PopupMenu::Exit) => { + let vertical = Layout::vertical([Constraint::Max(10)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Max(60)]).flex(Flex::Center); + let [exit_menu_area] = vertical.areas(frame.area()); + let [exit_menu_area] = horizontal.areas(exit_menu_area); + frame.render_widget(Clear, exit_menu_area); // clears out the background + + // Render exit text. + let exit_text = Paragraph::new(EXIT) + .style(Style::default()) + .block(block::Block::bordered().padding(Padding::uniform(1))) + .alignment(Alignment::Center); + frame.render_widget(exit_text, exit_menu_area); + } + None => {} + } + } +} diff --git a/pp_bots/src/app/widgets.rs b/pp_bots/src/app/widgets.rs new file mode 100644 index 0000000..3a431e3 --- /dev/null +++ b/pp_bots/src/app/widgets.rs @@ -0,0 +1,106 @@ +use private_poker::constants::MAX_USER_INPUT_LENGTH; + +/// Manages user inputs at the terminal. +pub struct UserInput { + /// Position of cursor in the input box. + pub char_idx: usize, + /// Current value of the input box. + pub value: String, +} + +impl UserInput { + pub fn backspace(&mut self) { + // Method "remove" is not used on the saved text for deleting the selected char. + // Reason: Using remove on String works on bytes instead of the chars. + // Using remove would require special care because of char boundaries. + if self.char_idx != 0 { + // Getting all characters before the selected character. + let before_char_to_delete = self.value.chars().take(self.char_idx - 1); + // Getting all characters after selected character. + let after_char_to_delete = self.value.chars().skip(self.char_idx); + + // Put all characters together except the selected one. + // By leaving the selected one out, it is forgotten and therefore deleted. + self.value = before_char_to_delete.chain(after_char_to_delete).collect(); + self.move_left(); + } + } + + /// Returns the byte index based on the character position. + /// + /// Since each character in a string can be contain multiple bytes, it's necessary to calculate + /// the byte index based on the index of the character. + fn byte_idx(&self) -> usize { + self.value + .char_indices() + .map(|(i, _)| i) + .nth(self.char_idx) + .unwrap_or(self.value.len()) + } + + pub fn clear(&mut self) { + self.char_idx = 0; + self.value.clear(); + } + + fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { + new_cursor_pos.clamp(0, self.value.chars().count()) + } + + pub fn delete(&mut self) { + // Method "remove" is not used on the saved text for deleting the selected char. + // Reason: Using remove on String works on bytes instead of the chars. + // Using remove would require special care because of char boundaries. + if self.char_idx != self.value.len() { + // Getting all characters before the selected character. + let before_char_to_delete = self.value.chars().take(self.char_idx); + // Getting all characters after selected character. + let after_char_to_delete = self.value.chars().skip(self.char_idx + 1); + + // Put all characters together except the selected one. + // By leaving the selected one out, it is forgotten and therefore deleted. + self.value = before_char_to_delete.chain(after_char_to_delete).collect(); + } + } + + pub fn input(&mut self, new_char: char) { + // Username length is about the same size as the largest allowed + if self.value.len() < MAX_USER_INPUT_LENGTH { + let idx = self.byte_idx(); + self.value.insert(idx, new_char); + self.move_right(); + } + } + + pub fn jump_to_first(&mut self) { + self.char_idx = 0; + } + + pub fn jump_to_last(&mut self) { + self.char_idx = self.value.len(); + } + + pub fn move_left(&mut self) { + let cursor_moved_left = self.char_idx.saturating_sub(1); + self.char_idx = self.clamp_cursor(cursor_moved_left); + } + + pub fn move_right(&mut self) { + let cursor_moved_right = self.char_idx.saturating_add(1); + self.char_idx = self.clamp_cursor(cursor_moved_right); + } + + pub fn new() -> Self { + Self { + char_idx: 0, + value: String::new(), + } + } + + pub fn submit(&mut self) -> String { + let input = self.value.clone(); + self.char_idx = 0; + self.value.clear(); + input + } +} diff --git a/pp_bots/src/main.rs b/pp_bots/src/main.rs index 426b4da..d8c2f6c 100644 --- a/pp_bots/src/main.rs +++ b/pp_bots/src/main.rs @@ -1,24 +1,23 @@ use anyhow::Error; use ctrlc::set_handler; use pico_args::Arguments; -use std::{ - sync::{Arc, Mutex}, - thread, -}; +use std::sync::{Arc, Mutex}; +mod app; mod bot; -use bot::{Bot, QLearning}; +use app::App; +use bot::QLearning; const HELP: &str = "\ Create poker bots and conect them to a private poker server over TCP USAGE: - pp_bots [OPTIONS] BOTNAME1 BOTNAME2 ... + pp_bots [OPTIONS] OPTIONS: --connect IP:PORT Server socket connection address [default: 127.0.0.1:6969] - --alpha ALPHA Q-Learning rate [default: 0.1] - --gamma GAMMA Discount rate [default: 0.95] + --alpha ALPHA Bot Q-Learning rate [default: 0.1] + --gamma GAMMA Bot discount rate [default: 0.95] FLAGS: -h, --help Print help information @@ -28,7 +27,6 @@ struct Args { addr: String, alpha: f32, gamma: f32, - botnames: Vec, } fn main() -> Result<(), Error> { @@ -46,66 +44,14 @@ fn main() -> Result<(), Error> { .unwrap_or("127.0.0.1:6969".into()), alpha: pargs.value_from_str("--alpha").unwrap_or("0.1".parse()?), gamma: pargs.value_from_str("--gamma").unwrap_or("0.95".parse()?), - botnames: pargs - .finish() - .iter() - .map(|s| s.to_str().unwrap().to_string()) - .collect(), }; - if args.botnames.is_empty() { - println!("no botnames provided"); - std::process::exit(0); - } - // Catching signals for exit. set_handler(|| std::process::exit(0))?; let policy = Arc::new(Mutex::new(QLearning::new(args.alpha, args.gamma))); - let workers: Vec<_> = args - .botnames - .into_iter() - .map(|botname| { - thread::spawn({ - let addr = args.addr.clone(); - let policy = policy.clone(); - move || -> Result<(), Error> { - let mut env = Bot::new(&botname, &addr)?; - loop { - let (mut state1, mut masks1) = env.reset()?; - loop { - let action = { - let mut policy = policy.lock().expect("sample lock"); - policy.sample(state1.clone(), masks1.clone()) - }; - let (state2, masks2, reward, done) = env.step(action.clone())?; - if done { - let mut policy = policy.lock().expect("done lock"); - policy.update_done(state1.clone(), action.clone(), reward); - break; - } - { - let mut policy = policy.lock().expect("step lock"); - policy.update_step( - state1.clone(), - action.clone(), - reward, - state2.clone(), - masks2.clone(), - ); - } - state1.clone_from(&state2); - masks1.clone_from(&masks2); - } - } - } - }) - }) - .collect(); - - for worker in workers { - let _ = worker.join(); - } - - Ok(()) + let terminal = ratatui::init(); + let app_result = App::new(args.addr, policy).run(terminal); + ratatui::restore(); + app_result } diff --git a/pp_client/src/app.rs b/pp_client/src/app.rs index fc594a8..05cab36 100644 --- a/pp_client/src/app.rs +++ b/pp_client/src/app.rs @@ -12,7 +12,6 @@ use private_poker::{ }, }; use ratatui::{ - self, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, layout::{Alignment, Constraint, Flex, Layout, Margin, Position}, style::{Style, Stylize},