diff --git a/server/src/bin/client.rs b/server/src/bin/client.rs index d74f60b..815f92c 100644 --- a/server/src/bin/client.rs +++ b/server/src/bin/client.rs @@ -7,12 +7,6 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use url::Url; use uuid::Uuid; -#[derive(Serialize, Deserialize, Debug)] -struct Step { - from: String, - to: String, -} - #[derive(Serialize, Deserialize)] #[serde(tag = "type")] enum ClientMessage { @@ -24,17 +18,6 @@ enum ClientMessage { RequestLegalMoves { fen: String }, } -#[derive(Serialize, Deserialize, Debug)] -struct ServerMessage { - #[serde(rename = "type")] - message_type: String, - player_id: Option, - match_id: Option, - opponent: Option, - color: Option, - reason: Option, -} - #[derive(Serialize, Deserialize)] pub enum ServerMessage2 { GameEnd { @@ -48,6 +31,9 @@ pub enum ServerMessage2 { color: String, opponent_name: String, }, + Ok { + response: Result<(), String>, + }, } #[tokio::main] diff --git a/server/src/connection.rs b/server/src/connection.rs index 828f0cf..246f032 100644 --- a/server/src/connection.rs +++ b/server/src/connection.rs @@ -6,7 +6,6 @@ use engine::{get_available_moves, is_game_over}; use futures_util::{SinkExt, StreamExt}; use log::{error, info, warn}; use serde::{Deserialize, Serialize}; -use std::char::from_u32_unchecked; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use tokio::net::TcpStream; @@ -46,18 +45,6 @@ pub struct Step { pub to: String, } -/*#[derive(Serialize, Deserialize, Debug)] -struct ServerMessage { - #[serde(rename = "type")] - message_type: String, - player_id: Option, - match_id: Option, - opponent: Option, - color: Option, - reason: Option, - response: Option, -}*/ - #[derive(Serialize, Deserialize)] pub enum ServerMessage2 { GameEnd { diff --git a/server/src/main.rs b/server/src/main.rs index e3876a5..1ff68ca 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,8 +1,7 @@ mod connection; mod matchmaking; -use env_logger::{Env, Logger}; -use log::{error, info, warn}; -use std::env; +use env_logger::Env; +use log::{error, info}; use tokio::net::TcpListener; #[tokio::main] diff --git a/server/src/matchmaking.rs b/server/src/matchmaking.rs index b1514c9..c43448a 100644 --- a/server/src/matchmaking.rs +++ b/server/src/matchmaking.rs @@ -1,5 +1,5 @@ use crate::connection::ServerMessage2; -use crate::connection::{ConnectionMap, GameMatch, MatchMap, WaitingQueue, broadcast_to_match}; +use crate::connection::{ConnectionMap, GameMatch, MatchMap, WaitingQueue}; use log::{error, info, warn}; use rand::random; use uuid::Uuid; @@ -26,12 +26,8 @@ impl MatchmakingSystem { } } - pub async fn clean_up(&self, match_id: Uuid) { - self.matches.lock().await.remove(&match_id); - } - async fn try_create_match(&self) { - info!("Checking for new matches!"); + //info!("Checking for new matches!"); let mut queue = self.waiting_queue.lock().await; while queue.len() >= 2 { @@ -72,6 +68,7 @@ impl MatchmakingSystem { } if let Some(player) = conn_map.get_mut(&black_player) { player.current_match = Some(match_id); + //TODO: at the end of a match delete this from player } else { error!("Could not store match id for black player"); } @@ -122,7 +119,7 @@ impl MatchmakingSystem { }; let _ = crate::connection::send_message_to_player_connection( - conn_map.get_mut(&white), + conn_map.get_mut(&black), &serde_json::to_string(&message).unwrap(), ) .await; diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 310b0ce..97f9116 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -6,5 +6,17 @@ edition = "2024" [dependencies] eframe = "0.33.0" egui = "0.33.0" -tokio-tungstenite = "0.28.0" winit = "0.30.12" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = "0.21" +tungstenite = "0.21" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures-util = "0.3.31" +url = "2.5.7" +uuid = {version = "1.18.1", features = ["v4", "serde"] } +engine = {path = "../engine/"} +log = {version = "0.4.28"} +env_logger = "0.11.8" +local-ip-address = "0.6.5" +anyhow = "1.0.100" diff --git a/ui/src/connection.rs b/ui/src/connection.rs new file mode 100644 index 0000000..006c88c --- /dev/null +++ b/ui/src/connection.rs @@ -0,0 +1,117 @@ +use engine::{chessmove::ChessMove, gameend::GameEnd}; +use futures_util::{SinkExt, StreamExt}; +use local_ip_address::local_ip; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::{ + net::{IpAddr, Ipv4Addr}, + sync::{Arc, Mutex}, +}; +use tokio_tungstenite::connect_async; +use tungstenite::Message; +use url::Url; +use uuid::Uuid; + +use crate::{ChessApp, ClientEvent, SharedGameState}; + +#[derive(Serialize, Deserialize)] +pub enum ServerMessage2 { + GameEnd { + winner: GameEnd, + }, + UIUpdate { + fen: String, + }, + MatchFound { + match_id: Uuid, + color: String, + opponent_name: String, + }, + Ok { + response: Result<(), String>, + }, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +enum ClientMessage { + Join { username: String }, + FindMatch, + Move { step: ChessMove, fen: String }, + Resign, + Chat { text: String }, + RequestLegalMoves { fen: String }, +} + +fn get_ip_address() -> IpAddr { + let ip = local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + + ip +} + +pub async fn handle_connection( + server_port: &str, + shared_state: SharedGameState, + ui_events: Arc>>, +) -> anyhow::Result<()> { + let address = get_ip_address(); + + //start main loop + let server_address = String::from("ws://") + &address.to_string() + ":" + server_port; + warn!( + "Machine IpAddress is bound for listener. Ip: {}", + server_address + ); + + let url = Url::parse(&server_address)?; + + let (ws_stream, _) = connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + while let Some(message) = read.next().await { + info!("connection"); + match message { + Ok(msg) => { + if msg.is_text() { + let text = msg.to_text().unwrap(); + info!("text: {}", text); + + /*if let Ok(parsed) = serde_json::from_str::(text) { + match parsed { + ServerMessage2::GameEnd { winner } => {} + ServerMessage2::UIUpdate { fen } => {} + ServerMessage2::MatchFound { + match_id, + color, + opponent_name, + } => { + //chess_app.player_color = Some(color); + } + ServerMessage2::Ok { response } => {} + _ => { + error!("Received unkown servermessage2"); + } + } + }*/ + + if let Ok(parsed) = serde_json::from_str::(text) { + // Update shared state with server message + shared_state.update_from_server_message(parsed); + } + + // Send UI events to server + let events = ui_events.lock().unwrap().drain(..).collect::>(); + for event in events { + let message = serde_json::to_string(&event)?; + write.send(Message::Text(message)).await?; + } + } + } + Err(e) => { + error!("Error receiving message: {}", e); + } + } + } + + Ok(()) +} diff --git a/ui/src/main.rs b/ui/src/main.rs index cc8c7c4..0a12141 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -1,194 +1,359 @@ use eframe::egui; +use engine::{boardsquare::BoardSquare, chessmove::ChessMove}; +use env_logger::Env; +use futures_util::{SinkExt, StreamExt}; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use url::Url; +use uuid::Uuid; -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions{ +#[tokio::main] +async fn main() -> anyhow::Result<(), eframe::Error> { + // Set up logging + let env = Env::default().filter_or("MY_LOG_LEVEL", "INFO"); + env_logger::init_from_env(env); + warn!("Initialized logger"); + + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_fullscreen(true) - .with_min_inner_size(egui::vec2(800.0, 600.0)) // Minimum width, height - .with_inner_size(egui::vec2(7680.0, 4320.0)), // Initial size + .with_fullscreen(false) + .with_min_inner_size(egui::vec2(800.0, 800.0)) + .with_inner_size(egui::vec2(1920.0, 1080.0)), ..Default::default() }; + eframe::run_native( - "Knightly", - options, - Box::new(|cc| { - let mut fonts = egui::FontDefinitions::default(); - fonts.font_data.insert( - "symbols".to_owned(), - egui::FontData::from_static(include_bytes!("../fonts/DejaVuSans.ttf")).into(), - ); - fonts - .families - .entry(egui::FontFamily::Proportional) - .or_default() - .insert(0, "symbols".to_owned()); - cc.egui_ctx.set_fonts(fonts); - Ok(Box::new(ChessApp::default())) - }), -) + "Knightly", + options, + Box::new(|cc| { + let mut fonts = egui::FontDefinitions::default(); + fonts.font_data.insert( + "symbols".to_owned(), + egui::FontData::from_static(include_bytes!("../fonts/DejaVuSans.ttf")).into(), + ); + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, "symbols".to_owned()); + cc.egui_ctx.set_fonts(fonts); + Ok(Box::new(ChessApp::default())) + }), + ) } -#[derive(Clone, Copy, PartialEq, Debug)] -enum Piece { - King(char), - Queen(char), - Rook(char), - Bishop(char), - Knight(char), - Pawn(char), - Empty, +#[derive(Serialize, Deserialize, Debug)] +pub enum ServerMessage2 { + GameEnd { + winner: String, + }, + UIUpdate { + fen: String, + }, + MatchFound { + match_id: Uuid, + color: String, + opponent_name: String, + }, + Ok { + response: Result<(), String>, + }, } -impl Piece { - fn symbol(&self) -> &'static str { - match self { - Piece::King('w') => "♔", - Piece::Queen('w') => "♕", - Piece::Rook('w') => "♖", - Piece::Bishop('w') => "♗", - Piece::Knight('w') => "♘", - Piece::Pawn('w') => "♙", - Piece::King('b') => "♚", - Piece::Queen('b') => "♛", - Piece::Rook('b') => "♜", - Piece::Bishop('b') => "♝", - Piece::Knight('b') => "♞", - Piece::Pawn('b') => "♟︎", - Piece::Empty => "", - _ => "", +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum ClientEvent { + Join { username: String }, + FindMatch, + Move { step: ChessMove }, + Resign, + Chat { text: String }, + RequestLegalMoves { fen: String }, +} + +// Game state +#[derive(Debug, Clone)] +struct GameState { + fen: String, + player_color: Option, + opponent_name: Option, + match_id: Option, + game_over: Option, +} + +impl Default for GameState { + fn default() -> Self { + Self { + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1".to_string(), + player_color: None, + opponent_name: None, + match_id: None, + game_over: None, } } } -#[derive(PartialEq, Debug)] -enum Turn { - White, - Black, -} - +// UI state enum AppState { MainMenu, + Connecting, + FindingMatch, InGame, - Settings, + GameOver, } struct ChessApp { - fullscreen: bool, - resolutions: Vec<(u32, u32)>, - selected_resolution: usize, state: AppState, - board: [[Piece; 8]; 8], - selected: Option<(usize, usize)>, - turn: Turn, - pending_settings: PendingSettings, + game_state: Arc>, server_port: String, -} + username: String, -#[derive(Default)] -struct PendingSettings { - fullscreen: bool, - selected_resolution: usize, - server_port: String, + // Channels for communication with network tasks + tx_to_network: Option>, + rx_from_network: Option>, + + // UI state + selected_square: Option<(usize, usize)>, } impl Default for ChessApp { fn default() -> Self { Self { - fullscreen: true, - resolutions: vec![ - (1280, 720), - (1600, 900), - (1920, 1080), - (2560, 1440), - (3840, 2160), - (7680, 4320), - ], - selected_resolution: 2, // Default to 1920x1080 state: AppState::MainMenu, - board: Self::starting_board(), - selected: None, - turn: Turn::White, - pending_settings: PendingSettings::default(), - server_port: "8080".to_string(), // Default port + game_state: Arc::new(Mutex::new(GameState::default())), + server_port: "9001".to_string(), + username: "Player".to_string(), + tx_to_network: None, + rx_from_network: None, + selected_square: None, } } } impl ChessApp { - fn starting_board() -> [[Piece; 8]; 8] { - use Piece::*; - [ - [ - Rook('b'), - Knight('b'), - Bishop('b'), - Queen('b'), - King('b'), - Bishop('b'), - Knight('b'), - Rook('b'), - ], - [Pawn('b'); 8], - [Empty; 8], - [Empty; 8], - [Empty; 8], - [Empty; 8], - [Pawn('w'); 8], - [ - Rook('w'), - Knight('w'), - Bishop('w'), - Queen('w'), - King('w'), - Bishop('w'), - Knight('w'), - Rook('w'), - ], - ] + fn connect_to_server(&mut self) { + let server_port = self.server_port.clone(); + let username = self.username.clone(); + let game_state = self.game_state.clone(); + + // Create channels for communication + let (tx_to_network, rx_from_ui) = mpsc::unbounded_channel(); + let (tx_to_ui, rx_from_network) = mpsc::unbounded_channel(); + + self.tx_to_network = Some(tx_to_network); + self.rx_from_network = Some(rx_from_network); + + self.state = AppState::Connecting; + + // Spawn network connection task + tokio::spawn(async move { + if let Err(e) = + Self::network_handler(server_port, username, rx_from_ui, tx_to_ui, game_state).await + { + error!("Network handler error: {}", e); + } + }); + } + + async fn network_handler( + server_port: String, + username: String, + mut rx_from_ui: mpsc::UnboundedReceiver, + tx_to_ui: mpsc::UnboundedSender, + game_state: Arc>, + ) -> anyhow::Result<()> { + // Build WebSocket URL + let server_address = format!("ws://127.0.0.1:{}", server_port); + let url = Url::parse(&server_address)?; + + info!("Connecting to: {}", server_address); + let (ws_stream, _) = connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send initial join message and immediately send FindMatch + let join_event = ClientEvent::Join { username }; + write + .send(Message::Text(serde_json::to_string(&join_event)?)) + .await?; + info!("Sent Join event"); + + // Send FindMatch immediately after joining + let find_match_event = ClientEvent::FindMatch; + write + .send(Message::Text(serde_json::to_string(&find_match_event)?)) + .await?; + info!("Sent FindMatch event"); + + // Spawn reader task + let tx_to_ui_clone = tx_to_ui.clone(); + let game_state_clone = game_state.clone(); + let reader_handle = tokio::spawn(async move { + while let Some(message) = read.next().await { + match message { + Ok(msg) if msg.is_text() => { + let text = msg.to_text().unwrap(); + info!("Received: {}", text); + + if let Ok(server_msg) = serde_json::from_str::(text) { + // Update game state + if let Ok(mut state) = game_state_clone.lock() { + match &server_msg { + ServerMessage2::UIUpdate { fen } => { + state.fen = fen.clone(); + } + ServerMessage2::MatchFound { + color, + opponent_name, + match_id, + } => { + state.player_color = Some(color.clone()); + state.opponent_name = Some(opponent_name.clone()); + state.match_id = Some(match_id.clone()); + } + ServerMessage2::GameEnd { winner } => { + state.game_over = Some(winner.clone()); + } + _ => {} + } + } + + // Send to UI + if let Err(e) = tx_to_ui_clone.send(server_msg) { + error!("Failed to send to UI: {}", e); + break; + } + } + } + Err(e) => { + error!("WebSocket error: {}", e); + break; + } + _ => {} + } + } + }); + + // Writer task (main thread) + while let Some(event) = rx_from_ui.recv().await { + let message = serde_json::to_string(&event)?; + write.send(Message::Text(message)).await?; + info!("Sent event to server: {:?}", event); + } + + // Wait for reader to finish + let _ = reader_handle.await; + + Ok(()) } fn handle_click(&mut self, row: usize, col: usize) { - if let Some((r, c)) = self.selected { - let piece = self.board[r][c]; - self.board[r][c] = Piece::Empty; - self.board[row][col] = piece; - self.selected = None; - self.turn = if self.turn == Turn::White { - Turn::Black - } else { - Turn::White - }; + if let Some((from_row, from_col)) = self.selected_square { + // Send move to server + if let Some(tx) = &self.tx_to_network { + // TODO: kinyerni a tenyleges kivalasztott babut + let chess_move = ChessMove::Quiet { + piece_type: engine::piecetype::PieceType::WhiteKing, + from_square: BoardSquare { x: 0, y: 1 }, + to_square: BoardSquare { x: 2, y: 2 }, + promotion_piece: None, + }; + let move_event = ClientEvent::Move { step: chess_move }; + let _ = tx.send(move_event); + } + self.selected_square = None; } else { - if self.board[row][col] != Piece::Empty { - self.selected = Some((row, col)); + // Select square + self.selected_square = Some((row, col)); + } + } + + fn fen_to_board(&self, fen: &str) -> [[char; 8]; 8] { + let mut board = [[' '; 8]; 8]; + let parts: Vec<&str> = fen.split_whitespace().collect(); + let board_str = parts[0]; + + let mut row = 0; + let mut col = 0; + + for c in board_str.chars() { + if c == '/' { + row += 1; + col = 0; + } else if c.is_digit(10) { + col += c.to_digit(10).unwrap() as usize; + } else { + if row < 8 && col < 8 { + board[row][col] = c; + } + col += 1; } } + + board } - fn apply_settings(&mut self, ctx: &egui::Context) { - self.fullscreen = self.pending_settings.fullscreen; - self.selected_resolution = self.pending_settings.selected_resolution; - self.server_port = self.pending_settings.server_port.clone(); - - if let Some(resolution) = self.resolutions.get(self.selected_resolution) { - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize( - egui::Vec2::new(resolution.0 as f32, resolution.1 as f32) - )); + fn chess_char_to_piece(&self, c: char) -> &'static str { + match c { + 'K' => "♚", + 'Q' => "♛", + 'R' => "♜", + 'B' => "♝", + 'N' => "♞", + 'P' => "♟︎", + 'k' => "♚", + 'q' => "♛", + 'r' => "♜", + 'b' => "♝", + 'n' => "♞", + 'p' => "♟︎", + _ => "", } - - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); } - fn enter_settings(&mut self) { - self.pending_settings.fullscreen = self.fullscreen; - self.pending_settings.selected_resolution = self.selected_resolution; - self.pending_settings.server_port = self.server_port.clone(); - self.state = AppState::Settings; + fn process_network_messages(&mut self) { + if let Some(rx) = &mut self.rx_from_network { + while let Ok(msg) = rx.try_recv() { + match msg { + ServerMessage2::MatchFound { .. } => { + info!("Match found! Transitioning to InGame state"); + self.state = AppState::InGame; + } + ServerMessage2::GameEnd { .. } => { + info!("Game over! Transitioning to GameOver state"); + self.state = AppState::GameOver; + } + ServerMessage2::Ok { response } => { + info!("Server OK response: {:?}", response); + // When we get the OK response, transition to FindingMatch state + // This shows the "Finding Match..." screen while we wait + if matches!(self.state, AppState::Connecting) { + self.state = AppState::FindingMatch; + } + } + ServerMessage2::UIUpdate { fen } => { + info!("Board updated with FEN: {}", fen); + // UI will automatically redraw with new FEN + if let Ok(mut game_state) = self.game_state.lock() { + game_state.fen = fen; + } + } + } + } + } } } impl eframe::App for ChessApp { - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Process incoming network messages + self.process_network_messages(); + + // Get current game state + let game_state = self.game_state.lock().unwrap().clone(); + match self.state { AppState::MainMenu => { egui::CentralPanel::default().show(ctx, |ui| { @@ -196,174 +361,167 @@ impl eframe::App for ChessApp { ui.heading("♞ Knightly ♞"); ui.add_space(30.0); - if ui.add_sized([300.0, 60.0], egui::Button::new("Play")).clicked() { - self.state = AppState::InGame; + ui.horizontal(|ui| { + ui.label("Username:"); + ui.text_edit_singleline(&mut self.username); + }); + + ui.horizontal(|ui| { + ui.label("Server Port:"); + ui.text_edit_singleline(&mut self.server_port); + }); + + ui.add_space(20.0); + + if ui.button("Connect & Play").clicked() { + self.connect_to_server(); } - ui.add_space(8.0); - - if ui.add_sized([300.0, 60.0], egui::Button::new("Settings")).clicked() { - self.enter_settings(); - } - ui.add_space(8.0); - - if ui - .add_sized([300.0, 60.0], egui::Button::new("Quit")) - .clicked() - { + + if ui.button("Quit").clicked() { std::process::exit(0); } }); }); } - AppState::Settings => { + AppState::Connecting => { egui::CentralPanel::default().show(ctx, |ui| { + // TODO: go back to menu btn ui.vertical_centered(|ui| { - ui.heading("Settings"); - ui.add_space(30.0); + ui.heading("Connecting to Server..."); + ui.add_space(20.0); + ui.spinner(); + }); + }); + } - // Fullscreen toggle - ui.horizontal(|ui| { - ui.label("Fullscreen:"); - if ui.checkbox(&mut self.pending_settings.fullscreen, "").changed() { - // If enabling fullscreen, we might want to disable resolution selection - } - }); - ui.add_space(10.0); + AppState::FindingMatch => { + egui::CentralPanel::default().show(ctx, |ui| { + // TODO: go back to menu btn + // TODO: how do we delete the player from the waiting queue + ui.vertical_centered(|ui| { + ui.heading("Finding Match..."); + ui.add_space(20.0); + ui.label("Waiting for an opponent..."); + ui.spinner(); - // Resolution dropdown - ui.horizontal(|ui| { - ui.label("Resolution:"); - egui::ComboBox::new("resolution_combo", "") - .selected_text(format!( - "{}x{}", - self.resolutions[self.pending_settings.selected_resolution].0, - self.resolutions[self.pending_settings.selected_resolution].1 - )) - .show_ui(ui, |ui| { - for (i, &(width, height)) in self.resolutions.iter().enumerate() { - ui.selectable_value( - &mut self.pending_settings.selected_resolution, - i, - format!("{}x{}", width, height), - ); - } - }); - }); - ui.add_space(10.0); - - // Server port input field - ui.horizontal(|ui| { - ui.label("Local Server Port:"); - ui.add(egui::TextEdit::singleline(&mut self.pending_settings.server_port) - .desired_width(100.0) - .hint_text("8080")); - }); - ui.add_space(30.0); - - // Apply and Cancel buttons - ui.horizontal(|ui| { - if ui.add_sized([140.0, 40.0], egui::Button::new("Apply")).clicked() { - self.apply_settings(ctx); - self.state = AppState::MainMenu; - } - - if ui.add_sized([140.0, 40.0], egui::Button::new("Cancel")).clicked() { - self.state = AppState::MainMenu; - } - }); + ui.add_space(20.0); + if ui.button("cancel").clicked() { + std::process::exit(0); + } }); }); } AppState::InGame => { + // Draw menu bar egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { ui.horizontal(|ui| { if ui.button("Main Menu").clicked() { - self.state = AppState::MainMenu; - } - if ui.button("Settings").clicked() { - self.enter_settings(); - } - if ui.button("New Game").clicked() { *self = ChessApp::default(); - self.state = AppState::InGame; } + + if ui.button("Resign").clicked() { + if let Some(tx) = &self.tx_to_network { + let _ = tx.send(ClientEvent::Resign); + } + } + ui.separator(); - ui.label(format!("Turn: {:?}", self.turn)); + + if let Some(color) = &game_state.player_color { + ui.label(format!("You are: {}", color)); + } + + if let Some(opponent) = &game_state.opponent_name { + ui.label(format!("vs: {}", opponent)); + } }); }); - + + // Draw chess board egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { - let full_avail = ui.available_rect_before_wrap(); - let board_tile = (full_avail.width().min(full_avail.height())) / 8.0; - let board_size = board_tile * 8.0; - - // Create a child UI at the board position + let board = self.fen_to_board(&game_state.fen); + let is_white = game_state + .player_color + .as_ref() + .map_or(true, |c| c == "white"); + + let available_size = ui.available_size(); + let board_size = available_size.x.min(available_size.y) * 0.9; + let tile_size = board_size / 8.0; + let (response, painter) = ui.allocate_painter( egui::Vec2::new(board_size, board_size), - egui::Sense::click() + egui::Sense::click(), ); - - let board_rect = egui::Rect::from_center_size( - full_avail.center(), - egui::vec2(board_size, board_size) - ); - - // Draw the chess board - let tile_size = board_size / 8.0; + + let board_top_left = response.rect.left_top(); + + // Draw board and pieces for row in 0..8 { for col in 0..8 { - let color = if (row + col) % 2 == 0 { - egui::Color32::from_rgb(100, 97, 97) + let (display_row, display_col) = if is_white { + (row, col) } else { - egui::Color32::from_rgb(217, 217, 217) + (7 - row, 7 - col) }; - + + let color = if (row + col) % 2 == 0 { + egui::Color32::from_rgb(240, 217, 181) // Light + } else { + egui::Color32::from_rgb(181, 136, 99) // Dark + }; + let rect = egui::Rect::from_min_size( egui::Pos2::new( - board_rect.min.x + col as f32 * tile_size, - board_rect.min.y + row as f32 * tile_size + board_top_left.x + col as f32 * tile_size, + board_top_left.y + row as f32 * tile_size, ), - egui::Vec2::new(tile_size, tile_size) + egui::Vec2::new(tile_size, tile_size), ); - + painter.rect_filled(rect, 0.0, color); - + // Draw piece - let piece = self.board[row][col]; - if piece != Piece::Empty { - let symbol = piece.symbol(); - let font_id = egui::FontId::proportional(tile_size * 0.75); + let piece_char = board[display_row][display_col]; + if piece_char != ' ' { + let symbol = self.chess_char_to_piece(piece_char); + let font_id = egui::FontId::proportional(tile_size * 0.8); + let text_color = if piece_char.is_uppercase() { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + painter.text( rect.center(), egui::Align2::CENTER_CENTER, symbol, font_id, - if matches!(piece, Piece::King('w') | Piece::Queen('w') | Piece::Rook('w') | Piece::Bishop('w') | Piece::Knight('w') | Piece::Pawn('w')) { - egui::Color32::WHITE - } else { - egui::Color32::BLACK - } + text_color, ); } - - // Draw selection highlight - if self.selected == Some((row, col)) { - painter.rect_stroke( - rect, - 0.0, - egui::Stroke::new(3.0, egui::Color32::RED), - egui::StrokeKind::Inside - ); + + // Draw selection + if let Some((sel_row, sel_col)) = self.selected_square { + if sel_row == display_row && sel_col == display_col { + painter.rect_stroke( + rect, + 0.0, + egui::Stroke::new(3.0, egui::Color32::RED), + egui::StrokeKind::Middle, + ); + } } - + // Handle clicks - if ui.ctx().input(|i| i.pointer.primary_clicked()) { - let click_pos = ui.ctx().input(|i| i.pointer.interact_pos()).unwrap(); - if rect.contains(click_pos) { - self.handle_click(row, col); + if response.clicked() { + if let Some(click_pos) = ui.ctx().pointer_interact_pos() { + if rect.contains(click_pos) { + self.handle_click(display_row, display_col); + } } } } @@ -371,61 +529,375 @@ impl eframe::App for ChessApp { }); }); } + + AppState::GameOver => { + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.heading("Game Over"); + ui.add_space(20.0); + + if let Some(reason) = &game_state.game_over { + ui.label(format!("Result: {}", reason)); + } + + ui.add_space(20.0); + + if ui.button("Back to Main Menu").clicked() { + *self = ChessApp::default(); + } + }); + }); + } } + + // Request repaint to keep UI responsive + ctx.request_repaint(); } } - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_initial_board_setup() { - let app = ChessApp::default(); - assert!(matches!(app.board[0][0], Piece::Rook('b'))); - assert!(matches!(app.board[7][0], Piece::Rook('w'))); - assert!(matches!(app.board[1][0], Piece::Pawn('b'))); - assert!(matches!(app.board[6][0], Piece::Pawn('w'))); - } - #[test] - fn test_piece_symbols() { - assert_eq!(Piece::King('w').symbol(), "♔"); - assert_eq!(Piece::King('b').symbol(), "♚"); - assert_eq!(Piece::Empty.symbol(), ""); - } - - #[test] - fn test_piece_selection() { - let mut app = ChessApp::default(); - app.handle_click(6, 0); - assert_eq!(app.selected, Some((6, 0))); - app.handle_click(6, 0); - assert_eq!(app.selected, None); - } - - #[test] - fn test_piece_movement() { - let mut app = ChessApp::default(); - // Select and move a piece - app.handle_click(6, 0); // Select white pawn - app.handle_click(5, 0); // Move to empty square - assert_eq!(app.board[6][0], Piece::Empty); - assert!(matches!(app.board[5][0], Piece::Pawn('w'))); + fn test_default_game_state() { + let game_state = GameState::default(); + assert_eq!( + game_state.fen, + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + ); + assert_eq!(game_state.player_color, None); + assert_eq!(game_state.opponent_name, None); + assert_eq!(game_state.match_id, None); + assert_eq!(game_state.game_over, None); } #[test] - fn test_turn_switching() { - let mut app = ChessApp::default(); - assert_eq!(app.turn, Turn::White); - app.handle_click(6, 0); // White selects - app.handle_click(5, 0); // White moves - assert_eq!(app.turn, Turn::Black); // Should now be Black's turn - } - - #[test] - fn test_server_port_default() { + fn test_fen_to_board_starting_position() { let app = ChessApp::default(); - assert_eq!(app.server_port, "8080"); + let board = app.fen_to_board("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"); + + // Test black pieces on rank 0 + assert_eq!(board[0][0], 'r'); + assert_eq!(board[0][1], 'n'); + assert_eq!(board[0][2], 'b'); + assert_eq!(board[0][3], 'q'); + assert_eq!(board[0][4], 'k'); + assert_eq!(board[0][5], 'b'); + assert_eq!(board[0][6], 'n'); + assert_eq!(board[0][7], 'r'); + + // Test black pawns on rank 1 + for col in 0..8 { + assert_eq!(board[1][col], 'p'); + } + + // Test empty squares in the middle + for row in 2..6 { + for col in 0..8 { + assert_eq!(board[row][col], ' '); + } + } + + // Test white pawns on rank 6 + for col in 0..8 { + assert_eq!(board[6][col], 'P'); + } + + // Test white pieces on rank 7 + assert_eq!(board[7][0], 'R'); + assert_eq!(board[7][1], 'N'); + assert_eq!(board[7][2], 'B'); + assert_eq!(board[7][3], 'Q'); + assert_eq!(board[7][4], 'K'); + assert_eq!(board[7][5], 'B'); + assert_eq!(board[7][6], 'N'); + assert_eq!(board[7][7], 'R'); + } + + #[test] + fn test_fen_to_board_with_numbers() { + let app = ChessApp::default(); + let board = app.fen_to_board("4k3/8/8/8/8/8/8/4K3"); + + // Test empty squares around kings + for row in 0..8 { + for col in 0..8 { + if (row == 0 && col == 4) || (row == 7 && col == 4) { + continue; // Skip king positions + } + assert_eq!(board[row][col], ' '); + } + } + + // Test king positions + assert_eq!(board[0][4], 'k'); // black king + assert_eq!(board[7][4], 'K'); // white king + } + + #[test] + fn test_chess_char_to_piece() { + let app = ChessApp::default(); + + // Test white pieces + assert_eq!(app.chess_char_to_piece('K'), "♚"); + assert_eq!(app.chess_char_to_piece('Q'), "♛"); + assert_eq!(app.chess_char_to_piece('R'), "♜"); + assert_eq!(app.chess_char_to_piece('B'), "♝"); + assert_eq!(app.chess_char_to_piece('N'), "♞"); + assert_eq!(app.chess_char_to_piece('P'), "♟︎"); + + // Test black pieces + assert_eq!(app.chess_char_to_piece('k'), "♚"); + assert_eq!(app.chess_char_to_piece('q'), "♛"); + assert_eq!(app.chess_char_to_piece('r'), "♜"); + assert_eq!(app.chess_char_to_piece('b'), "♝"); + assert_eq!(app.chess_char_to_piece('n'), "♞"); + assert_eq!(app.chess_char_to_piece('p'), "♟︎"); + + // Test invalid piece + assert_eq!(app.chess_char_to_piece('X'), ""); + assert_eq!(app.chess_char_to_piece(' '), ""); + } + + #[test] + fn test_chess_app_default() { + let app = ChessApp::default(); + + assert_eq!(app.server_port, "9001"); + assert_eq!(app.username, "Player"); + assert!(app.tx_to_network.is_none()); + assert!(app.rx_from_network.is_none()); + assert!(app.selected_square.is_none()); + + // Verify initial state is MainMenu + match app.state { + AppState::MainMenu => (), + _ => panic!("Expected initial state to be MainMenu"), + } + } + + #[test] + fn test_game_state_clone() { + let mut original = GameState::default(); + original.player_color = Some("white".to_string()); + original.opponent_name = Some("Opponent".to_string()); + original.match_id = Some(Uuid::new_v4()); + original.game_over = Some("Checkmate".to_string()); + + let cloned = original.clone(); + + assert_eq!(original.fen, cloned.fen); + assert_eq!(original.player_color, cloned.player_color); + assert_eq!(original.opponent_name, cloned.opponent_name); + assert_eq!(original.match_id, cloned.match_id); + assert_eq!(original.game_over, cloned.game_over); + } + + #[test] + fn test_handle_click_selection() { + let mut app = ChessApp::default(); + + // Initially no square should be selected + assert_eq!(app.selected_square, None); + + // Click on a square should select it + app.handle_click(3, 4); + assert_eq!(app.selected_square, Some((3, 4))); + + // Click on another square should deselect and send move (if tx exists) + // Since we don't have a real tx in tests, we just verify the selection is cleared + app.handle_click(4, 4); + assert_eq!(app.selected_square, None); + } + + #[test] + fn test_process_network_messages_match_found() { + let mut app = ChessApp::default(); + let (tx, mut rx) = mpsc::unbounded_channel(); + app.rx_from_network = Some(rx); + + // Send a MatchFound message + let match_id = Uuid::new_v4(); + let message = ServerMessage2::MatchFound { + match_id, + color: "white".to_string(), + opponent_name: "TestOpponent".to_string(), + }; + + tx.send(message).unwrap(); + + // Process the message + app.process_network_messages(); + + // State should transition to InGame + match app.state { + AppState::InGame => (), + _ => panic!("Expected state to transition to InGame"), + } + } + + #[test] + fn test_process_network_messages_game_over() { + let mut app = ChessApp::default(); + let (tx, mut rx) = mpsc::unbounded_channel(); + app.rx_from_network = Some(rx); + + // Send a GameEnd message + let message = ServerMessage2::GameEnd { + winner: "White won by checkmate".to_string(), + }; + + tx.send(message).unwrap(); + + // Process the message + app.process_network_messages(); + + // State should transition to GameOver + match app.state { + AppState::GameOver => (), + _ => panic!("Expected state to transition to GameOver"), + } + } + + #[test] + fn test_process_network_messages_ui_update() { + let mut app = ChessApp::default(); + let (tx, mut rx) = mpsc::unbounded_channel(); + app.rx_from_network = Some(rx); + + // Send a UIUpdate message + let new_fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1".to_string(); + let message = ServerMessage2::UIUpdate { + fen: new_fen.clone(), + }; + + tx.send(message).unwrap(); + + // Process the message + app.process_network_messages(); + + // Game state should be updated with new FEN + let game_state = app.game_state.lock().unwrap(); + assert_eq!(game_state.fen, new_fen); + } + + #[test] + fn test_process_network_messages_ok_response() { + let mut app = ChessApp::default(); + app.state = AppState::Connecting; + let (tx, mut rx) = mpsc::unbounded_channel(); + app.rx_from_network = Some(rx); + + // Send an Ok message + let message = ServerMessage2::Ok { response: Ok(()) }; + + tx.send(message).unwrap(); + + // Process the message + app.process_network_messages(); + + // State should transition to FindingMatch when in Connecting state + match app.state { + AppState::FindingMatch => (), + _ => panic!("Expected state to transition to FindingMatch"), + } + } + + #[test] + fn test_fen_edge_cases() { + let app = ChessApp::default(); + + // Test empty board + let empty_board = app.fen_to_board("8/8/8/8/8/8/8/8"); + for row in 0..8 { + for col in 0..8 { + assert_eq!(empty_board[row][col], ' '); + } + } + + // Test FEN with multiple digit numbers + let board = app.fen_to_board("k7/8/8/8/8/8/8/7K"); + assert_eq!(board[0][0], 'k'); + assert_eq!(board[7][7], 'K'); + + // Test FEN with mixed pieces and numbers + let board = app.fen_to_board("r3k2r/8/8/8/8/8/8/R3K2R"); + assert_eq!(board[0][0], 'r'); + assert_eq!(board[0][4], 'k'); + assert_eq!(board[0][7], 'r'); + assert_eq!(board[7][0], 'R'); + assert_eq!(board[7][4], 'K'); + assert_eq!(board[7][7], 'R'); + } + + #[tokio::test] + async fn test_client_event_serialization() { + // Test Join event + let join_event = ClientEvent::Join { + username: "test".to_string(), + }; + let serialized = serde_json::to_string(&join_event).unwrap(); + assert!(serialized.contains("Join")); + assert!(serialized.contains("test")); + + // Test FindMatch event + let find_match_event = ClientEvent::FindMatch; + let serialized = serde_json::to_string(&find_match_event).unwrap(); + assert!(serialized.contains("FindMatch")); + + // Test Move event + let chess_move = ChessMove::Quiet { + piece_type: engine::piecetype::PieceType::WhiteKing, + from_square: BoardSquare { x: 0, y: 1 }, + to_square: BoardSquare { x: 2, y: 2 }, + promotion_piece: None, + }; + let move_event = ClientEvent::Move { step: chess_move }; + let serialized = serde_json::to_string(&move_event).unwrap(); + assert!(serialized.contains("Move")); + + // Test Resign event + let resign_event = ClientEvent::Resign; + let serialized = serde_json::to_string(&resign_event).unwrap(); + assert!(serialized.contains("Resign")); + } + + #[test] + fn test_server_message_deserialization() { + // Test Ok message + let ok_json = r#"{"Ok":{"response":{"Ok":null}}}"#; + let message: ServerMessage2 = serde_json::from_str(ok_json).unwrap(); + match message { + ServerMessage2::Ok { response } => { + assert!(response.is_ok()); + } + _ => panic!("Expected Ok message"), + } + + // Test MatchFound message + let match_found_json = r#"{"MatchFound":{"match_id":"12345678-1234-1234-1234-123456789012","color":"white","opponent_name":"Test"}}"#; + let message: ServerMessage2 = serde_json::from_str(match_found_json).unwrap(); + match message { + ServerMessage2::MatchFound { + match_id, + color, + opponent_name, + } => { + assert_eq!(color, "white"); + assert_eq!(opponent_name, "Test"); + } + _ => panic!("Expected MatchFound message"), + } + + // Test UIUpdate message + let ui_update_json = + r#"{"UIUpdate":{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"}}"#; + let message: ServerMessage2 = serde_json::from_str(ui_update_json).unwrap(); + match message { + ServerMessage2::UIUpdate { fen } => { + assert!(fen.contains("rnbqkbnr")); + } + _ => panic!("Expected UIUpdate message"), + } } }