From c2da4e64c047b3a7db412afc9ff63ede19c216ce Mon Sep 17 00:00:00 2001 From: Al Miller Date: Wed, 24 Jan 2024 23:32:54 -0500 Subject: [PATCH 1/2] feat: board lifecycle --- ...f12d8b99a83834706ad207bdb08e0ecb9118f.json | 22 -- ...6acfc1e63248600b92160dd74488a60012699.json | 14 ++ ...1bfc1c201d215075c1a7e3bed3d8dfa9c719d.json | 22 ++ ...de5fecfd43ef472f3c55d913b0583bbfb5532.json | 17 ++ ...198f2fe7d7ab2e85f380e1536b7a8fb37c24c.json | 46 ++++ ...c10e6f5a9cd27f331a7b4af45056113fb3b74.json | 17 ++ src/api/games/make_move.rs | 27 ++- src/api/games/read_game.rs | 22 +- src/database/models/game.rs | 227 +++++++++++++++--- src/database/models/game_status.rs | 6 +- src/database/models/mod.rs | 2 +- templates/board.html | 5 + 12 files changed, 357 insertions(+), 70 deletions(-) delete mode 100644 .sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json create mode 100644 .sqlx/query-c1037eca08ec19e80f8ad23cee76acfc1e63248600b92160dd74488a60012699.json create mode 100644 .sqlx/query-c91ced62187b2f4d566ef8ce5191bfc1c201d215075c1a7e3bed3d8dfa9c719d.json create mode 100644 .sqlx/query-d018b902622b6c1273dbb17d251de5fecfd43ef472f3c55d913b0583bbfb5532.json create mode 100644 .sqlx/query-d0646f52e0a1e1a39a2a7f5c675198f2fe7d7ab2e85f380e1536b7a8fb37c24c.json create mode 100644 .sqlx/query-f6c504d520f8beb954363630588c10e6f5a9cd27f331a7b4af45056113fb3b74.json 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-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..df1847c 100644 --- a/src/api/games/make_move.rs +++ b/src/api/games/make_move.rs @@ -7,13 +7,14 @@ use axum::{ use sqlx::types::Uuid; use crate::api::models::ApiBoard; -use crate::database::models::{Game, GameError}; +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,10 +23,14 @@ 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, + if !Game::exists(&mut conn, game_id).await? { + return Err(ReadBoardError::NotFound); + } + let maybe_game_board = GameBoard::make_move(&mut conn, game_id, &uci_move, resign).await; + let board = match maybe_game_board { + Ok(game_board) => game_board.board().clone(), Err(e) => match e { GameError::InvalidMove(_, board) => board, _ => return Err(e.into()), @@ -55,11 +60,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..3fc7c27 100644 --- a/src/api/games/read_game.rs +++ b/src/api/games/read_game.rs @@ -6,7 +6,7 @@ use axum::{ use sqlx::types::Uuid; use crate::api::models::ApiBoard; -use crate::database::models::{Game, GameError}; +use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; pub async fn handler( @@ -14,7 +14,11 @@ 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?; + 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 = ApiBoard { board, game_id: game_id.to_string(), @@ -35,11 +39,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/database/models/game.rs b/src/database/models/game.rs index 98d0ecc..fc530e4 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,70 @@ impl Game { &self.status } + // 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 + "#, + ) + .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 + } + /* 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,19 +123,70 @@ 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 = GameBoard::latest(conn, game_id).await?; + + // If the game is already over, return an error + if game.status != GameStatus::Active { + return Err(GameError::InvalidMove( + uci_move.to_string(), + game.board().clone(), + )); + } + + 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; // Attempt to make the move on the board @@ -126,28 +230,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, + }) } } 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..e1dbfe9 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -3,4 +3,4 @@ mod game_outcome; mod game_status; mod game_winner; -pub use game::{Game, GameError, NewGame}; +pub use game::{Game, GameBoard, GameError, NewGame}; diff --git a/templates/board.html b/templates/board.html index cf6e8b8..b914861 100644 --- a/templates/board.html +++ b/templates/board.html @@ -14,5 +14,10 @@

Board

+
+ + + +
{% endblock %} \ No newline at end of file From a9e7b13734c9f03086d6cadbdafe3bb5c6fb4a34 Mon Sep 17 00:00:00 2001 From: Al Miller Date: Thu, 25 Jan 2024 10:58:20 -0500 Subject: [PATCH 2/2] feat: comprehensive board views --- ...0c94392e0fa691dcbc5dc0b0a504304c7c21.json} | 4 +- src/api/games/make_move.rs | 32 ++-- src/api/games/read_game.rs | 13 +- src/api/models/api_board.rs | 155 +++++++++++++----- src/api/models/api_game.rs | 37 ++++- src/api/models/mod.rs | 2 +- src/database/models/game.rs | 41 +++-- src/database/models/mod.rs | 3 + src/database/models/position.rs | 38 +++++ templates/board.html | 17 +- templates/game.html | 8 +- templates/games.html | 2 + 12 files changed, 276 insertions(+), 76 deletions(-) rename .sqlx/{query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json => query-70dfab069e3fe8b8281ea26e99220c94392e0fa691dcbc5dc0b0a504304c7c21.json} (87%) create mode 100644 src/database/models/position.rs 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/src/api/games/make_move.rs b/src/api/games/make_move.rs index df1847c..8a0c8ba 100644 --- a/src/api/games/make_move.rs +++ b/src/api/games/make_move.rs @@ -6,7 +6,7 @@ use axum::{ }; use sqlx::types::Uuid; -use crate::api::models::ApiBoard; +use crate::api::models::ApiGameBoard; use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; @@ -28,30 +28,34 @@ pub async fn handler( if !Game::exists(&mut conn, game_id).await? { return Err(ReadBoardError::NotFound); } - let maybe_game_board = GameBoard::make_move(&mut conn, game_id, &uci_move, resign).await; - let board = match maybe_game_board { - Ok(game_board) => game_board.board().clone(), - Err(e) => match e { - GameError::InvalidMove(_, board) => board, - _ => return Err(e.into()), - }, - }; + + // 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)] diff --git a/src/api/games/read_game.rs b/src/api/games/read_game.rs index 3fc7c27..5007c8b 100644 --- a/src/api/games/read_game.rs +++ b/src/api/games/read_game.rs @@ -5,7 +5,7 @@ use axum::{ }; use sqlx::types::Uuid; -use crate::api::models::ApiBoard; +use crate::api::models::ApiGameBoard; use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; @@ -19,18 +19,21 @@ pub async fn handler( } let game_board = GameBoard::latest(&mut conn, game_id).await?; let board = game_board.board().clone(); - let api_board = ApiBoard { + 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)] 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 fc530e4..0bb2f69 100644 --- a/src/database/models/game.rs +++ b/src/database/models/game.rs @@ -52,6 +52,14 @@ 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 @@ -75,6 +83,7 @@ impl Game { winner as "winner: GameWinner", outcome as "outcome: GameOutcome" FROM games + ORDER BY created_at DESC "#, ) .fetch_all(&mut *conn) @@ -99,6 +108,18 @@ impl GameBoard { &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 -- assumes the game exists @@ -142,14 +163,12 @@ impl GameBoard { uci_move: &str, resign: bool, ) -> Result { - let game = GameBoard::latest(conn, game_id).await?; + let game = Self::latest(conn, game_id).await?; - // If the game is already over, return an error - if game.status != GameStatus::Active { - return Err(GameError::InvalidMove( - uci_move.to_string(), - game.board().clone(), - )); + // 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(); @@ -189,10 +208,12 @@ impl GameBoard { 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 @@ -314,6 +335,6 @@ impl GameBoard { 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/mod.rs b/src/database/models/mod.rs index e1dbfe9..fb21837 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -4,3 +4,6 @@ mod game_status; mod game_winner; 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 b914861..2b220ff 100644 --- a/templates/board.html +++ b/templates/board.html @@ -2,22 +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