diff --git a/.sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json b/.sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json deleted file mode 100644 index 4d22c0a..0000000 --- a/.sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT board as \"board: Board\"\n FROM positions\n JOIN moves ON moves.position_id = positions.id\n JOIN games ON games.id = moves.game_id\n WHERE games.id = $1\n ORDER BY moves.move_number DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "board: Board", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f" -} diff --git a/.sqlx/query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json b/.sqlx/query-70dfab069e3fe8b8281ea26e99220c94392e0fa691dcbc5dc0b0a504304c7c21.json similarity index 87% rename from .sqlx/query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json rename to .sqlx/query-70dfab069e3fe8b8281ea26e99220c94392e0fa691dcbc5dc0b0a504304c7c21.json index bbc91eb..0c6cc34 100644 --- a/.sqlx/query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json +++ b/.sqlx/query-70dfab069e3fe8b8281ea26e99220c94392e0fa691dcbc5dc0b0a504304c7c21.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n id as \"id: Uuid\",\n created_at as \"created_at: OffsetDateTime\",\n updated_at as \"updated_at: OffsetDateTime\",\n status as \"status: GameStatus\",\n winner as \"winner: GameWinner\",\n outcome as \"outcome: GameOutcome\"\n FROM games\n ", + "query": "SELECT\n id as \"id: Uuid\",\n created_at as \"created_at: OffsetDateTime\",\n updated_at as \"updated_at: OffsetDateTime\",\n status as \"status: GameStatus\",\n winner as \"winner: GameWinner\",\n outcome as \"outcome: GameOutcome\"\n FROM games\n ORDER BY created_at DESC\n ", "describe": { "columns": [ { @@ -46,5 +46,5 @@ true ] }, - "hash": "b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85" + "hash": "70dfab069e3fe8b8281ea26e99220c94392e0fa691dcbc5dc0b0a504304c7c21" } diff --git a/.sqlx/query-c1037eca08ec19e80f8ad23cee76acfc1e63248600b92160dd74488a60012699.json b/.sqlx/query-c1037eca08ec19e80f8ad23cee76acfc1e63248600b92160dd74488a60012699.json new file mode 100644 index 0000000..f6143f0 --- /dev/null +++ b/.sqlx/query-c1037eca08ec19e80f8ad23cee76acfc1e63248600b92160dd74488a60012699.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE games\n SET status = 'active'\n WHERE id = $1\n AND status = 'created'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c1037eca08ec19e80f8ad23cee76acfc1e63248600b92160dd74488a60012699" +} diff --git a/.sqlx/query-c91ced62187b2f4d566ef8ce5191bfc1c201d215075c1a7e3bed3d8dfa9c719d.json b/.sqlx/query-c91ced62187b2f4d566ef8ce5191bfc1c201d215075c1a7e3bed3d8dfa9c719d.json new file mode 100644 index 0000000..43957e0 --- /dev/null +++ b/.sqlx/query-c91ced62187b2f4d566ef8ce5191bfc1c201d215075c1a7e3bed3d8dfa9c719d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM games WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c91ced62187b2f4d566ef8ce5191bfc1c201d215075c1a7e3bed3d8dfa9c719d" +} diff --git a/.sqlx/query-d018b902622b6c1273dbb17d251de5fecfd43ef472f3c55d913b0583bbfb5532.json b/.sqlx/query-d018b902622b6c1273dbb17d251de5fecfd43ef472f3c55d913b0583bbfb5532.json new file mode 100644 index 0000000..f303634 --- /dev/null +++ b/.sqlx/query-d018b902622b6c1273dbb17d251de5fecfd43ef472f3c55d913b0583bbfb5532.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE games\n SET status = $1,\n winner = $2,\n outcome = $3\n WHERE id = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d018b902622b6c1273dbb17d251de5fecfd43ef472f3c55d913b0583bbfb5532" +} diff --git a/.sqlx/query-d0646f52e0a1e1a39a2a7f5c675198f2fe7d7ab2e85f380e1536b7a8fb37c24c.json b/.sqlx/query-d0646f52e0a1e1a39a2a7f5c675198f2fe7d7ab2e85f380e1536b7a8fb37c24c.json new file mode 100644 index 0000000..bd9b375 --- /dev/null +++ b/.sqlx/query-d0646f52e0a1e1a39a2a7f5c675198f2fe7d7ab2e85f380e1536b7a8fb37c24c.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n g.id as \"id: Uuid\",\n p.board as \"board: Board\",\n g.status as \"status: GameStatus\",\n g.winner as \"winner: GameWinner\",\n g.outcome as \"outcome: GameOutcome\"\n FROM positions as p\n JOIN moves as m ON m.position_id = p.id\n JOIN games as g ON g.id = m.game_id\n WHERE g.id = $1\n ORDER BY m.move_number DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "board: Board", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "status: GameStatus", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "winner: GameWinner", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "outcome: GameOutcome", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "d0646f52e0a1e1a39a2a7f5c675198f2fe7d7ab2e85f380e1536b7a8fb37c24c" +} diff --git a/.sqlx/query-f6c504d520f8beb954363630588c10e6f5a9cd27f331a7b4af45056113fb3b74.json b/.sqlx/query-f6c504d520f8beb954363630588c10e6f5a9cd27f331a7b4af45056113fb3b74.json new file mode 100644 index 0000000..0eb3721 --- /dev/null +++ b/.sqlx/query-f6c504d520f8beb954363630588c10e6f5a9cd27f331a7b4af45056113fb3b74.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE games\n SET winner = $1,\n status = $2,\n outcome = $3\n WHERE id = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f6c504d520f8beb954363630588c10e6f5a9cd27f331a7b4af45056113fb3b74" +} diff --git a/src/api/games/make_move.rs b/src/api/games/make_move.rs index a51871e..8a0c8ba 100644 --- a/src/api/games/make_move.rs +++ b/src/api/games/make_move.rs @@ -6,14 +6,15 @@ use axum::{ }; use sqlx::types::Uuid; -use crate::api::models::ApiBoard; -use crate::database::models::{Game, GameError}; +use crate::api::models::ApiGameBoard; +use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; #[derive(serde::Deserialize)] pub struct MakeMoveRequest { #[serde(rename = "uciMove")] uci_move: String, + resign: Option, } pub async fn handler( @@ -22,31 +23,39 @@ pub async fn handler( Form(request): Form, ) -> Result { let uci_move = request.uci_move; + let resign = request.resign.unwrap_or(false); let mut conn = state.database().begin().await?; - let maybe_board = Game::make_move(&mut conn, game_id, &uci_move).await; - let board = match maybe_board { - Ok(board) => board, - Err(e) => match e { - GameError::InvalidMove(_, board) => board, - _ => return Err(e.into()), - }, - }; + if !Game::exists(&mut conn, game_id).await? { + return Err(ReadBoardError::NotFound); + } + + // Returns the updated board if the move was valid. Otherwise, returns the latest board. + let game_board = GameBoard::make_move(&mut conn, game_id, &uci_move, resign).await?; + // If we got here, then either we made a valid move // or no changes were made to the database (invalid move) conn.commit().await?; - let api_board = ApiBoard { + let board = game_board.board().clone(); + let status = game_board.status().clone(); + let winner = game_board.winner().clone(); + let outcome = game_board.outcome().clone(); + let game_id = game_id.to_string(); + let api_board = ApiGameBoard { + game_id, board, - game_id: game_id.to_string(), + status, + winner, + outcome, }; - Ok(TemplateApiBoard { api_board }) + Ok(TemplateApiGameBoard { api_board }) } #[derive(Template)] #[template(path = "board.html")] -struct TemplateApiBoard { - api_board: ApiBoard, +struct TemplateApiGameBoard { + api_board: ApiGameBoard, } #[derive(Debug, thiserror::Error)] @@ -55,11 +64,21 @@ pub enum ReadBoardError { Sqlx(#[from] sqlx::Error), #[error("game error: {0}")] Game(#[from] GameError), + #[error("game not found")] + NotFound, } impl IntoResponse for ReadBoardError { fn into_response(self) -> Response { - let body = format!("{}", self); - (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + match self { + ReadBoardError::NotFound => { + let body = format!("{}", self); + (axum::http::StatusCode::NOT_FOUND, body).into_response() + } + _ => { + let body = format!("{}", self); + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + } + } } } diff --git a/src/api/games/read_game.rs b/src/api/games/read_game.rs index 89624ec..5007c8b 100644 --- a/src/api/games/read_game.rs +++ b/src/api/games/read_game.rs @@ -5,8 +5,8 @@ use axum::{ }; use sqlx::types::Uuid; -use crate::api::models::ApiBoard; -use crate::database::models::{Game, GameError}; +use crate::api::models::ApiGameBoard; +use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; pub async fn handler( @@ -14,19 +14,26 @@ pub async fn handler( Path(game_id): Path, ) -> Result { let mut conn = state.database().acquire().await?; - let board = Game::latest_board(&mut conn, game_id).await?; - let api_board = ApiBoard { + if !Game::exists(&mut conn, game_id).await? { + return Err(ReadBoardError::NotFound); + } + let game_board = GameBoard::latest(&mut conn, game_id).await?; + let board = game_board.board().clone(); + let api_board = ApiGameBoard { board, + status: game_board.status().clone(), + winner: game_board.winner().clone(), + outcome: game_board.outcome().clone(), game_id: game_id.to_string(), }; - Ok(TemplateApiBoard { api_board }) + Ok(TemplateApiGameBoard { api_board }) } #[derive(Template)] #[template(path = "board.html")] -struct TemplateApiBoard { - api_board: ApiBoard, +struct TemplateApiGameBoard { + api_board: ApiGameBoard, } #[derive(Debug, thiserror::Error)] @@ -35,11 +42,21 @@ pub enum ReadBoardError { Sqlx(#[from] sqlx::Error), #[error("game error: {0}")] Game(#[from] GameError), + #[error("game not found")] + NotFound, } impl IntoResponse for ReadBoardError { fn into_response(self) -> Response { - let body = format!("{}", self); - (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + match self { + ReadBoardError::NotFound => { + let body = format!("{}", self); + (axum::http::StatusCode::NOT_FOUND, body).into_response() + } + _ => { + let body = format!("{}", self); + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + } + } } } diff --git a/src/api/models/api_board.rs b/src/api/models/api_board.rs index 6db15a2..47b8c97 100644 --- a/src/api/models/api_board.rs +++ b/src/api/models/api_board.rs @@ -1,18 +1,52 @@ use pleco::core::sq::SQ as Sq; use pleco::core::Piece; +use pleco::core::Player; +use crate::database::models::GameOutcome; +use crate::database::models::GameStatus; +use crate::database::models::GameWinner; use crate::database::types::DatabaseBoard as Board; -pub struct ApiBoard { +pub struct ApiGameBoard { pub game_id: String, pub board: Board, + pub status: GameStatus, + pub winner: Option, + pub outcome: Option, } -impl ApiBoard { +impl ApiGameBoard { pub fn game_id(&self) -> &str { &self.game_id } + // TODO: I should consolidate these types + pub fn turn(&self) -> String { + let player = match self.board.turn() { + Player::White => GameWinner::White, + Player::Black => GameWinner::Black, + }; + player.to_string() + } + + pub fn status(&self) -> String { + self.status.to_string() + } + + pub fn winner(&self) -> String { + match &self.winner { + Some(winner) => winner.to_string(), + None => "None".to_string(), + } + } + + pub fn outcome(&self) -> String { + match &self.outcome { + Some(outcome) => outcome.to_string(), + None => "None".to_string(), + } + } + pub fn board_html(&self) -> String { // We'll just pass raw HTML to our template let mut html_board = String::new(); @@ -20,46 +54,93 @@ impl ApiBoard { // Iterate over ranks to fully construct the board -- we need to populate every cell // with metadata at the moment - for rank in (0..8).rev() { - // New rank - html_board.push_str(""); - for file in 0..8 { - // Read the piece at this square and populate the cell - let square = Sq::from(rank * 8 + file); - let piece = self.board.piece_at_sq(square); - let id = square.to_string(); - let color_class = if square.on_light_square() { - "light" - } else { - "dark" - }; + // Depending on the player, we'll either iterate from 0..8 or 8..0 + match self.board.turn() { + Player::White => { + for rank in (0..8).rev() { + // New rank + html_board.push_str(""); + for file in 0..8 { + // Read the piece at this square and populate the cell + let square = Sq::from(rank * 8 + file); + let piece = self.board.piece_at_sq(square); + let id = square.to_string(); + let color_class = if square.on_light_square() { + "light" + } else { + "dark" + }; - // Metadata breakdown: - // - id: the square's readable id (e.g. "a1") - // - class: - // - chess-square-{light|dark}: the square's color - // - chess-piece-{piece_char}: the occupying piece, if any. e.g. "chess-piece-P" for a white pawn - match render_html_piece(piece) { - Some(piece_html) => { - // Note: Since we know `piece` is `Some`, we can call .character_lossy() here - html_board.push_str(&format!( - "{}", - id, - color_class, - piece.character_lossy(), - piece_html - )); + // Metadata breakdown: + // - id: the square's readable id (e.g. "a1") + // - class: + // - chess-square-{light|dark}: the square's color + // - chess-piece-{piece_char}: the occupying piece, if any. e.g. "chess-piece-P" for a white pawn + match render_html_piece(piece) { + Some(piece_html) => { + // Note: Since we know `piece` is `Some`, we can call .character_lossy() here + html_board.push_str(&format!( + "{}", + id, + color_class, + piece.character_lossy(), + piece_html + )); + } + None => { + html_board.push_str(&format!( + "", + id, color_class + )); + } + } } - None => { - html_board.push_str(&format!( - "", - id, color_class - )); + html_board.push_str(""); + } + } + Player::Black => { + for rank in 0..8 { + // New rank + html_board.push_str(""); + for file in (0..8).rev() { + // Read the piece at this square and populate the cell + let square = Sq::from(rank * 8 + file); + let piece = self.board.piece_at_sq(square); + let id = square.to_string(); + let color_class = if square.on_light_square() { + "light" + } else { + "dark" + }; + + // Metadata breakdown: + // - id: the square's readable id (e.g. "a1") + // - class: + // - chess-square-{light|dark}: the square's color + // - chess-piece-{piece_char}: the occupying piece, if any. e.g. "chess-piece-P" for a white pawn + match render_html_piece(piece) { + Some(piece_html) => { + // Note: Since we know `piece` is `Some`, we can call .character_lossy() here + html_board.push_str(&format!( + "{}", + id, + color_class, + piece.character_lossy(), + piece_html + )); + } + None => { + html_board.push_str(&format!( + "", + id, color_class + )); + } + } } + html_board.push_str(""); } } - html_board.push_str(""); - } + }; html_board.push_str(""); html_board diff --git a/src/api/models/api_game.rs b/src/api/models/api_game.rs index 7cfc767..4b93b6d 100644 --- a/src/api/models/api_game.rs +++ b/src/api/models/api_game.rs @@ -1,15 +1,46 @@ use crate::database::models::Game; +use crate::database::models::GameOutcome; +use crate::database::models::GameStatus; +use crate::database::models::GameWinner; pub struct ApiGame { - pub id: String, - pub status: String, + id: String, + status: GameStatus, + winner: Option, + outcome: Option, +} + +impl ApiGame { + pub fn id(&self) -> &str { + &self.id + } + + pub fn status(&self) -> String { + self.status.to_string() + } + + pub fn winner(&self) -> String { + match &self.winner { + Some(winner) => winner.to_string(), + None => "None".to_string(), + } + } + + pub fn outcome(&self) -> String { + match &self.outcome { + Some(outcome) => outcome.to_string(), + None => "None".to_string(), + } + } } impl From for ApiGame { fn from(game: Game) -> Self { Self { id: game.id().to_string(), - status: game.status().to_string(), + status: game.status().clone(), + winner: game.winner().clone(), + outcome: game.outcome().clone(), } } } diff --git a/src/api/models/mod.rs b/src/api/models/mod.rs index 3c1b6ea..d673baa 100644 --- a/src/api/models/mod.rs +++ b/src/api/models/mod.rs @@ -1,5 +1,5 @@ mod api_board; mod api_game; -pub use api_board::ApiBoard; +pub use api_board::ApiGameBoard; pub use api_game::ApiGame; diff --git a/src/database/models/game.rs b/src/database/models/game.rs index 98d0ecc..0bb2f69 100644 --- a/src/database/models/game.rs +++ b/src/database/models/game.rs @@ -1,3 +1,4 @@ +use pleco::core::Player; use sqlx::types::Uuid; use sqlx::FromRow; use sqlx::PgConnection; @@ -43,7 +44,6 @@ pub struct Game { } impl Game { - // Getters pub fn id(&self) -> Uuid { self.id } @@ -52,17 +52,91 @@ impl Game { &self.status } + pub fn winner(&self) -> &Option { + &self.winner + } + + pub fn outcome(&self) -> &Option { + &self.outcome + } + + // TODO: make the state machine more robust -- but maybe eventually this will + // check if a user has access to a game + /// Check if a game exists in the database + pub async fn exists(conn: &mut PgConnection, game_id: Uuid) -> Result { + let maybe_game = sqlx::query!(r#"SELECT id FROM games WHERE id = $1"#, game_id,) + .fetch_optional(&mut *conn) + .await?; + Ok(maybe_game.is_some()) + } + + // TODO: pagination + /// Read all games from the database + pub async fn read_all(conn: &mut PgConnection) -> Result, GameError> { + let games = sqlx::query_as!( + Game, + r#"SELECT + id as "id: Uuid", + created_at as "created_at: OffsetDateTime", + updated_at as "updated_at: OffsetDateTime", + status as "status: GameStatus", + winner as "winner: GameWinner", + outcome as "outcome: GameOutcome" + FROM games + ORDER BY created_at DESC + "#, + ) + .fetch_all(&mut *conn) + .await?; + Ok(games) + } +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +pub struct GameBoard { + id: Uuid, + board: Board, + status: GameStatus, + winner: Option, + outcome: Option, +} + +impl GameBoard { + // Getters + pub fn board(&self) -> &Board { + &self.board + } + + pub fn status(&self) -> &GameStatus { + &self.status + } + + pub fn winner(&self) -> &Option { + &self.winner + } + + pub fn outcome(&self) -> &Option { + &self.outcome + } + /* Database Operations */ - /// Return the latest board for a game - pub async fn latest_board(conn: &mut PgConnection, game_id: Uuid) -> Result { - let maybe_board = sqlx::query_scalar!( - r#"SELECT board as "board: Board" - FROM positions - JOIN moves ON moves.position_id = positions.id - JOIN games ON games.id = moves.game_id - WHERE games.id = $1 - ORDER BY moves.move_number DESC + /// Return the latest board for a game -- assumes the game exists + pub async fn latest(conn: &mut PgConnection, game_id: Uuid) -> Result { + let maybe_game = sqlx::query_as!( + Self, + r#"SELECT + g.id as "id: Uuid", + p.board as "board: Board", + g.status as "status: GameStatus", + g.winner as "winner: GameWinner", + g.outcome as "outcome: GameOutcome" + FROM positions as p + JOIN moves as m ON m.position_id = p.id + JOIN games as g ON g.id = m.game_id + WHERE g.id = $1 + ORDER BY m.move_number DESC LIMIT 1 "#, game_id, @@ -70,25 +144,76 @@ impl Game { .fetch_optional(&mut *conn) .await?; - match maybe_board { - Some(board) => Ok(board), - None => Ok(Board::new()), + match maybe_game { + Some(game) => Ok(game), + None => Ok(Self { + id: game_id, + board: Board::new(), + status: GameStatus::Created, + winner: None, + outcome: None, + }), } } - /// Make a move in a game. Return the updated board. + /// Make a move in a game. Return the updated board -- assumes the game exists pub async fn make_move( conn: &mut PgConnection, game_id: Uuid, uci_move: &str, - ) -> Result { - let mut board = Game::latest_board(conn, game_id).await?; + resign: bool, + ) -> Result { + let game = Self::latest(conn, game_id).await?; + + // TODO: I don't like that this isn't an explicit error + // If the game is already over, just return it + if game.status == GameStatus::Complete { + return Ok(game); + } + + let mut board = game.board().clone(); + let player = board.turn(); + + // TODO: MAKE MORE CONCISE + // If the current player is resigning, update the game status and return + if resign { + let game_winner = match player { + Player::White => GameWinner::Black, + Player::Black => GameWinner::White, + }; + let game_outcome = GameOutcome::Resignation; + let game_status = GameStatus::Complete; + sqlx::query!( + r#"UPDATE games + SET status = $1, + winner = $2, + outcome = $3 + WHERE id = $4 + "#, + game_status.to_string(), + game_winner.to_string(), + game_outcome.to_string(), + game_id + ) + .execute(&mut *conn) + .await?; + return Ok(Self { + id: game_id, + board: board.clone(), + status: game_status, + winner: Some(game_winner), + outcome: Some(game_outcome), + }); + } + let move_number = board.moves_played() as i32; + // TODO: I don't like that this isn't an explicit error // Attempt to make the move on the board let success = board.apply_uci_move(uci_move); if !success { - return Err(GameError::InvalidMove(uci_move.to_string(), board.clone())); + return Ok(game); + // return Err(GameError::InvalidMove(uci_move.to_string(), board.clone())); } // Insert the FEN into the database if it doesn't already exist @@ -126,28 +251,83 @@ impl Game { .execute(&mut *conn) .await?; - // Return the updated board - Ok(board) - } + // Check if the game is over + if board.checkmate() { + let game_winner = match player { + Player::White => GameWinner::White, + Player::Black => GameWinner::Black, + }; + let game_outcome = GameOutcome::Checkmate; + let game_status = GameStatus::Complete; + sqlx::query!( + r#"UPDATE games + SET status = $1, + winner = $2, + outcome = $3 + WHERE id = $4 + "#, + game_status.to_string(), + game_winner.to_string(), + game_outcome.to_string(), + game_id, + ) + .execute(&mut *conn) + .await?; + return Ok(Self { + id: game_id, + board: board.clone(), + status: game_status, + winner: Some(game_winner), + outcome: Some(game_outcome), + }); + } else if board.stalemate() { + let game_winner = GameWinner::Draw; + let game_outcome = GameOutcome::Stalemate; + let game_status = GameStatus::Complete; + sqlx::query!( + r#"UPDATE games + SET winner = $1, + status = $2, + outcome = $3 + WHERE id = $4 + "#, + game_winner.to_string(), + game_status.to_string(), + game_outcome.to_string(), + game_id, + ) + .execute(&mut *conn) + .await?; + return Ok(Self { + id: game_id, + board: board.clone(), + status: game_status, + winner: Some(game_winner), + outcome: Some(game_outcome), + }); + } - // TODO: pagination - /// Read all games from the database - pub async fn read_all(conn: &mut PgConnection) -> Result, GameError> { - let games = sqlx::query_as!( - Game, - r#"SELECT - id as "id: Uuid", - created_at as "created_at: OffsetDateTime", - updated_at as "updated_at: OffsetDateTime", - status as "status: GameStatus", - winner as "winner: GameWinner", - outcome as "outcome: GameOutcome" - FROM games + // TODO: find a better way to do this -- maybe there will be an 'accept' game worflow in the future + // Update the game's status to active if it's not already + sqlx::query!( + r#"UPDATE games + SET status = 'active' + WHERE id = $1 + AND status = 'created' "#, + game_id, ) - .fetch_all(&mut *conn) + .execute(&mut *conn) .await?; - Ok(games) + + // Return the updated board + Ok(Self { + id: game_id, + board: board.clone(), + status: GameStatus::Active, + winner: None, + outcome: None, + }) } } @@ -155,6 +335,6 @@ impl Game { pub enum GameError { #[error("sqlx error: {0}")] Sqlx(#[from] sqlx::Error), - #[error("invalid move: {0} on board {1}")] - InvalidMove(String, Board), + // #[error("invalid move: {0} on board {1}")] + // InvalidMove(String, Board), } diff --git a/src/database/models/game_status.rs b/src/database/models/game_status.rs index ccce2df..46f923c 100644 --- a/src/database/models/game_status.rs +++ b/src/database/models/game_status.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; pub enum GameStatus { Created, Active, - Completed, + Complete, Abandoned, } @@ -17,7 +17,7 @@ impl Display for GameStatus { match self { GameStatus::Created => write!(f, "created"), GameStatus::Active => write!(f, "active"), - GameStatus::Completed => write!(f, "completed"), + GameStatus::Complete => write!(f, "complete"), GameStatus::Abandoned => write!(f, "abandoned"), } } @@ -30,7 +30,7 @@ impl TryFrom<&str> for GameStatus { match value { "created" => Ok(GameStatus::Created), "active" => Ok(GameStatus::Active), - "completed" => Ok(GameStatus::Completed), + "complete" => Ok(GameStatus::Complete), "abandoned" => Ok(GameStatus::Abandoned), _ => Err(GameStatusError::InvalidGameStatus), } diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 3045e55..fb21837 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -3,4 +3,7 @@ mod game_outcome; mod game_status; mod game_winner; -pub use game::{Game, GameError, NewGame}; +pub use game::{Game, GameBoard, GameError, NewGame}; +pub use game_outcome::GameOutcome; +pub use game_status::GameStatus; +pub use game_winner::GameWinner; diff --git a/src/database/models/position.rs b/src/database/models/position.rs new file mode 100644 index 0000000..439f2dd --- /dev/null +++ b/src/database/models/position.rs @@ -0,0 +1,38 @@ +use sqlx::postgres::PgConnection; +use sqlx::types::Uuid; + +use crate::database::types::DatabaseBoard as Board; + +pub struct Position; + +impl Position { + pub async fn record_move(conn: &mut PgConnection, board: &mut Board, uci_move: &str) -> Result { + // Attempt to make the move on the board + let success = board.apply_uci_move(uci_move); + if !success { + return Err(PositionError::InvalidUciMove(uci_move.to_string(), board.clone())); + } + // Insert the FEN into the database if it doesn't already exist + // Return the FEN ID + let board_fen = board.fen(); + let position_id = sqlx::query_scalar!( + r#"INSERT INTO positions (board) + VALUES ($1) + RETURNING id as "id: Uuid" + "#, + board_fen, + ) + .fetch_one(&mut *conn) + .await?; + + Ok(position_id) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PositionError { + #[error("invalid uci move on board: {0} | {1}")] + InvalidUciMove(String, Board), + #[error("sqlx error: {0}")] + Sqlx(#[from] sqlx::Error) +} \ No newline at end of file diff --git a/templates/board.html b/templates/board.html index cf6e8b8..2b220ff 100644 --- a/templates/board.html +++ b/templates/board.html @@ -2,17 +2,37 @@ {% block content %}

Board

+ {% let board_html = api_board.board_html() %} {% let game_id = api_board.game_id() %} +
+ + {% if api_board.status() == "complete" %} +

Game over!

+

Winner: {{ api_board.winner() }}

+

Outcome: {{ api_board.outcome() }}

+ {% else %} +

Turn: {{ api_board.turn() }}

+ {% endif %} + {{ board_html|safe }}
- +{% if api_board.status() == "active" %} +
+ + + +
+{% endif %} + {% endblock %} \ No newline at end of file diff --git a/templates/game.html b/templates/game.html index 219b38b..93fc5c5 100644 --- a/templates/game.html +++ b/templates/game.html @@ -1,6 +1,8 @@ - - {{ api_game.id }} - {{ api_game.status }} + + {{ api_game.id() }} + {{ api_game.status() }} + {{ api_game.outcome() }} + {{ api_game.winner() }} diff --git a/templates/games.html b/templates/games.html index 7235dff..e1589c1 100644 --- a/templates/games.html +++ b/templates/games.html @@ -5,6 +5,8 @@ ID Status + Outcome + Winner