diff --git a/.sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json b/.sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json new file mode 100644 index 0000000..4d22c0a --- /dev/null +++ b/.sqlx/query-04cd0b12578f44aacf5846cadbaf12d8b99a83834706ad207bdb08e0ecb9118f.json @@ -0,0 +1,22 @@ +{ + "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-4df3c2631ab6aecb42120c68766a4e7a63bc01ae3bd65302afc2213a5f39633c.json b/.sqlx/query-17602c73cd0bf74a20007c7b9e1b8ed3809ccd439f4f4eff0216b4aba1021bc1.json similarity index 61% rename from .sqlx/query-4df3c2631ab6aecb42120c68766a4e7a63bc01ae3bd65302afc2213a5f39633c.json rename to .sqlx/query-17602c73cd0bf74a20007c7b9e1b8ed3809ccd439f4f4eff0216b4aba1021bc1.json index 865c71b..f8d3768 100644 --- a/.sqlx/query-4df3c2631ab6aecb42120c68766a4e7a63bc01ae3bd65302afc2213a5f39633c.json +++ b/.sqlx/query-17602c73cd0bf74a20007c7b9e1b8ed3809ccd439f4f4eff0216b4aba1021bc1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO games DEFAULT VALUES RETURNING\n id as \"id: Uuid\",\n current_fen_id as \"current_fen_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 ", + "query": "INSERT INTO games DEFAULT VALUES RETURNING\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 ", "describe": { "columns": [ { @@ -10,31 +10,26 @@ }, { "ordinal": 1, - "name": "current_fen_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 2, "name": "created_at: OffsetDateTime", "type_info": "Timestamp" }, { - "ordinal": 3, + "ordinal": 2, "name": "updated_at: OffsetDateTime", "type_info": "Timestamp" }, { - "ordinal": 4, + "ordinal": 3, "name": "status: GameStatus", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 4, "name": "winner: GameWinner", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 5, "name": "outcome: GameOutcome", "type_info": "Varchar" } @@ -47,10 +42,9 @@ false, false, false, - false, true, true ] }, - "hash": "4df3c2631ab6aecb42120c68766a4e7a63bc01ae3bd65302afc2213a5f39633c" + "hash": "17602c73cd0bf74a20007c7b9e1b8ed3809ccd439f4f4eff0216b4aba1021bc1" } diff --git a/.sqlx/query-6b7b09da1c25608208fd0cb530b0f82fa6a266b8dbfb5cf0495b0c4f12ad9e50.json b/.sqlx/query-6b7b09da1c25608208fd0cb530b0f82fa6a266b8dbfb5cf0495b0c4f12ad9e50.json new file mode 100644 index 0000000..ab4cc7c --- /dev/null +++ b/.sqlx/query-6b7b09da1c25608208fd0cb530b0f82fa6a266b8dbfb5cf0495b0c4f12ad9e50.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO positions (board)\n VALUES ($1)\n RETURNING id as \"id: Uuid\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id: Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6b7b09da1c25608208fd0cb530b0f82fa6a266b8dbfb5cf0495b0c4f12ad9e50" +} diff --git a/.sqlx/query-a7b1c1e77b20e1a3a4bfd931874cd60eb342d42f9d0cd44ab10450e2499c42a2.json b/.sqlx/query-a7b1c1e77b20e1a3a4bfd931874cd60eb342d42f9d0cd44ab10450e2499c42a2.json deleted file mode 100644 index 94bd27a..0000000 --- a/.sqlx/query-a7b1c1e77b20e1a3a4bfd931874cd60eb342d42f9d0cd44ab10450e2499c42a2.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n games.id as \"id: Uuid\",\n fens.fen as \"current_fen: String\"\n FROM games\n INNER JOIN fens ON games.current_fen_id = fens.id\n WHERE games.id = $1\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "current_fen: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "a7b1c1e77b20e1a3a4bfd931874cd60eb342d42f9d0cd44ab10450e2499c42a2" -} diff --git a/.sqlx/query-373f64ffad0cbebf7b1809f605b93126dd303aa85537a5fa09997c6c59d41c6d.json b/.sqlx/query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json similarity index 62% rename from .sqlx/query-373f64ffad0cbebf7b1809f605b93126dd303aa85537a5fa09997c6c59d41c6d.json rename to .sqlx/query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json index d73429b..bbc91eb 100644 --- a/.sqlx/query-373f64ffad0cbebf7b1809f605b93126dd303aa85537a5fa09997c6c59d41c6d.json +++ b/.sqlx/query-b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n id as \"id: Uuid\",\n current_fen_id as \"current_fen_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 ", "describe": { "columns": [ { @@ -10,31 +10,26 @@ }, { "ordinal": 1, - "name": "current_fen_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 2, "name": "created_at: OffsetDateTime", "type_info": "Timestamp" }, { - "ordinal": 3, + "ordinal": 2, "name": "updated_at: OffsetDateTime", "type_info": "Timestamp" }, { - "ordinal": 4, + "ordinal": 3, "name": "status: GameStatus", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 4, "name": "winner: GameWinner", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 5, "name": "outcome: GameOutcome", "type_info": "Varchar" } @@ -47,10 +42,9 @@ false, false, false, - false, true, true ] }, - "hash": "373f64ffad0cbebf7b1809f605b93126dd303aa85537a5fa09997c6c59d41c6d" + "hash": "b7b72db08aa367a47d6416be39acb4277dcc5a9111394da19fac399942254b85" } diff --git a/.sqlx/query-ef1d261f66b72ab55e5ad11b3028b06848c454b8c143737393561b68d9ed8bb1.json b/.sqlx/query-ef1d261f66b72ab55e5ad11b3028b06848c454b8c143737393561b68d9ed8bb1.json new file mode 100644 index 0000000..7324707 --- /dev/null +++ b/.sqlx/query-ef1d261f66b72ab55e5ad11b3028b06848c454b8c143737393561b68d9ed8bb1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO moves (game_id, position_id, move_number)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "ef1d261f66b72ab55e5ad11b3028b06848c454b8c143737393561b68d9ed8bb1" +} diff --git a/Makefile b/Makefile index ddcf67f..f045425 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ clippy: postgres: ./bin/postgres.sh run +.PHONY: postgres-clean +postgres-clean: + ./bin/postgres.sh clean + .PHONY: test test: cargo test --all --workspace --bins --tests --benches diff --git a/migrations/20240124210837_drop_fens_table.sql b/migrations/20240124210837_drop_fens_table.sql new file mode 100644 index 0000000..35cd494 --- /dev/null +++ b/migrations/20240124210837_drop_fens_table.sql @@ -0,0 +1,7 @@ +-- We don't need the current_fen_id column anymore for games +ALTER TABLE games DROP COLUMN current_fen_id; +-- We don't need the fen_id column anymore for moves +ALTER TABLE moves DROP COLUMN fen_id; + +-- We dont need the fens table anymore +DROP TABLE fens; \ No newline at end of file diff --git a/migrations/20240124213710_refactor_moves_into_moves_and_positions.sql b/migrations/20240124213710_refactor_moves_into_moves_and_positions.sql new file mode 100644 index 0000000..6164753 --- /dev/null +++ b/migrations/20240124213710_refactor_moves_into_moves_and_positions.sql @@ -0,0 +1,38 @@ +-- ALTER THE MOVES TABLE + +-- Drop Unique constraint on moves (game_id, move_number) +ALTER TABLE moves DROP CONSTRAINT moves_game_id_move_number_key; + +-- Drop our index on moves (game_id) +DROP INDEX idx_moves_game_id; + +-- Drop the game_id, move, and move_number columns +ALTER TABLE moves DROP COLUMN game_id; +ALTER TABLE moves DROP COLUMN move_number; +ALTER TABLE moves DROP COLUMN move; + +-- Insert just a plain fen column that should be unique +ALTER TABLE moves ADD COLUMN board TEXT UNIQUE NOT NULL; + +-- RENAME THE MOVES TABLE TO POSITIONS + +-- Rename the entire moves table to positions +-- This will describe every unqiue position that a game has been in +ALTER TABLE moves RENAME TO positions; + +-- CREATE A NEW MOVES TABLE + +-- Create a new table, also called moves, to track moves made in a game +CREATE TABLE IF NOT EXISTS moves ( + id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(), + -- The game id + game_id UUID NOT NULL REFERENCES games(id) ON DELETE CASCADE, + -- The position of the game and the last move made + position_id UUID NOT NULL REFERENCES positions(id) ON DELETE CASCADE, + -- TODO: do we need this? This information is available within the FEN of the position + -- The move number of the game + move_number INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- enforce uniqueness on point in a game. also implement an index + UNIQUE (game_id, move_number) +); \ No newline at end of file diff --git a/src/api/games/board/mod.rs b/src/api/games/board/mod.rs deleted file mode 100644 index 0b21506..0000000 --- a/src/api/games/board/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod read_board; diff --git a/src/api/games/board/read_board.rs b/src/api/games/board/read_board.rs deleted file mode 100644 index 5fb6c41..0000000 --- a/src/api/games/board/read_board.rs +++ /dev/null @@ -1,60 +0,0 @@ -use askama::Template; -use axum::{ - extract::{Path, State}, - response::{IntoResponse, Response}, -}; -use sqlx::types::Uuid; - -use crate::api::models::{ApiBoard, ApiBoardError}; -use crate::database::models::PartialGameWithFen; -use crate::AppState; - -pub async fn handler( - State(state): State, - Path(game_id): Path, -) -> Result { - let conn = state.database(); - let partial_game_with_fen = match PartialGameWithFen::read(&conn, game_id).await? { - Some(partial_game_with_fen) => partial_game_with_fen, - None => return Err(ReadBoardError::NotFound), - }; - let api_board = ApiBoard::try_from(partial_game_with_fen)?; - - Ok(Board { api_board }) -} - -#[derive(Template)] -#[template(path = "board.html")] -struct Board { - api_board: ApiBoard, -} - -#[derive(Debug, thiserror::Error)] -pub enum ReadBoardError { - #[error("api board error: {0}")] - ApiBoard(#[from] ApiBoardError), - #[error("sqlx error: {0}")] - Sqlx(#[from] sqlx::Error), - #[error("game not found")] - NotFound, -} - -impl IntoResponse for ReadBoardError { - fn into_response(self) -> Response { - match self { - Self::NotFound => { - let body = format!("{}", self); - (axum::http::StatusCode::NOT_FOUND, body).into_response() - } - _ => { - let error = format!("{}", self); - tracing::error!("{}", error); - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "Internal Server Error", - ) - .into_response() - } - } - } -} diff --git a/src/api/games/make_move.rs b/src/api/games/make_move.rs new file mode 100644 index 0000000..a51871e --- /dev/null +++ b/src/api/games/make_move.rs @@ -0,0 +1,65 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + Form, +}; +use sqlx::types::Uuid; + +use crate::api::models::ApiBoard; +use crate::database::models::{Game, GameError}; +use crate::AppState; + +#[derive(serde::Deserialize)] +pub struct MakeMoveRequest { + #[serde(rename = "uciMove")] + uci_move: String, +} + +pub async fn handler( + State(state): State, + Path(game_id): Path, + Form(request): Form, +) -> Result { + let uci_move = request.uci_move; + 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 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 { + board, + game_id: game_id.to_string(), + }; + + Ok(TemplateApiBoard { api_board }) +} + +#[derive(Template)] +#[template(path = "board.html")] +struct TemplateApiBoard { + api_board: ApiBoard, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadBoardError { + #[error("sqlx error: {0}")] + Sqlx(#[from] sqlx::Error), + #[error("game error: {0}")] + Game(#[from] GameError), +} + +impl IntoResponse for ReadBoardError { + fn into_response(self) -> Response { + let body = format!("{}", self); + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + } +} diff --git a/src/api/games/mod.rs b/src/api/games/mod.rs index a0c8765..ab9bff2 100644 --- a/src/api/games/mod.rs +++ b/src/api/games/mod.rs @@ -1,3 +1,4 @@ -pub mod board; pub mod create_game; +pub mod make_move; pub mod read_all_games; +pub mod read_game; diff --git a/src/api/games/read_all_games.rs b/src/api/games/read_all_games.rs index cebcb09..237939a 100644 --- a/src/api/games/read_all_games.rs +++ b/src/api/games/read_all_games.rs @@ -5,14 +5,14 @@ use axum::{ }; use crate::api::models::ApiGame; -use crate::database::models::Game; +use crate::database::models::{Game, GameError}; use crate::AppState; pub async fn handler( State(state): State, ) -> Result { - let conn = state.database(); - let games = Game::read_all(&conn).await?; + let mut conn = state.database().acquire().await?; + let games = Game::read_all(&mut conn).await?; let api_games = games.into_iter().map(ApiGame::from).collect(); Ok(Records { api_games }) } @@ -27,6 +27,8 @@ struct Records { pub enum ReadAllGamesError { #[error("sqlx error: {0}")] Sqlx(#[from] sqlx::Error), + #[error("game error: {0}")] + Game(#[from] GameError), } impl IntoResponse for ReadAllGamesError { diff --git a/src/api/games/read_game.rs b/src/api/games/read_game.rs new file mode 100644 index 0000000..89624ec --- /dev/null +++ b/src/api/games/read_game.rs @@ -0,0 +1,45 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, +}; +use sqlx::types::Uuid; + +use crate::api::models::ApiBoard; +use crate::database::models::{Game, GameError}; +use crate::AppState; + +pub async fn handler( + State(state): State, + 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 { + board, + game_id: game_id.to_string(), + }; + + Ok(TemplateApiBoard { api_board }) +} + +#[derive(Template)] +#[template(path = "board.html")] +struct TemplateApiBoard { + api_board: ApiBoard, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadBoardError { + #[error("sqlx error: {0}")] + Sqlx(#[from] sqlx::Error), + #[error("game error: {0}")] + Game(#[from] GameError), +} + +impl IntoResponse for ReadBoardError { + fn into_response(self) -> 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 dad2158..6db15a2 100644 --- a/src/api/models/api_board.rs +++ b/src/api/models/api_board.rs @@ -1,92 +1,69 @@ -use std::convert::TryFrom; - -use pleco::board::Board; use pleco::core::sq::SQ as Sq; use pleco::core::Piece; -use crate::database::models::PartialGameWithFen; +use crate::database::types::DatabaseBoard as Board; pub struct ApiBoard { - game_id: String, - board_html: String, + pub game_id: String, + pub board: Board, } impl ApiBoard { pub fn game_id(&self) -> &str { &self.game_id } - pub fn board_html(&self) -> &str { - &self.board_html - } -} - -impl TryFrom for ApiBoard { - type Error = ApiBoardError; - - fn try_from(game: PartialGameWithFen) -> Result { - let game_id = game.id().to_string(); - let board_html = render_html_board(game.current_fen())?; - Ok(Self { - game_id, - board_html, - }) - } -} -/// Render a FEN formatted str into an HTML chess board -fn render_html_board(fen: &str) -> Result { - // Read the FEN into a board - let board = Board::from_fen(fen).map_err(|e| ApiBoardError::FenBuilder(format!("{:?}", e)))?; + pub fn board_html(&self) -> String { + // We'll just pass raw HTML to our template + let mut html_board = String::new(); + html_board.push_str(""); - // We'll just pass raw HTML to our template - let mut html_board = String::new(); - html_board.push_str("
"); + // 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" + }; - // 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 = 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 - )); + // 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("
{}{}
"); - Ok(html_board) + html_board.push_str(""); + html_board + } } fn render_html_piece(piece: Piece) -> Option { @@ -106,10 +83,3 @@ fn render_html_piece(piece: Piece) -> Option { Piece::BlackKing => Some("♚".to_string()), } } - -#[derive(Debug, thiserror::Error)] -pub enum ApiBoardError { - // TODO: should use FenBuildError here, but it doesn't implement Error - #[error("fen builder error: {0}")] - FenBuilder(String), -} diff --git a/src/api/models/mod.rs b/src/api/models/mod.rs index 02ad387..3c1b6ea 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, ApiBoardError}; +pub use api_board::ApiBoard; pub use api_game::ApiGame; diff --git a/src/database/mod.rs b/src/database/mod.rs index c446ac8..1c9014f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1 +1,2 @@ pub mod models; +pub mod types; diff --git a/src/database/models/fen.rs b/src/database/models/fen.rs deleted file mode 100644 index 30ba006..0000000 --- a/src/database/models/fen.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(FromRow)] -pub struct Fen { - id: Uuid, - // TODO: Database type for FEN - fen: String, - created_at: OffsetDateTime, -} diff --git a/src/database/models/game.rs b/src/database/models/game.rs index eb9f13c..baa14d6 100644 --- a/src/database/models/game.rs +++ b/src/database/models/game.rs @@ -1,5 +1,6 @@ use sqlx::types::Uuid; use sqlx::FromRow; +use sqlx::PgConnection; use sqlx::PgPool; use time::OffsetDateTime; @@ -7,6 +8,8 @@ use super::game_outcome::GameOutcome; use super::game_status::GameStatus; use super::game_winner::GameWinner; +use crate::database::types::DatabaseBoard as Board; + pub struct NewGame; impl NewGame { @@ -15,7 +18,6 @@ impl NewGame { Game, r#"INSERT INTO games DEFAULT VALUES RETURNING id as "id: Uuid", - current_fen_id as "current_fen_id: Uuid", created_at as "created_at: OffsetDateTime", updated_at as "updated_at: OffsetDateTime", status as "status: GameStatus", @@ -33,7 +35,6 @@ impl NewGame { #[derive(Debug, FromRow)] pub struct Game { id: Uuid, - current_fen_id: Uuid, created_at: OffsetDateTime, updated_at: OffsetDateTime, status: GameStatus, @@ -46,32 +47,87 @@ impl Game { pub fn id(&self) -> Uuid { self.id } - // pub fn current_fen_id(&self) -> Uuid { - // self.current_fen_id - // } - // pub fn created_at(&self) -> OffsetDateTime { - // self.created_at - // } - // pub fn updated_at(&self) -> OffsetDateTime { - // self.updated_at - // } + 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 + LIMIT 1 + "#, + game_id, + ) + .fetch_optional(&mut *conn) + .await?; + + match maybe_board { + Some(board) => Ok(board), + None => Ok(Board::new()), + } + } + + /// Make a move in a game. Return the updated board. + 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?; + let move_number = board.moves_played() as i32; + + // 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())); + } + + // Insert the FEN into the database if it doesn't already exist + // Return the position 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?; + + // Insert the move into the database + sqlx::query!( + r#"INSERT INTO moves (game_id, position_id, move_number) + VALUES ($1, $2, $3) + "#, + game_id, + position_id, + move_number, + ) + .execute(&mut *conn) + .await?; + + // Return the updated board + Ok(board) + } // TODO: pagination - pub async fn read_all(conn: &PgPool) -> Result, sqlx::Error> { + /// 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", - current_fen_id as "current_fen_id: Uuid", created_at as "created_at: OffsetDateTime", updated_at as "updated_at: OffsetDateTime", status as "status: GameStatus", @@ -80,43 +136,16 @@ impl Game { FROM games "#, ) - .fetch_all(conn) + .fetch_all(&mut *conn) .await?; Ok(games) } } -#[derive(Debug, Clone)] -pub struct PartialGameWithFen { - id: Uuid, - current_fen: String, -} - -impl PartialGameWithFen { - // Getters - pub fn id(&self) -> Uuid { - self.id - } - - pub fn current_fen(&self) -> &str { - &self.current_fen - } - - pub async fn read(conn: &PgPool, id: Uuid) -> Result, sqlx::Error> { - let game = sqlx::query_as!( - PartialGameWithFen, - r#"SELECT - games.id as "id: Uuid", - fens.fen as "current_fen: String" - FROM games - INNER JOIN fens ON games.current_fen_id = fens.id - WHERE games.id = $1 - LIMIT 1 - "#, - id - ) - .fetch_optional(conn) - .await?; - Ok(game) - } +#[derive(Debug, thiserror::Error)] +pub enum GameError { + #[error("sqlx error: {0}")] + Sqlx(#[from] sqlx::Error), + #[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 21eb2bb..3045e55 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, NewGame, PartialGameWithFen}; +pub use game::{Game, GameError, NewGame}; diff --git a/src/database/models/move.rs b/src/database/models/move.rs deleted file mode 100644 index 73b70d1..0000000 --- a/src/database/models/move.rs +++ /dev/null @@ -1,10 +0,0 @@ -#[derive(FromRow)] -pub struct Move { - id: Uuid, - game_id: Uuid, - fen_id: Uuid, - move_number: i32, - // TODO: Database tupe for move - r#move: String, - created_at: OffsetDateTime -} \ No newline at end of file diff --git a/src/database/types/database_board.rs b/src/database/types/database_board.rs new file mode 100644 index 0000000..8a8c3a6 --- /dev/null +++ b/src/database/types/database_board.rs @@ -0,0 +1,75 @@ +use std::fmt::{self, Debug, Display, Formatter}; +use std::ops::{Deref, DerefMut}; + +use pleco::board::Board; +use sqlx::error::BoxDynError; +use sqlx::postgres::{PgTypeInfo, PgValueRef}; +use sqlx::{Decode, Postgres, Type}; + +#[derive(Clone)] +pub struct DatabaseBoard(Board); + +impl DatabaseBoard { + pub fn new() -> Self { + Self(Board::start_pos()) + } +} + +impl Decode<'_, Postgres> for DatabaseBoard { + fn decode(value: PgValueRef<'_>) -> Result { + let fen_str = >::decode(value)?; + + // TODO: length check + let board = Board::from_fen(&fen_str).map_err(|_| DatabaseBoardError::InvalidFen)?; + + Ok(Self(board)) + } +} + +impl Type for DatabaseBoard { + fn compatible(ty: &PgTypeInfo) -> bool { + >::compatible(ty) + } + + fn type_info() -> PgTypeInfo { + >::type_info() + } +} + +impl Deref for DatabaseBoard { + type Target = Board; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for DatabaseBoard { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Debug for DatabaseBoard { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.fen()) + } +} + +impl Display for DatabaseBoard { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.fen()) + } +} + +impl From for DatabaseBoard { + fn from(val: Board) -> Self { + Self(val) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DatabaseBoardError { + #[error("invalid fen string")] + InvalidFen, +} diff --git a/src/database/types/mod.rs b/src/database/types/mod.rs new file mode 100644 index 0000000..a8d52fa --- /dev/null +++ b/src/database/types/mod.rs @@ -0,0 +1,3 @@ +mod database_board; + +pub use database_board::DatabaseBoard; diff --git a/src/main.rs b/src/main.rs index bcd9e73..b919c56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ impl AppState { #[shuttle_runtime::main] async fn main( #[shuttle_shared_db::Postgres( - local_uri = "postgres://postgres:postgres@localhost:5432/postgres" + local_uri = "postgres://postgres:postgres@localhost:5432/krondor-chess-db" )] db: PgPool, ) -> shuttle_axum::ShuttleAxum { @@ -51,7 +51,7 @@ async fn main( ) .route( "/games/:game_id", - get(api::games::board::read_board::handler), + get(api::games::read_game::handler).post(api::games::make_move::handler), ) .with_state(state) // Static assets