Skip to content

Commit

Permalink
add poker bot tui
Browse files Browse the repository at this point in the history
  • Loading branch information
theOGognf committed Oct 13, 2024
1 parent c188629 commit 9b14c39
Show file tree
Hide file tree
Showing 7 changed files with 381 additions and 66 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions pp_bots/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
260 changes: 260 additions & 0 deletions pp_bots/src/app.rs
Original file line number Diff line number Diff line change
@@ -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<Result<(), Error>>);

fn worker(
mut env: Bot,
policy: Arc<Mutex<QLearning>>,
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<Mutex<QLearning>>,
workers: Vec<Worker>,
table_state: TableState,
user_input: UserInput,
popup_menu: Option<PopupMenu>,
}

impl App {
pub fn new(addr: String, policy: Arc<Mutex<QLearning>>) -> 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 => {}
}
}
}
106 changes: 106 additions & 0 deletions pp_bots/src/app/widgets.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit 9b14c39

Please sign in to comment.