diff --git a/.github/workflows/dispatcher.yml b/.github/workflows/dispatcher.yml index 6532bfd..cb168d7 100644 --- a/.github/workflows/dispatcher.yml +++ b/.github/workflows/dispatcher.yml @@ -16,7 +16,9 @@ jobs: server: ${{ steps.check.outputs.server }} ui: ${{ steps.check.outputs.ui }} steps: - - uses: actions/checkout@v4 + - name: checkout repository + uses: actions/checkout@v4 + - name: Determine which tests to run id: check run: | diff --git a/Docs/Knightly rendszerterv v1.png b/Docs/Knightly rendszerterv v1.png new file mode 100644 index 0000000..9826e93 Binary files /dev/null and b/Docs/Knightly rendszerterv v1.png differ diff --git a/Docs/Knightly rendszerterv v1.xml b/Docs/Knightly rendszerterv v1.xml new file mode 100644 index 0000000..063e792 --- /dev/null +++ b/Docs/Knightly rendszerterv v1.xml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/engine/src/bitboard.rs b/engine/src/bitboard.rs index 67d128d..504ae04 100644 --- a/engine/src/bitboard.rs +++ b/engine/src/bitboard.rs @@ -1,2 +1,7 @@ mod attackmaps; -mod utils; \ No newline at end of file +mod utils; +mod legality; +mod checkinfo; +mod attacks; + +pub mod board; \ No newline at end of file diff --git a/engine/src/bitboard/attacks.rs b/engine/src/bitboard/attacks.rs new file mode 100644 index 0000000..a348d96 --- /dev/null +++ b/engine/src/bitboard/attacks.rs @@ -0,0 +1,89 @@ +use super::board::Board; +use super::attackmaps::*; + +impl Board { + + const RANK_2: u64 = 0x0000_0000_0000_FF00; + const RANK_7: u64 = 0x00FF_0000_0000_0000; + + pub fn get_pseudo_pawn_moves(&self, sq: u32) -> u64 { + let pawn: u64 = 1 << sq; + let mut move_mask: u64 = 0u64; + let move_offset: i8 = 8 - 16 * self.side_to_move as i8; + + let next_sq: u64 = if move_offset > 0 {pawn << move_offset} else {pawn >> -move_offset}; + if (self.occupancy[2] & next_sq) == 0 { + move_mask |= next_sq; + + if (self.side_to_move == 0 && pawn & Self::RANK_2 != 0) + || (self.side_to_move == 1 && pawn & Self::RANK_7 != 0) { + + let next_sq: u64 = if move_offset > 0 {next_sq << move_offset} else {next_sq >> -move_offset}; + if (self.occupancy[2] & next_sq) == 0 { + move_mask |= next_sq; + } + } + } + + return move_mask; + } + #[inline] + pub fn get_pseudo_knight_moves(&self, sq: u32) -> u64 { + return KNIGHT_ATTACK_MAP[sq as usize]; + } + #[inline] + pub fn get_pseudo_king_moves(&self, sq: u32) -> u64 { + return KING_ATTACK_MAP[sq as usize]; + } + #[inline] + pub fn get_pseudo_pawn_captures(&self, sq: u32) -> u64 { + return PAWN_ATTACK_MAP[sq as usize][self.side_to_move as usize]; + } + #[inline] + pub fn get_pseudo_opponent_pawn_captures(&self, sq: u32) -> u64 { + return PAWN_ATTACK_MAP[sq as usize][1 - self.side_to_move as usize]; + } + #[inline] + pub fn get_pseudo_bishop_moves(&self, sq: u32) -> u64 { + let mut moves = 0u64; + let sq = sq as usize; + let occupancy = self.occupancy[2]; + moves |= get_raycast_from_square_in_direction(occupancy, sq, 1); + moves |= get_raycast_from_square_in_direction(occupancy, sq, 3); + moves |= get_raycast_from_square_in_direction(occupancy, sq, 5); + moves |= get_raycast_from_square_in_direction(occupancy, sq, 7); + + return moves; + } + #[inline] + pub fn get_pseudo_rook_moves(&self, sq: u32) -> u64 { + let mut moves: u64 = 0u64; + let occupancy = self.occupancy[2]; + let sq = sq as usize; + moves |= get_raycast_from_square_in_direction(occupancy, sq, 0); + moves |= get_raycast_from_square_in_direction(occupancy, sq, 2); + moves |= get_raycast_from_square_in_direction(occupancy, sq, 4); + moves |= get_raycast_from_square_in_direction(occupancy, sq, 6); + + return moves; + } + #[inline(always)] + pub fn get_pseudo_queen_moves(&self, sq: u32) -> u64 { + return self.get_pseudo_bishop_moves(sq) | self.get_pseudo_rook_moves(sq); + } +} + +#[inline(always)] +pub fn get_raycast_from_square_in_direction(occupancy: u64, sq: usize, dir: usize) -> u64 { + let is_up: bool = dir / 4 == 0; + let mut ray: u64 = RAY_TABLE[sq][dir]; + let blockers: u64 = occupancy & ray; + + if blockers != 0 { + let first_blocker: u32 = if is_up { blockers.trailing_zeros() } else { 63 - blockers.leading_zeros() }; + + ray &= !RAY_TABLE[first_blocker as usize][dir]; + } + + return ray; +} \ No newline at end of file diff --git a/engine/src/bitboard/board.rs b/engine/src/bitboard/board.rs new file mode 100644 index 0000000..11a85ce --- /dev/null +++ b/engine/src/bitboard/board.rs @@ -0,0 +1,182 @@ +use super::utils::try_get_square_number_from_notation; + +pub struct Board { + pub(in super) bitboards: [u64; 12], // 0-5 -> white pieces (P, N, B, R, Q, K), 6-11 -> black pieces (p, n, b, r, q, k) + pub(in super) piece_board: [u8; 64], // same as board indexes, 12 -> empty square + pub(in super) occupancy: [u64; 3], // 0 -> white, 1 -> black, 2 -> combined + pub(in super) castling_rights: u8, // 0b0000_KQkq + pub(in super) pinned_squares: [u8; 64], // 0 -> E-W, 1 -> NE-SW, 2 -> N-S, 3 -> SE-NW, 4 -> no pin + pub(in super) pin_mask: u64, // 1 -> pin, 0 -> no pin + pub(in super) en_passant_square: u64, // 1 -> ep square, 0 -> no ep square + pub(in super) side_to_move: u8 // 0 -> white to play, 1 -> black to play +} + +impl Board { + + pub fn new_clear() -> Self { + let mut bit_board: Self = Self { + bitboards: [0x0000_0000_0000_0000; 12], + piece_board: [12; 64], + occupancy: [0x0000_0000_0000_0000; 3], + castling_rights: 0b0000_0000, + pinned_squares: [4; 64], + pin_mask: 0u64, + en_passant_square: 0x0000_0000_0000_0000, + side_to_move: 0 + }; + + return bit_board; + } + pub fn new() -> Self { + let mut bit_board: Board = Self { + bitboards: [0x0000_0000_0000_FF00, + 0x0000_0000_0000_0042, + 0x0000_0000_0000_0024, + 0x0000_0000_0000_0081, + 0x0000_0000_0000_0008, + 0x0000_0000_0000_0010, + 0x00FF_0000_0000_0000, + 0x4200_0000_0000_0000, + 0x2400_0000_0000_0000, + 0x8100_0000_0000_0000, + 0x0800_0000_0000_0000, + 0x1000_0000_0000_0000], + piece_board: [12; 64], + occupancy: [0; 3], + castling_rights: 0b0000_1111, + pinned_squares: [4; 64], + pin_mask: 0u64, + en_passant_square: 0x0000_0000_0000_0000, + side_to_move: 0 + }; + bit_board.calc_occupancy(); + bit_board.calc_piece_board(); + + return bit_board; + } + pub fn build(fen: &str) -> Self { + let mut board: Board = Board::new_clear(); + + let mut col: i32 = 0; + let mut row: i32 = 7; + let pieces: [char; 12] = ['p', 'n', 'b', 'r', 'q', 'k', 'P', 'N', 'B', 'R', 'Q', 'K']; + let mut coming_up: &str = fen; + + for (i, c) in coming_up.chars().enumerate() { + if pieces.contains(&c) { + // board.place_piece(row*8 + col, c); + col += 1; + } + else if ('1'..='8').contains(&c) { + col += c.to_string().parse::().unwrap(); + } + else if c == '/' { + row -= 1; + col = 0; + } + else { + coming_up = &coming_up[i+1..]; + break; + } + } + board.calc_occupancy(); + + match coming_up.chars().next().unwrap() { + 'w' => board.side_to_move = 0, + 'b' => board.side_to_move = 1, + _ => panic!("invalid fen notation / to be handled later") + } + coming_up = &coming_up[2..]; + + for (i, c) in coming_up.chars().enumerate() { + match c { + 'K' => board.castling_rights |= 1 << 3, + 'Q' => board.castling_rights |= 1 << 2, + 'k' => board.castling_rights |= 1 << 1, + 'q' => board.castling_rights |= 1, + '-' => { + coming_up = &coming_up[i+2..]; + break; + } + _ => { + coming_up = &coming_up[i+1..]; + break; + } + } + } + match coming_up.chars().next().unwrap() { + '-' => { + coming_up = &coming_up[1..]; + } + _ => { + let notation = coming_up.split(' ').next().unwrap(); + if let Ok(epsq_index) = try_get_square_number_from_notation(notation) { + board.en_passant_square = 1 << epsq_index; + } + } + } + board.calc_pinned_squares(); + board.calc_piece_board(); + + return board; + } + + #[inline(always)] + pub fn bitboards(&self, index: usize) -> u64 { + return self.bitboards[index]; + } + #[inline(always)] + pub fn piece_board(&self, sq: u8) -> u8 { + return self.piece_board[sq as usize]; + } + #[inline(always)] + pub fn occupancy(&self, side: usize) -> u64 { + return self.occupancy[side]; + } + #[inline(always)] + pub fn castling_rights(&self) -> u8 { + return self.castling_rights; + } + #[inline(always)] + pub fn pinned_squares(&self, sq: usize) -> u8 { + return self.pinned_squares[sq]; + } + #[inline(always)] + pub fn pin_mask(&self) -> u64 { + return self.pin_mask; + } + #[inline(always)] + pub fn en_passant_square(&self) -> u64 { + return self.en_passant_square; + } + #[inline(always)] + pub fn side_to_move(&self) -> u8 { + return self.side_to_move; + } + + #[inline(always)] + pub fn current_king_square(&self) -> u32 { + return if self.side_to_move == 0 { self.bitboards[5].trailing_zeros() } else { self.bitboards[11].trailing_zeros() }; + } + + fn calc_occupancy(&mut self) { + self.occupancy = [0u64; 3]; + for b in 0..6 { + self.occupancy[0] |= self.bitboards[b]; + } + for b in 6..12 { + self.occupancy[1] |= self.bitboards[b]; + } + self.occupancy[2] = self.occupancy[0] | self.occupancy[1]; + } + fn calc_piece_board(&mut self) { + for sq in 0..64 { + for b in 0..12 { + if (self.bitboards[b as usize] & 1 << sq) != 0 { + self.piece_board[sq] = b; + } + } + } + } + +} \ No newline at end of file diff --git a/engine/src/bitboard/checkinfo.rs b/engine/src/bitboard/checkinfo.rs new file mode 100644 index 0000000..b145321 --- /dev/null +++ b/engine/src/bitboard/checkinfo.rs @@ -0,0 +1,21 @@ + +pub struct CheckInfo { + pub check_count: u8, + pub move_mask: u64 +} + +impl CheckInfo { + + pub fn new() -> Self { + return Self { + check_count: 0, + move_mask: 0xFFFF_FFFF_FFFF_FFFF + } + } + + #[inline(always)] + pub fn add_checker(&mut self, move_mask: u64) { + self.move_mask &= move_mask; + self.check_count += 1; + } +} \ No newline at end of file diff --git a/engine/src/bitboard/legality.rs b/engine/src/bitboard/legality.rs new file mode 100644 index 0000000..7d12e57 --- /dev/null +++ b/engine/src/bitboard/legality.rs @@ -0,0 +1,48 @@ +use super::board::Board; +use super::attackmaps::RAY_TABLE; + +impl Board { + + pub(in super) fn calc_pinned_squares(&mut self) { + self.pinned_squares = [4; 64]; + self.pin_mask = 0u64; + + let friendly_pieces: u64 = self.occupancy[self.side_to_move as usize]; + let offset: usize = 6 * self.side_to_move as usize; + let king_board: u64 = self.bitboards[5 + offset]; + let king_sq: u32 = king_board.trailing_zeros(); + let opponent_queen_bishop_mask: u64 = self.bitboards[8 - offset] | self.bitboards[10 - offset]; + let opponent_queen_rook_mask: u64 = self.bitboards[9 - offset] | self.bitboards[10 - offset]; + + // Queen-Rook directions + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_rook_mask, 0); + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_rook_mask, 2); + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_rook_mask, 4); + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_rook_mask, 6); + + // Queen-Bishop directions + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_bishop_mask, 1); + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_bishop_mask, 3); + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_bishop_mask, 5); + self.set_pinned_in_ray_direction(king_sq, friendly_pieces, opponent_queen_bishop_mask, 7); + } + + pub(in super) fn set_pinned_in_ray_direction(&mut self, king_sq: u32, friendly_pieces: u64, attackers: u64, dir: u8) { + let is_up: bool = dir / 4 == 0; + let mask: u64 = RAY_TABLE[king_sq as usize][dir as usize]; + let blockers: u64 = self.occupancy[2] & mask; + if blockers == 0 { return; } + let first_blocker_sq: u32 = if is_up { blockers.trailing_zeros() } else { 63 - blockers.leading_zeros() }; + if (friendly_pieces & 1 << first_blocker_sq) != 0 { + let blockers: u64 = blockers & !(1 << first_blocker_sq); + if blockers == 0 { return; } + let second_blocker_sq: u32 = if is_up { blockers.trailing_zeros() } else { 63 - blockers.leading_zeros() }; + + if (attackers & 1 << second_blocker_sq) != 0 { + self.pinned_squares[first_blocker_sq as usize] = dir % 4; + self.pin_mask |= 1 << first_blocker_sq; + } + } + } + +} \ No newline at end of file diff --git a/engine/src/bitboard/utils.rs b/engine/src/bitboard/utils.rs index a30e503..25d7ba8 100644 --- a/engine/src/bitboard/utils.rs +++ b/engine/src/bitboard/utils.rs @@ -27,6 +27,27 @@ pub fn notation_from_square_number(sq: u8) -> String { return notation; } +pub fn try_get_square_number_from_notation(notation: &str) -> Result { + + let file = match notation.chars().nth(0).unwrap() { + 'a' => 0, + 'b' => 1, + 'c' => 2, + 'd' => 3, + 'e' => 4, + 'f' => 5, + 'g' => 6, + 'h' => 7, + _ => { return Result::Err(()); } + }; + if let Some(rank) = notation.chars().nth(1) { + return Result::Ok(file + 8 * (rank.to_digit(10).unwrap() as u8) - 8); + } + else { + return Result::Err(()); + } +} + // <----- TESTS -----> diff --git a/server/Cargo.toml b/server/Cargo.toml index ff06670..c631dd0 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,3 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] +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"] } +anyhow = "1.0.100" +rand = "0.9.2" diff --git a/server/src/connection.rs b/server/src/connection.rs new file mode 100644 index 0000000..715ae88 --- /dev/null +++ b/server/src/connection.rs @@ -0,0 +1,218 @@ +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; +use uuid::Uuid; + +// Type definitions +pub type Tx = futures_util::stream::SplitSink, Message>; +pub type ConnectionMap = Arc>>; +pub type MatchMap = Arc>>; +pub type WaitingQueue = Arc>>; + +// Helper functions to create new instances +pub fn new_connection_map() -> ConnectionMap { + Arc::new(Mutex::new(HashMap::new())) +} + +pub fn new_match_map() -> MatchMap { + Arc::new(Mutex::new(HashMap::new())) +} + +pub fn new_waiting_queue() -> WaitingQueue { + Arc::new(Mutex::new(VecDeque::new())) +} + +#[derive(Debug)] +pub struct PlayerConnection { + pub id: Uuid, + pub username: Option, + pub tx: Tx, + pub current_match: Option, +} + +#[derive(Debug, Clone)] +pub struct GameMatch { + pub id: Uuid, + pub player_white: Uuid, + pub player_black: Uuid, + pub board_state: String, + pub move_history: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Step { + pub from: String, + pub to: String, +} + +// Message sending utilities +pub async fn send_message_to_player( + connections: &ConnectionMap, + player_id: Uuid, + message: &str, +) -> Result<(), Box> { + let mut connections_lock = connections.lock().await; + if let Some(connection) = connections_lock.get_mut(&player_id) { + connection + .tx + .send(Message::Text(message.to_string())) + .await?; + } + Ok(()) +} + +pub async fn broadcast_to_all(connections: &ConnectionMap, message: &str) { + let mut connections_lock = connections.lock().await; + let mut dead_connections = Vec::new(); + + for (id, connection) in connections_lock.iter_mut() { + if let Err(e) = connection.tx.send(Message::Text(message.to_string())).await { + eprintln!("Failed to send to {}: {}", id, e); + dead_connections.push(*id); + } + } + + // Clean up dead connections + for dead_id in dead_connections { + connections_lock.remove(&dead_id); + } +} + +pub async fn broadcast_to_match( + connections: &ConnectionMap, + matches: &MatchMap, + match_id: Uuid, + message: &str, +) -> Result<(), Box> { + let matches_lock = matches.lock().await; + if let Some(game_match) = matches_lock.get(&match_id) { + send_message_to_player(connections, game_match.player_white, message).await?; + send_message_to_player(connections, game_match.player_black, message).await?; + } + Ok(()) +} + +// Connection handler +pub async fn handle_connection( + stream: TcpStream, + connections: ConnectionMap, + matches: MatchMap, + waiting_queue: WaitingQueue, + event_system: crate::events::EventSystem, +) -> anyhow::Result<()> { + use tokio_tungstenite::accept_async; + + let ws_stream = accept_async(stream).await?; + let (write, mut read) = ws_stream.split(); + + let player_id = Uuid::new_v4(); + + // Store the connection + { + let mut conn_map = connections.lock().await; + conn_map.insert( + player_id, + PlayerConnection { + id: player_id, + username: None, + tx: write, + current_match: None, + }, + ); + } + + println!("New connection: {}", player_id); + + // Send welcome message + let _ = send_message_to_player( + &connections, + player_id, + &format!(r#"{{"type": "welcome", "player_id": "{}"}}"#, player_id), + ) + .await; + + // Message processing loop + while let Some(Ok(message)) = read.next().await { + if message.is_text() { + let text = message.to_text()?; + println!("Received from {}: {}", player_id, text); + + // TODO: Parse and handle message with event system + // This will be implemented when we integrate the event system + } + } + + // Cleanup on disconnect + cleanup_player(player_id, &connections, &matches, &waiting_queue).await; + println!("Connection {} closed", player_id); + + Ok(()) +} + +async fn cleanup_player( + player_id: Uuid, + connections: &ConnectionMap, + _matches: &MatchMap, + waiting_queue: &WaitingQueue, +) { + // Remove from waiting queue + waiting_queue.lock().await.retain(|&id| id != player_id); + + // Remove from connections + connections.lock().await.remove(&player_id); + + println!("Cleaned up player {}", player_id); +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[tokio::test] + async fn test_send_message_to_nonexistent_player() { + let connections = new_connection_map(); + let player_id = Uuid::new_v4(); + + let result = send_message_to_player(&connections, player_id, "test message").await; + assert!(result.is_ok(), "Should handle missing player gracefully"); + } + + #[tokio::test] + async fn test_broadcast_to_empty_connections() { + let connections = new_connection_map(); + + broadcast_to_all(&connections, "test broadcast").await; + + let conn_map = connections.lock().await; + assert!(conn_map.is_empty(), "Connections should still be empty"); + } + + #[tokio::test] + async fn test_connection_cleanup() { + let connections = new_connection_map(); + let matches = new_match_map(); + let waiting_queue = new_waiting_queue(); + + let player_id = Uuid::new_v4(); + + { + waiting_queue.lock().await.push_back(player_id); + assert_eq!(waiting_queue.lock().await.len(), 1); + } + + cleanup_player(player_id, &connections, &matches, &waiting_queue).await; + + { + let queue = waiting_queue.lock().await; + assert!( + !queue.contains(&player_id), + "Player should be removed from waiting queue" + ); + } + } +} diff --git a/server/src/events.rs b/server/src/events.rs new file mode 100644 index 0000000..8cc43a4 --- /dev/null +++ b/server/src/events.rs @@ -0,0 +1,126 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Step { + pub from: String, + pub to: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum ClientEvent { + Join { username: String }, + FindMatch, + Move { from: String, to: String }, + Resign, + Chat { text: String }, + RequestLegalMoves { fen: String }, +} + +#[derive(Debug)] +pub enum ServerEvent { + PlayerJoined(Uuid, String), + PlayerLeft(Uuid), + PlayerJoinedQueue(Uuid), + PlayerJoinedMatch(Uuid, Uuid), // player_id, match_id + PlayerMove(Uuid, Step), + PlayerResigned(Uuid), + MatchCreated(Uuid, Uuid, Uuid), // match_id, white_id, black_id +} + +pub struct EventSystem { + sender: mpsc::UnboundedSender<(Uuid, ClientEvent)>, + receiver: Arc>>, +} + +impl Clone for EventSystem { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + receiver: Arc::clone(&self.receiver), + } + } +} + +impl EventSystem { + pub fn new() -> Self { + let (sender, receiver) = mpsc::unbounded_channel(); + Self { + sender, + receiver: Arc::new(Mutex::new(receiver)), + } + } + + pub async fn send_event( + &self, + player_id: Uuid, + event: ClientEvent, + ) -> Result<(), Box> { + self.sender.send((player_id, event))?; + Ok(()) + } + + pub async fn next_event(&self) -> Option<(Uuid, ClientEvent)> { + let mut receiver = self.receiver.lock().await; + receiver.recv().await + } +} + +impl Default for EventSystem { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[tokio::test] + async fn test_event_system_send_and_receive() { + let event_system = EventSystem::new(); + let player_id = Uuid::new_v4(); + + let join_event = ClientEvent::Join { + username: "test_user".to_string(), + }; + + let send_result = event_system.send_event(player_id, join_event).await; + assert!(send_result.is_ok(), "Should send event successfully"); + + let received = event_system.next_event().await; + assert!(received.is_some(), "Should receive sent event"); + + let (received_id, received_event) = received.unwrap(); + assert_eq!(received_id, player_id, "Should receive correct player ID"); + + match received_event { + ClientEvent::Join { username } => { + assert_eq!(username, "test_user", "Should receive correct username"); + } + _ => panic!("Should receive Join event"), + } + } + + #[tokio::test] + async fn test_event_system_clone() { + let event_system1 = EventSystem::new(); + let event_system2 = event_system1.clone(); + + let player_id = Uuid::new_v4(); + let event = ClientEvent::FindMatch; + + event_system1.send_event(player_id, event).await.unwrap(); + + let received = event_system2.next_event().await; + assert!( + received.is_some(), + "Cloned event system should receive events" + ); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index e7a11a9..0d35baf 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,54 @@ -fn main() { - println!("Hello, world!"); +mod connection; +mod events; +mod matchmaking; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let address = "0.0.0.0:9001"; + let listener = TcpListener::bind(address).await?; + println!("Server running on ws://{}", address); + + // Shared state initialization using the new helper functions + let connections = connection::new_connection_map(); + let matches = connection::new_match_map(); + let waiting_queue = connection::new_waiting_queue(); + + // Event system for communication between components + let event_system = events::EventSystem::new(); + + // Start matchmaking background task + let matchmaker = matchmaking::MatchmakingSystem::new( + connections.clone(), + matches.clone(), + waiting_queue.clone(), + event_system.clone(), + ); + tokio::spawn(async move { + matchmaker.run().await; + }); + + // Main connection loop + while let Ok((stream, _)) = listener.accept().await { + let connections = connections.clone(); + let matches = matches.clone(); + let waiting_queue = waiting_queue.clone(); + let event_system = event_system.clone(); + + tokio::spawn(async move { + if let Err(e) = connection::handle_connection( + stream, + connections, + matches, + waiting_queue, + event_system, + ) + .await + { + eprintln!("Connection error: {}", e); + } + }); + } + + Ok(()) } diff --git a/server/src/matchmaking.rs b/server/src/matchmaking.rs new file mode 100644 index 0000000..4f4cc27 --- /dev/null +++ b/server/src/matchmaking.rs @@ -0,0 +1,202 @@ +use crate::connection::{ConnectionMap, GameMatch, MatchMap, WaitingQueue}; +use crate::events::EventSystem; +use rand::random; +use uuid::Uuid; + +pub struct MatchmakingSystem { + connections: ConnectionMap, + matches: MatchMap, + waiting_queue: WaitingQueue, + event_system: EventSystem, +} + +impl MatchmakingSystem { + pub fn new( + connections: ConnectionMap, + matches: MatchMap, + waiting_queue: WaitingQueue, + event_system: EventSystem, + ) -> Self { + Self { + connections, + matches, + waiting_queue, + event_system, + } + } + + pub async fn run(&self) { + loop { + self.try_create_match().await; + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + } + + async fn try_create_match(&self) { + let mut queue = self.waiting_queue.lock().await; + + while queue.len() >= 2 { + let player1 = queue.pop_front().unwrap(); + let player2 = queue.pop_front().unwrap(); + + let match_id = Uuid::new_v4(); + let (white_player, black_player) = if random::() { + (player1, player2) + } else { + (player2, player1) + }; + + let game_match = GameMatch { + id: match_id, + player_white: white_player, + player_black: black_player, + board_state: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1".to_string(), + move_history: Vec::new(), + }; + + // Store the match + self.matches.lock().await.insert(match_id, game_match); + + // Update player connections + { + let mut conn_map = self.connections.lock().await; + if let Some(player) = conn_map.get_mut(&white_player) { + player.current_match = Some(match_id); + } + if let Some(player) = conn_map.get_mut(&black_player) { + player.current_match = Some(match_id); + } + } + + // Notify players + self.notify_players(white_player, black_player, match_id) + .await; + } + } + + async fn notify_players(&self, white: Uuid, black: Uuid, match_id: Uuid) { + let conn_map = self.connections.lock().await; + + // Get opponent names + let white_name = conn_map + .get(&black) + .and_then(|c| c.username.as_deref()) + .unwrap_or("Opponent"); + let black_name = conn_map + .get(&white) + .and_then(|c| c.username.as_deref()) + .unwrap_or("Opponent"); + + // Notify white player + if let Some(_) = conn_map.get(&white) { + let message = format!( + r#"{{"type": "match_found", "match_id": "{}", "opponent": "{}", "color": "white"}}"#, + match_id, black_name + ); + let _ = + crate::connection::send_message_to_player(&self.connections, white, &message).await; + } + + // Notify black player + if let Some(_) = conn_map.get(&black) { + let message = format!( + r#"{{"type": "match_found", "match_id": "{}", "opponent": "{}", "color": "black"}}"#, + match_id, white_name + ); + let _ = + crate::connection::send_message_to_player(&self.connections, black, &message).await; + } + + println!("Match created: {} (white) vs {} (black)", white, black); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::EventSystem; + use uuid::Uuid; + + use crate::connection::new_connection_map; + use crate::connection::new_match_map; + use crate::connection::new_waiting_queue; + + #[tokio::test] + async fn test_matchmaking_creates_matches() { + let connections = new_connection_map(); + let matches = new_match_map(); + let waiting_queue = new_waiting_queue(); + let event_system = EventSystem::new(); + + let matchmaking = MatchmakingSystem::new( + connections.clone(), + matches.clone(), + waiting_queue.clone(), + event_system.clone(), + ); + + let player1 = Uuid::new_v4(); + let player2 = Uuid::new_v4(); + + { + waiting_queue.lock().await.push_back(player1); + waiting_queue.lock().await.push_back(player2); + } + + matchmaking.try_create_match().await; + + { + let matches_map = matches.lock().await; + assert_eq!(matches_map.len(), 1, "Should create one match"); + + let game_match = matches_map.values().next().unwrap(); + assert!(game_match.player_white == player1 || game_match.player_white == player2); + assert!(game_match.player_black == player1 || game_match.player_black == player2); + assert_ne!( + game_match.player_white, game_match.player_black, + "Players should be different" + ); + } + + { + let queue = waiting_queue.lock().await; + assert!( + queue.is_empty(), + "Waiting queue should be empty after matchmaking" + ); + } + } + + #[tokio::test] + async fn test_matchmaking_with_odd_players() { + let connections = new_connection_map(); + let matches = new_match_map(); + let waiting_queue = new_waiting_queue(); + let event_system = EventSystem::new(); + + let matchmaking = MatchmakingSystem::new( + connections.clone(), + matches.clone(), + waiting_queue.clone(), + event_system.clone(), + ); + + let player1 = Uuid::new_v4(); + { + waiting_queue.lock().await.push_back(player1); + } + + matchmaking.try_create_match().await; + + { + let matches_map = matches.lock().await; + assert!( + matches_map.is_empty(), + "Should not create match with only one player" + ); + + let queue = waiting_queue.lock().await; + assert_eq!(queue.len(), 1, "Should keep single player in queue"); + } + } +} diff --git a/server/src/messages.rs b/server/src/messages.rs new file mode 100644 index 0000000..e6e957a --- /dev/null +++ b/server/src/messages.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum ServerMessage { + Welcome { + player_id: String, + }, + MatchFound { + match_id: String, + opponent: String, + color: String, + }, + GameStart { + fen: String, + white_time: u32, + black_time: u32, + }, + MoveResult { + valid: bool, + from: String, + to: String, + new_fen: String, + }, + OpponentMove { + from: String, + to: String, + }, + GameEnd { + result: String, + reason: String, + }, + Error { + reason: String, + }, +}