From 0f02877238f75ae5c888af7c7fb1aba6c491824e Mon Sep 17 00:00:00 2001 From: Al Miller Date: Fri, 26 Jan 2024 16:15:54 -0500 Subject: [PATCH 1/2] streaming bones --- src/api/games/create_game.rs | 14 +- src/api/games/make_move.rs | 47 +++--- src/api/games/mod.rs | 2 + src/api/games/read_all_games.rs | 12 +- src/api/games/read_game.rs | 19 +-- src/api/games/read_game_board.rs | 59 ++++++++ src/api/games/watch_game_sse.rs | 39 +++++ src/api/mod.rs | 2 +- .../{api_board.rs => api_game_board.rs} | 13 ++ .../models/{api_game.rs => api_game_item.rs} | 6 +- src/api/models/mod.rs | 8 +- src/database/models/game.rs | 50 ++----- src/main.rs | 15 +- static/js/board.js | 141 ++++++++++-------- templates/board.html | 38 ----- templates/game.html | 8 - templates/game_board.html | 32 ++++ templates/game_index.html | 22 +++ templates/game_item.html | 8 + templates/{games.html => game_list.html} | 9 +- templates/index.html | 8 +- templates/stream.html | 7 - 22 files changed, 344 insertions(+), 215 deletions(-) create mode 100644 src/api/games/read_game_board.rs create mode 100644 src/api/games/watch_game_sse.rs rename src/api/models/{api_board.rs => api_game_board.rs} (94%) rename src/api/models/{api_game.rs => api_game_item.rs} (92%) delete mode 100644 templates/board.html delete mode 100644 templates/game.html create mode 100644 templates/game_board.html create mode 100644 templates/game_index.html create mode 100644 templates/game_item.html rename templates/{games.html => game_list.html} (58%) delete mode 100644 templates/stream.html diff --git a/src/api/games/create_game.rs b/src/api/games/create_game.rs index e1515d5..21d46d3 100644 --- a/src/api/games/create_game.rs +++ b/src/api/games/create_game.rs @@ -4,21 +4,23 @@ use axum::{ response::{IntoResponse, Response}, }; -use crate::api::models::ApiGame; +use crate::api::models::ApiGameItem; use crate::database::models::NewGame; use crate::AppState; pub async fn handler(State(state): State) -> Result { let game = NewGame::create(&state.database()).await?; - let api_game = ApiGame::from(game); - Ok(NewGameTemplate { api_game }) + let api_game = ApiGameItem::from(game); + Ok(GameItemTemplate { + game_item: api_game, + }) } #[derive(Template)] -#[template(path = "game.html")] -struct NewGameTemplate { - api_game: ApiGame, +#[template(path = "game_item.html")] +struct GameItemTemplate { + game_item: ApiGameItem, } #[derive(Debug, thiserror::Error)] diff --git a/src/api/games/make_move.rs b/src/api/games/make_move.rs index 8a0c8ba..ab33c97 100644 --- a/src/api/games/make_move.rs +++ b/src/api/games/make_move.rs @@ -1,16 +1,17 @@ -use askama::Template; use axum::{ extract::{Path, State}, + http::StatusCode, response::{IntoResponse, Response}, - Form, + Extension, Form, }; use sqlx::types::Uuid; -use crate::api::models::ApiGameBoard; use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; -#[derive(serde::Deserialize)] +use super::watch_game_sse::{GameUpdate, GameUpdateStream}; + +#[derive(serde::Deserialize, Debug)] pub struct MakeMoveRequest { #[serde(rename = "uciMove")] uci_move: String, @@ -19,6 +20,7 @@ pub struct MakeMoveRequest { pub async fn handler( State(state): State, + Extension(tx): Extension, Path(game_id): Path, Form(request): Form, ) -> Result { @@ -30,32 +32,15 @@ pub async fn handler( } // 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?; + 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 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, - status, - winner, - outcome, - }; - - Ok(TemplateApiGameBoard { api_board }) -} + if tx.send(GameUpdate).is_err() { + tracing::warn!("failed to send game update: game_id={}", game_id); + } -#[derive(Template)] -#[template(path = "board.html")] -struct TemplateApiGameBoard { - api_board: ApiGameBoard, + Ok(StatusCode::OK) } #[derive(Debug, thiserror::Error)] @@ -75,6 +60,16 @@ impl IntoResponse for ReadBoardError { let body = format!("{}", self); (axum::http::StatusCode::NOT_FOUND, body).into_response() } + ReadBoardError::Game(e) => match e { + GameError::InvalidMove(_) | GameError::GameComplete => { + let body = format!("{}", e); + (axum::http::StatusCode::BAD_REQUEST, body).into_response() + } + _ => { + let body = format!("internal server error: {}", e); + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_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 ab9bff2..22d670b 100644 --- a/src/api/games/mod.rs +++ b/src/api/games/mod.rs @@ -2,3 +2,5 @@ pub mod create_game; pub mod make_move; pub mod read_all_games; pub mod read_game; +pub mod read_game_board; +pub mod watch_game_sse; diff --git a/src/api/games/read_all_games.rs b/src/api/games/read_all_games.rs index 237939a..dd9bdfd 100644 --- a/src/api/games/read_all_games.rs +++ b/src/api/games/read_all_games.rs @@ -4,7 +4,7 @@ use axum::{ response::{IntoResponse, Response}, }; -use crate::api::models::ApiGame; +use crate::api::models::ApiGameItem; use crate::database::models::{Game, GameError}; use crate::AppState; @@ -13,14 +13,14 @@ pub async fn handler( ) -> Result { 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 }) + let game_items = games.into_iter().map(ApiGameItem::from).collect(); + Ok(GameList { game_items }) } #[derive(Template)] -#[template(path = "games.html")] -struct Records { - api_games: Vec, +#[template(path = "game_list.html")] +struct GameList { + game_items: Vec, } #[derive(Debug, thiserror::Error)] diff --git a/src/api/games/read_game.rs b/src/api/games/read_game.rs index 5007c8b..771ecf4 100644 --- a/src/api/games/read_game.rs +++ b/src/api/games/read_game.rs @@ -17,23 +17,18 @@ pub async fn handler( 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(TemplateApiGameBoard { api_board }) + + let api_game_board = ApiGameBoard::from(game_board); + + Ok(TemplateApiGameBoard { api_game_board }) } #[derive(Template)] -#[template(path = "board.html")] +#[template(path = "game_index.html")] struct TemplateApiGameBoard { - api_board: ApiGameBoard, + api_game_board: ApiGameBoard, } #[derive(Debug, thiserror::Error)] diff --git a/src/api/games/read_game_board.rs b/src/api/games/read_game_board.rs new file mode 100644 index 0000000..ba147d8 --- /dev/null +++ b/src/api/games/read_game_board.rs @@ -0,0 +1,59 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, +}; +use sqlx::types::Uuid; + +use crate::api::models::ApiGameBoard; +use crate::database::models::{Game, GameBoard, GameError}; +use crate::AppState; + +pub async fn handler( + State(state): State, + Path(game_id): Path, +) -> Result { + let mut conn = state.database().acquire().await?; + if !Game::exists(&mut conn, game_id).await? { + return Err(ReadBoardError::NotFound); + } + + let game_board = GameBoard::latest(&mut conn, game_id).await?; + + tracing::info!("read game board: {:?}", game_board); + + let api_game_board = ApiGameBoard::from(game_board); + + Ok(TemplateApiGameBoard { api_game_board }) +} + +#[derive(Template)] +#[template(path = "game_board.html")] +struct TemplateApiGameBoard { + api_game_board: ApiGameBoard, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadBoardError { + #[error("sqlx error: {0}")] + 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 { + 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/watch_game_sse.rs b/src/api/games/watch_game_sse.rs new file mode 100644 index 0000000..1ea010c --- /dev/null +++ b/src/api/games/watch_game_sse.rs @@ -0,0 +1,39 @@ +use std::convert::Infallible; +use std::time::Duration; + +use axum::{ + extract::Path, + response::{sse::Event, Sse}, + Extension, +}; +use serde::Serialize; +use sqlx::types::Uuid; +use tokio::sync::broadcast::Sender; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::{Stream, StreamExt as _}; + +pub type GameUpdateStream = Sender; + +#[derive(Clone, Serialize, Debug)] +pub struct GameUpdate; + +pub async fn handler( + Path(game_id): Path, + Extension(tx): Extension, +) -> Sse>> { + let rx = tx.subscribe(); + + let stream = BroadcastStream::new(rx); + + // Catch all updata events for this game + Sse::new( + stream + .map(move |_| Event::default().event(format!("game-update-{}", game_id))) + .map(Ok), + ) + .keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(60)) + .text("keep-alive-text"), + ) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index d028182..608fd61 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,2 +1,2 @@ pub mod games; -mod models; +pub mod models; diff --git a/src/api/models/api_board.rs b/src/api/models/api_game_board.rs similarity index 94% rename from src/api/models/api_board.rs rename to src/api/models/api_game_board.rs index 47b8c97..ebf64ea 100644 --- a/src/api/models/api_board.rs +++ b/src/api/models/api_game_board.rs @@ -2,6 +2,7 @@ use pleco::core::sq::SQ as Sq; use pleco::core::Piece; use pleco::core::Player; +use crate::database::models::GameBoard; use crate::database::models::GameOutcome; use crate::database::models::GameStatus; use crate::database::models::GameWinner; @@ -15,6 +16,18 @@ pub struct ApiGameBoard { pub outcome: Option, } +impl From for ApiGameBoard { + fn from(game_board: GameBoard) -> Self { + Self { + game_id: game_board.id().to_string(), + board: game_board.board().clone(), + status: game_board.status().clone(), + winner: game_board.winner().clone(), + outcome: game_board.outcome().clone(), + } + } +} + impl ApiGameBoard { pub fn game_id(&self) -> &str { &self.game_id diff --git a/src/api/models/api_game.rs b/src/api/models/api_game_item.rs similarity index 92% rename from src/api/models/api_game.rs rename to src/api/models/api_game_item.rs index 4b93b6d..7e9cebd 100644 --- a/src/api/models/api_game.rs +++ b/src/api/models/api_game_item.rs @@ -3,14 +3,14 @@ use crate::database::models::GameOutcome; use crate::database::models::GameStatus; use crate::database::models::GameWinner; -pub struct ApiGame { +pub struct ApiGameItem { id: String, status: GameStatus, winner: Option, outcome: Option, } -impl ApiGame { +impl ApiGameItem { pub fn id(&self) -> &str { &self.id } @@ -34,7 +34,7 @@ impl ApiGame { } } -impl From for ApiGame { +impl From for ApiGameItem { fn from(game: Game) -> Self { Self { id: game.id().to_string(), diff --git a/src/api/models/mod.rs b/src/api/models/mod.rs index d673baa..b0ad449 100644 --- a/src/api/models/mod.rs +++ b/src/api/models/mod.rs @@ -1,5 +1,5 @@ -mod api_board; -mod api_game; +mod api_game_board; +mod api_game_item; -pub use api_board::ApiGameBoard; -pub use api_game::ApiGame; +pub use api_game_board::ApiGameBoard; +pub use api_game_item::ApiGameItem; diff --git a/src/database/models/game.rs b/src/database/models/game.rs index 0bb2f69..2652caa 100644 --- a/src/database/models/game.rs +++ b/src/database/models/game.rs @@ -104,6 +104,10 @@ pub struct GameBoard { impl GameBoard { // Getters + pub fn id(&self) -> Uuid { + self.id + } + pub fn board(&self) -> &Board { &self.board } @@ -162,13 +166,12 @@ impl GameBoard { game_id: Uuid, uci_move: &str, resign: bool, - ) -> Result { + ) -> Result<(), GameError> { 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); + return Err(GameError::GameComplete); } let mut board = game.board().clone(); @@ -197,13 +200,7 @@ impl GameBoard { ) .execute(&mut *conn) .await?; - return Ok(Self { - id: game_id, - board: board.clone(), - status: game_status, - winner: Some(game_winner), - outcome: Some(game_outcome), - }); + return Ok(()); } let move_number = board.moves_played() as i32; @@ -212,8 +209,7 @@ impl GameBoard { // Attempt to make the move on the board let success = board.apply_uci_move(uci_move); if !success { - return Ok(game); - // return Err(GameError::InvalidMove(uci_move.to_string(), board.clone())); + return Err(GameError::InvalidMove(uci_move.to_string())); } // Insert the FEN into the database if it doesn't already exist @@ -273,13 +269,7 @@ impl GameBoard { ) .execute(&mut *conn) .await?; - return Ok(Self { - id: game_id, - board: board.clone(), - status: game_status, - winner: Some(game_winner), - outcome: Some(game_outcome), - }); + return Ok(()); } else if board.stalemate() { let game_winner = GameWinner::Draw; let game_outcome = GameOutcome::Stalemate; @@ -298,13 +288,7 @@ impl GameBoard { ) .execute(&mut *conn) .await?; - return Ok(Self { - id: game_id, - board: board.clone(), - status: game_status, - winner: Some(game_winner), - outcome: Some(game_outcome), - }); + return Ok(()); } // TODO: find a better way to do this -- maybe there will be an 'accept' game worflow in the future @@ -321,13 +305,7 @@ impl GameBoard { .await?; // Return the updated board - Ok(Self { - id: game_id, - board: board.clone(), - status: GameStatus::Active, - winner: None, - outcome: None, - }) + Ok(()) } } @@ -335,6 +313,8 @@ 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}")] + InvalidMove(String), + #[error("game already complete")] + GameComplete, } diff --git a/src/main.rs b/src/main.rs index b919c56..3b8705b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use askama::Template; +use axum::Extension; use axum::{response::IntoResponse, routing::get, Router}; use sqlx::PgPool; - +use tokio::sync::broadcast::channel; use tower_http::services::ServeDir; mod api; @@ -25,7 +26,7 @@ impl AppState { #[shuttle_runtime::main] async fn main( #[shuttle_shared_db::Postgres( - local_uri = "postgres://postgres:postgres@localhost:5432/krondor-chess-db" + local_uri = &std::env::var("DATABASE_URL").expect("DATABASE_URL must be set") )] db: PgPool, ) -> shuttle_axum::ShuttleAxum { @@ -36,6 +37,7 @@ async fn main( .expect("Looks like something went wrong with migrations :("); // Setup State let state = AppState::new(db); + let (tx, _rx) = channel::(10); // Register panics as they happen register_panic_logger(); @@ -53,7 +55,16 @@ async fn main( "/games/:game_id", get(api::games::read_game::handler).post(api::games::make_move::handler), ) + .route( + "/games/:game_id/sse", + get(api::games::watch_game_sse::handler), + ) + .route( + "/games/:game_id/board", + get(api::games::read_game_board::handler), + ) .with_state(state) + .layer(Extension(tx)) // Static assets .nest_service("/static", ServeDir::new("static")); diff --git a/static/js/board.js b/static/js/board.js index f77fed5..11159f8 100644 --- a/static/js/board.js +++ b/static/js/board.js @@ -1,75 +1,96 @@ -document.addEventListener('DOMContentLoaded', () => { - let selectedPiece = null; +let selectedPiece = null; +let fromSquare = null; +let toSquare = null; - // Add listeners to all squares - document.querySelectorAll('[class*="chess-square-"]').forEach(square => { - // On click - square.addEventListener('click', function() { - if (!selectedPiece && squareHasPiece(this) ) { - // Select the piece - selectedPiece = this; - this.classList.add('selected'); - } else if (selectedPiece) { - // Move the piece to the new square - movePiece(selectedPiece, this); - selectedPiece.classList.remove('selected'); +// Add listeners to all squares +document.querySelectorAll('[class*="chess-square-"]').forEach(square => { + // On click + square.addEventListener('click', function() { + if (!selectedPiece && squareHasPiece(this) ) { + // Select the piece + selectedPiece = this; + fromSquare = this; + this.classList.add('selected'); + } else if (selectedPiece) { + if (this === selectedPiece) { + // Deselect the piece + this.classList.remove('selected'); selectedPiece = null; + return; } - }); + + toSquare = this; + // Move the piece to the new square + movePiece(selectedPiece, this); + selectedPiece.classList.remove('selected'); + selectedPiece = null; + } }); +}); - // (Overly) Simple function to check if a square has a piece - function squareHasPiece(square) { - return square.innerHTML !== ''; +// TODO: make this work -- so that we can reset from bad moves +document.body.addEventListener('htmx:responseError', function(event) { + console.log(event); + // Check if the event is for the element you're interested in + if (event.target.id === 'submitMove') { + // Swap the pieces back + let fromSqaureHtml = fromSquare.innerHTML; + fromSquare.innerHTML = toSquare.innerHTML; + toSquare.innerHTML = fromSqaureHtml; } +}); + +// (Overly) Simple function to check if a square has a piece +function squareHasPiece(square) { + return square.innerHTML !== ''; +} - // Logic for moving a piece - function movePiece(fromSquare, toSquare) { - // Get the identifying class name (e.g. `chess-piece-P` or `chess-piece-p`) of the piece - let fromPieceClass = fromSquare.classList[1]; - let fromPiece = fromPieceClass.split('-')[2]; - - // Get the relevant squares - let fromPosition = fromSquare.getAttribute('id'); - let toPosition = toSquare.getAttribute('id'); - let toRank = toPosition[1]; +// Logic for moving a piece +function movePiece(fromSquare, toSquare) { + // Get the identifying class name (e.g. `chess-piece-P` or `chess-piece-p`) of the piece + let fromPieceClass = fromSquare.classList[1]; + let fromPiece = fromPieceClass.split('-')[2]; - // Determine the uci formatted move - let promotionHtml = null; - let promotionClass = null; - uciMove = `${fromPosition}${toPosition}`; - // Check if a pawn is being promoted - if ((fromPiece === 'P' && toRank === '8') || (fromPiece === 'p' && toRank === '1')) { - // TODO: allow user to select piece to promote to piece of their choice - uciMove += 'q'; // Promote to queen - if (fromPiece === 'P') { - promotionHtml = '♕'; - promotionClass = 'chess-piece-Q'; - } else { - promotionHtml = '♛'; - promotionClass = 'chess-piece-q'; - } - } + // Get the relevant squares + let fromPosition = fromSquare.getAttribute('id'); + let toPosition = toSquare.getAttribute('id'); + let toRank = toPosition[1]; - // Update the board - let toPieceHtml = promotionHtml ?? fromSquare.innerHTML; - let toPieceClass = promotionClass ?? fromPieceClass; - toSquare.innerHTML = toPieceHtml; // Add the piece to the new square - if (toSquare.classList.length === 1) { - toSquare.classList.add(toPieceClass); + // Determine the uci formatted move + let promotionHtml = null; + let promotionClass = null; + uciMove = `${fromPosition}${toPosition}`; + // Check if a pawn is being promoted + if ((fromPiece === 'P' && toRank === '8') || (fromPiece === 'p' && toRank === '1')) { + // TODO: allow user to select piece to promote to piece of their choice + uciMove += 'q'; // Promote to queen + if (fromPiece === 'P') { + promotionHtml = '♕'; + promotionClass = 'chess-piece-Q'; } else { - toSquare.classList.replace(toSquare.classList[1], toPieceClass); // Update the class of the new square + promotionHtml = '♛'; + promotionClass = 'chess-piece-q'; } - fromSquare.innerHTML = ''; // Remove the piece from the current square - sendMove(uciMove); } - function sendMove(uciMove) { - console.log(uciMove); - // Write our move to the hidden input field - document.getElementById('uciMoveInput').value = uciMove; - // Make the button visible - document.getElementById('moveForm').style.display = 'block'; + // Update the board + let toPieceHtml = promotionHtml ?? fromSquare.innerHTML; + let toPieceClass = promotionClass ?? fromPieceClass; + toSquare.innerHTML = toPieceHtml; // Add the piece to the new square + if (toSquare.classList.length === 1) { + toSquare.classList.add(toPieceClass); + } else { + toSquare.classList.replace(toSquare.classList[1], toPieceClass); // Update the class of the new square } -}); + fromSquare.innerHTML = ''; // Remove the piece from the current square + sendMove(uciMove); +} + +function sendMove(uciMove) { + console.log(uciMove); + // Write our move to the hidden input field + document.getElementById('uciMoveInput').value = uciMove; + // Make the button visible + document.getElementById('moveForm').style.display = 'block'; +} diff --git a/templates/board.html b/templates/board.html deleted file mode 100644 index 2b220ff..0000000 --- a/templates/board.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "base.html" %} - -{% 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 deleted file mode 100644 index 93fc5c5..0000000 --- a/templates/game.html +++ /dev/null @@ -1,8 +0,0 @@ - - - {{ api_game.id() }} - {{ api_game.status() }} - {{ api_game.outcome() }} - {{ api_game.winner() }} - - diff --git a/templates/game_board.html b/templates/game_board.html new file mode 100644 index 0000000..013fe89 --- /dev/null +++ b/templates/game_board.html @@ -0,0 +1,32 @@ + +
+ + + {% let board_html = api_game_board.board_html() %} + {% let game_id = api_game_board.game_id() %} + + {% if api_game_board.status() == "complete" %} +

Game over!

+

Winner: {{ api_game_board.winner() }}

+

Outcome: {{ api_game_board.outcome() }}

+ {% else %} +

Turn: {{ api_game_board.turn() }}

+ {% endif %} + + {{ board_html|safe }} + + {% if api_game_board.status() == "active" %} + + +
+ + + + +
+ {% endif %} +
\ No newline at end of file diff --git a/templates/game_index.html b/templates/game_index.html new file mode 100644 index 0000000..174a3f9 --- /dev/null +++ b/templates/game_index.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} + +

Board

+ + + +{% let game_id = api_game_board.game_id() %} + +
+ +
+ + + {% include "game_board.html" %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/game_item.html b/templates/game_item.html new file mode 100644 index 0000000..81ad40c --- /dev/null +++ b/templates/game_item.html @@ -0,0 +1,8 @@ + + + {{ game_item.id() }} + {{ game_item.status() }} + {{ game_item.outcome() }} + {{ game_item.winner() }} + + diff --git a/templates/games.html b/templates/game_list.html similarity index 58% rename from templates/games.html rename to templates/game_list.html index e1589c1..7a1379d 100644 --- a/templates/games.html +++ b/templates/game_list.html @@ -1,5 +1,4 @@ - -
+
@@ -9,9 +8,9 @@ - - {% for api_game in api_games %} - {% include "game.html" %} + + {% for game_item in game_items %} + {% include "game_item.html" %} {% endfor %}
Winner
diff --git a/templates/index.html b/templates/index.html index 1f02503..67c39d2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,8 +2,12 @@ {% extends "base.html" %} {% block content %} -

Games

- +

Welcome to Krondor Chess!

+ +

Take a peek at some of the games currently being played, or creata a new one!

+ + +
Loading...
diff --git a/templates/stream.html b/templates/stream.html deleted file mode 100644 index d46eec4..0000000 --- a/templates/stream.html +++ /dev/null @@ -1,7 +0,0 @@ - - - -
- -
- From 6661b30fc64a2d742ab74ef51b8777d854c9fa33 Mon Sep 17 00:00:00 2001 From: Al Miller Date: Sun, 28 Jan 2024 23:52:47 -0500 Subject: [PATCH 2/2] feat: streaming --- Cargo.lock | 364 +++++++++++++++++++------------ Cargo.toml | 18 +- src/api/games/make_move.rs | 8 +- src/api/games/mod.rs | 1 - src/api/games/read_game.rs | 10 +- src/api/games/read_game_board.rs | 59 ----- src/api/games/watch_game_sse.rs | 16 +- src/api/mod.rs | 1 + src/api/models/api_game_board.rs | 1 + src/api/templates/game_board.rs | 9 + src/api/templates/game_index.rs | 9 + src/api/templates/mod.rs | 5 + src/main.rs | 8 +- static/js/board.js | 99 +++++---- static/js/sse.js | 306 ++++++++++++++++++++++++++ templates/base.html | 7 +- templates/game_board.html | 33 +-- templates/game_index.html | 21 +- templates/index.html | 1 - 19 files changed, 669 insertions(+), 307 deletions(-) delete mode 100644 src/api/games/read_game_board.rs create mode 100644 src/api/templates/game_board.rs create mode 100644 src/api/templates/game_index.rs create mode 100644 src/api/templates/mod.rs create mode 100644 static/js/sse.js diff --git a/Cargo.lock b/Cargo.lock index 6808e5a..5ac641b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,15 +391,15 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.31" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-targets 0.48.5", + "windows-targets 0.52.0", ] [[package]] @@ -627,6 +627,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -689,21 +698,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -850,7 +844,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.11", - "indexmap 2.1.0", + "indexmap 2.2.1", "slab", "tokio", "tokio-util", @@ -869,7 +863,7 @@ dependencies = [ "futures-sink", "futures-util", "http 1.0.0", - "indexmap 2.1.0", + "indexmap 2.2.1", "slab", "tokio", "tokio-util", @@ -1105,6 +1099,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.11", + "hyper 0.14.28", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-timeout" version = "0.4.1" @@ -1180,14 +1188,20 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "433de089bd45971eecf4668ee0ee8f4cec17db4f8bd8f7bc3197a6ce37aa7d9b" dependencies = [ "equivalent", "hashbrown 0.14.3", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "itertools" version = "0.10.5" @@ -1411,24 +1425,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c55d0c9dc43dedfd2414deb74ade67687749ef88b1d3482024d4c81d901a7a83" -[[package]] -name = "native-tls" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" -dependencies = [ - "lazy_static", - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.27.1" @@ -1553,50 +1549,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "openssl" -version = "0.10.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671" -dependencies = [ - "bitflags 2.4.2", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "openssl-sys" -version = "0.9.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1665caf8ab2dc9aef43d1c0023bd904633a6a05cb30b0ad59bec2ae986e57a7" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.21.0" @@ -1605,7 +1557,7 @@ checksum = "1e32339a5dc40459130b3bd269e9892439f55b33e772d2a9d402a789baaf4e8a" dependencies = [ "futures-core", "futures-sink", - "indexmap 2.1.0", + "indexmap 2.2.1", "js-sys", "once_cell", "pin-project-lite", @@ -1773,18 +1725,18 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" dependencies = [ "proc-macro2", "quote", @@ -1882,9 +1834,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.76" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] @@ -2129,13 +2081,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.3", + "regex-automata 0.4.5", "regex-syntax 0.8.2", ] @@ -2150,9 +2102,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" dependencies = [ "aho-corasick", "memchr", @@ -2171,6 +2123,46 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +[[package]] +name = "reqwest" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "ring" version = "0.17.7" @@ -2230,6 +2222,37 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustrict" version = "0.7.12" @@ -2259,15 +2282,6 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" -[[package]] -name = "schannel" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2275,26 +2289,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "security-framework" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.9.1" +name = "sct" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "core-foundation-sys", - "libc", + "ring", + "untrusted", ] [[package]] @@ -2308,18 +2309,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", @@ -2328,9 +2329,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.111" +version = "1.0.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" dependencies = [ "itoa", "ryu", @@ -2392,9 +2393,9 @@ dependencies = [ [[package]] name = "shuttle-axum" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1037680b94f73044b9205ec27cd6fc8018ca0b8aebb707ef15191965ff3ebd1" +checksum = "b9ea1b505a5b2976647168561bc09224e600d30d7186ff53baa739a6ea61f321" dependencies = [ "axum 0.7.4", "shuttle-runtime", @@ -2402,9 +2403,9 @@ dependencies = [ [[package]] name = "shuttle-codegen" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "032ec76ecb7e3698d7a9e836e3649a88faa13e53f4d13eaa314030761f4ecac9" +checksum = "eb0b7a98b90227032415702ebd7aa920bfffac52704ede68705ab508d7b477da" dependencies = [ "proc-macro-error", "proc-macro2", @@ -2414,9 +2415,9 @@ dependencies = [ [[package]] name = "shuttle-common" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e3ddec998dd953743ac0e47de7ae6d65fcb1ffc45e68a4ceecd2558f8cad1b" +checksum = "735deac187695b33410cd8cc18ef409db54bcfb27698500eb4624d30f45d7431" dependencies = [ "anyhow", "async-trait", @@ -2436,6 +2437,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "pin-project", + "reqwest", "rustrict", "semver", "serde", @@ -2458,9 +2460,9 @@ dependencies = [ [[package]] name = "shuttle-proto" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff4e554df686fb01ce42636bf768ba902800934ba4e22f5a5d96082f608d325" +checksum = "11950d438e79df346f0fa7f4ef6265310fade58eeb8122d054ed41f8e8c025ff" dependencies = [ "futures-core", "prost 0.12.3", @@ -2471,9 +2473,9 @@ dependencies = [ [[package]] name = "shuttle-runtime" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4e48b0112dc1ba7ae056197810808a27948a7352340506f355da7e4bdc6cb4" +checksum = "21e548acb39c387c657c059729a1a8b0e90176ef675b5c989e71b14711ee0dde" dependencies = [ "anyhow", "async-trait", @@ -2497,23 +2499,24 @@ dependencies = [ [[package]] name = "shuttle-service" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809db2a4b8511b07f363942737459bb4ec4f859d9adac7759c52677beabe9af4" +checksum = "02a13d269e942132d7936fa538f2e68af267f938a507c91b5e40a5b1b6881c04" dependencies = [ "anyhow", "async-trait", "serde", "shuttle-common", + "shuttle-proto", "strfmt", "thiserror", ] [[package]] name = "shuttle-shared-db" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fbf377957afb7c9a5b74b13a897c466252e46f6e78a4ae48b9fe2282baacdd" +checksum = "5d1953714724a537c668eef00b86d47d7b8c44a99048fab1588b2afdd2536037" dependencies = [ "async-trait", "serde", @@ -2669,13 +2672,14 @@ dependencies = [ "futures-util", "hashlink", "hex", - "indexmap 2.1.0", + "indexmap 2.2.1", "log", "memchr", - "native-tls", "once_cell", "paste", "percent-encoding", + "rustls", + "rustls-pemfile", "serde", "serde_json", "sha2", @@ -2688,6 +2692,7 @@ dependencies = [ "tracing", "url", "uuid", + "webpki-roots", ] [[package]] @@ -2938,6 +2943,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.9.0" @@ -3067,6 +3093,16 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.14" @@ -3501,6 +3537,18 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.90" @@ -3530,6 +3578,16 @@ version = "0.2.90" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" +[[package]] +name = "web-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "0.2.4" @@ -3540,6 +3598,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" + [[package]] name = "whoami" version = "1.4.1" @@ -3709,6 +3773,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "zerocopy" version = "0.7.32" diff --git a/Cargo.toml b/Cargo.toml index ab315b8..a97f4cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,12 +6,12 @@ edition = "2021" [dependencies] askama = { version = "0.12.1", features = ["with-axum"] } askama_axum = "0.4.0" -axum = "0.7.3" +axum = { version = "^0.7", features = ["tokio"] } serde = { version = "1.0.195", features = ["derive"] } serde_json = "1.0.111" -shuttle-axum = "0.36.0" -shuttle-runtime = "0.36.0" -shuttle-shared-db = { version = "0.36.0", features = ["postgres"] } +shuttle-axum = "^0.37" +shuttle-runtime = "^0.37" +shuttle-shared-db = { version = "^0.37", features = ["sqlx", "postgres"] } sqlx = { version = "0.7.3", features = ["macros", "uuid", "time"] } thiserror = "1.0.56" time = { version = "0.3.31", features = ["serde"] } @@ -19,15 +19,9 @@ tokio = "1.28.2" tokio-stream = { version = "0.1.14", features = ["sync"] } tower-http = { version = "0.5.1", features = ["fs"] } uuid = { version = "1.7.0", features = ["serde"] } +pleco = "0.5.0" tracing = "^0.1" tracing-appender = "^0.2" tracing-futures = { version = "^0.2", default-features = false, features = ["std-future"] } -tracing-subscriber = { version = "^0.3", default-features = false, features = ["ansi", "env-filter", "fmt", "local-time", "time", "tracing"] } -pleco = "0.5.0" - - -# TODO: add prop tests for database types -# [dev-dependencies] -# proptest = "1.4.0" -# proptest-derive = "0.4.0" +tracing-subscriber = { version = "^0.3", default-features = false, features = ["ansi", "env-filter", "fmt", "local-time", "time", "tracing"] } \ No newline at end of file diff --git a/src/api/games/make_move.rs b/src/api/games/make_move.rs index ab33c97..7f531f4 100644 --- a/src/api/games/make_move.rs +++ b/src/api/games/make_move.rs @@ -6,10 +6,12 @@ use axum::{ }; use sqlx::types::Uuid; +use crate::api::models::ApiGameBoard; +use crate::api::templates::GameBoardTemplate; use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; -use super::watch_game_sse::{GameUpdate, GameUpdateStream}; +use super::watch_game_sse::GameUpdateStream; #[derive(serde::Deserialize, Debug)] pub struct MakeMoveRequest { @@ -34,9 +36,11 @@ pub async fn handler( // Returns the updated board if the move was valid. Otherwise, returns the latest board. GameBoard::make_move(&mut conn, game_id, &uci_move, resign).await?; + // Wow this really sucks, the client should just read this again + let api_game_board = ApiGameBoard::from(GameBoard::latest(&mut conn, game_id).await?); conn.commit().await?; - if tx.send(GameUpdate).is_err() { + if tx.send(GameBoardTemplate { api_game_board }).is_err() { tracing::warn!("failed to send game update: game_id={}", game_id); } diff --git a/src/api/games/mod.rs b/src/api/games/mod.rs index 22d670b..4c7d183 100644 --- a/src/api/games/mod.rs +++ b/src/api/games/mod.rs @@ -2,5 +2,4 @@ pub mod create_game; pub mod make_move; pub mod read_all_games; pub mod read_game; -pub mod read_game_board; pub mod watch_game_sse; diff --git a/src/api/games/read_game.rs b/src/api/games/read_game.rs index 771ecf4..106f643 100644 --- a/src/api/games/read_game.rs +++ b/src/api/games/read_game.rs @@ -1,4 +1,3 @@ -use askama::Template; use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, @@ -6,6 +5,7 @@ use axum::{ use sqlx::types::Uuid; use crate::api::models::ApiGameBoard; +use crate::api::templates::GameIndexTemplate; use crate::database::models::{Game, GameBoard, GameError}; use crate::AppState; @@ -22,13 +22,7 @@ pub async fn handler( let api_game_board = ApiGameBoard::from(game_board); - Ok(TemplateApiGameBoard { api_game_board }) -} - -#[derive(Template)] -#[template(path = "game_index.html")] -struct TemplateApiGameBoard { - api_game_board: ApiGameBoard, + Ok(GameIndexTemplate { api_game_board }) } #[derive(Debug, thiserror::Error)] diff --git a/src/api/games/read_game_board.rs b/src/api/games/read_game_board.rs deleted file mode 100644 index ba147d8..0000000 --- a/src/api/games/read_game_board.rs +++ /dev/null @@ -1,59 +0,0 @@ -use askama::Template; -use axum::{ - extract::{Path, State}, - response::{IntoResponse, Response}, -}; -use sqlx::types::Uuid; - -use crate::api::models::ApiGameBoard; -use crate::database::models::{Game, GameBoard, GameError}; -use crate::AppState; - -pub async fn handler( - State(state): State, - Path(game_id): Path, -) -> Result { - let mut conn = state.database().acquire().await?; - if !Game::exists(&mut conn, game_id).await? { - return Err(ReadBoardError::NotFound); - } - - let game_board = GameBoard::latest(&mut conn, game_id).await?; - - tracing::info!("read game board: {:?}", game_board); - - let api_game_board = ApiGameBoard::from(game_board); - - Ok(TemplateApiGameBoard { api_game_board }) -} - -#[derive(Template)] -#[template(path = "game_board.html")] -struct TemplateApiGameBoard { - api_game_board: ApiGameBoard, -} - -#[derive(Debug, thiserror::Error)] -pub enum ReadBoardError { - #[error("sqlx error: {0}")] - 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 { - 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/watch_game_sse.rs b/src/api/games/watch_game_sse.rs index 1ea010c..b749333 100644 --- a/src/api/games/watch_game_sse.rs +++ b/src/api/games/watch_game_sse.rs @@ -1,22 +1,23 @@ use std::convert::Infallible; use std::time::Duration; +use askama::Template; use axum::{ extract::Path, response::{sse::Event, Sse}, Extension, }; -use serde::Serialize; use sqlx::types::Uuid; use tokio::sync::broadcast::Sender; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt as _}; -pub type GameUpdateStream = Sender; +use crate::api::templates::GameBoardTemplate; -#[derive(Clone, Serialize, Debug)] -pub struct GameUpdate; +// TODO: generalize and use the read_game_board handler +pub type GameUpdateStream = Sender; +// TODO: proper error handling pub async fn handler( Path(game_id): Path, Extension(tx): Extension, @@ -28,7 +29,12 @@ pub async fn handler( // Catch all updata events for this game Sse::new( stream - .map(move |_| Event::default().event(format!("game-update-{}", game_id))) + .map(move |tagb| { + let tagb = tagb.unwrap(); + Event::default() + .event(format!("game-update-{}", game_id)) + .data(tagb.render().unwrap()) + }) .map(Ok), ) .keep_alive( diff --git a/src/api/mod.rs b/src/api/mod.rs index 608fd61..44a11f2 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,2 +1,3 @@ pub mod games; pub mod models; +pub mod templates; diff --git a/src/api/models/api_game_board.rs b/src/api/models/api_game_board.rs index ebf64ea..5b0f1c5 100644 --- a/src/api/models/api_game_board.rs +++ b/src/api/models/api_game_board.rs @@ -8,6 +8,7 @@ use crate::database::models::GameStatus; use crate::database::models::GameWinner; use crate::database::types::DatabaseBoard as Board; +#[derive(Clone)] pub struct ApiGameBoard { pub game_id: String, pub board: Board, diff --git a/src/api/templates/game_board.rs b/src/api/templates/game_board.rs new file mode 100644 index 0000000..ce215cf --- /dev/null +++ b/src/api/templates/game_board.rs @@ -0,0 +1,9 @@ +use askama::Template; + +use crate::api::models::ApiGameBoard; + +#[derive(Template, Clone)] +#[template(path = "game_board.html")] +pub struct GameBoardTemplate { + pub api_game_board: ApiGameBoard, +} diff --git a/src/api/templates/game_index.rs b/src/api/templates/game_index.rs new file mode 100644 index 0000000..5a8ba10 --- /dev/null +++ b/src/api/templates/game_index.rs @@ -0,0 +1,9 @@ +use askama::Template; + +use crate::api::models::ApiGameBoard; + +#[derive(Template)] +#[template(path = "game_index.html")] +pub struct GameIndexTemplate { + pub api_game_board: ApiGameBoard, +} diff --git a/src/api/templates/mod.rs b/src/api/templates/mod.rs new file mode 100644 index 0000000..2d7e2db --- /dev/null +++ b/src/api/templates/mod.rs @@ -0,0 +1,5 @@ +mod game_board; +mod game_index; + +pub use game_board::GameBoardTemplate; +pub use game_index::GameIndexTemplate; diff --git a/src/main.rs b/src/main.rs index 3b8705b..fb7c1c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,8 @@ use tower_http::services::ServeDir; mod api; mod database; +use api::templates::GameBoardTemplate; + #[derive(Clone)] pub struct AppState { database: PgPool, @@ -37,7 +39,7 @@ async fn main( .expect("Looks like something went wrong with migrations :("); // Setup State let state = AppState::new(db); - let (tx, _rx) = channel::(10); + let (tx, _rx) = channel::(10); // Register panics as they happen register_panic_logger(); @@ -59,10 +61,6 @@ async fn main( "/games/:game_id/sse", get(api::games::watch_game_sse::handler), ) - .route( - "/games/:game_id/board", - get(api::games::read_game_board::handler), - ) .with_state(state) .layer(Extension(tx)) // Static assets diff --git a/static/js/board.js b/static/js/board.js index 11159f8..fed47f5 100644 --- a/static/js/board.js +++ b/static/js/board.js @@ -1,45 +1,8 @@ +// WOOO globabl state let selectedPiece = null; let fromSquare = null; let toSquare = null; -// Add listeners to all squares -document.querySelectorAll('[class*="chess-square-"]').forEach(square => { - // On click - square.addEventListener('click', function() { - if (!selectedPiece && squareHasPiece(this) ) { - // Select the piece - selectedPiece = this; - fromSquare = this; - this.classList.add('selected'); - } else if (selectedPiece) { - if (this === selectedPiece) { - // Deselect the piece - this.classList.remove('selected'); - selectedPiece = null; - return; - } - - toSquare = this; - // Move the piece to the new square - movePiece(selectedPiece, this); - selectedPiece.classList.remove('selected'); - selectedPiece = null; - } - }); -}); - -// TODO: make this work -- so that we can reset from bad moves -document.body.addEventListener('htmx:responseError', function(event) { - console.log(event); - // Check if the event is for the element you're interested in - if (event.target.id === 'submitMove') { - // Swap the pieces back - let fromSqaureHtml = fromSquare.innerHTML; - fromSquare.innerHTML = toSquare.innerHTML; - toSquare.innerHTML = fromSqaureHtml; - } -}); - // (Overly) Simple function to check if a square has a piece function squareHasPiece(square) { return square.innerHTML !== ''; @@ -94,3 +57,63 @@ function sendMove(uciMove) { document.getElementById('moveForm').style.display = 'block'; } +initBoard = function() { + selectedPiece = null; + fromSquare = null; + toSquare = null; + + // Remove all event listeners + document.querySelectorAll('[class*="chess-square-"]').forEach(square => { + square.replaceWith(square.cloneNode(true)); + }); + + document.getElementById('moveForm').style.display = 'none'; + document.getElementById('uciMoveInput').value = ''; + + // Assuming 'chessboard' is the ID of the parent element + const chessboard = document.getElementById('chessboard'); + chessboard.addEventListener('click', function(event) { + // Check if the clicked element is a chess square + const clickedSquare = event.target.closest('[class*="chess-square-"]'); + if (!clickedSquare) return; // Not a chess square, ignore the click + if (!selectedPiece && squareHasPiece(clickedSquare)) { + // Select the piece + selectedPiece = clickedSquare; + fromSquare = clickedSquare; + clickedSquare.classList.add('selected'); + } else if (selectedPiece) { + if (clickedSquare === selectedPiece) { + // Deselect the piece + clickedSquare.classList.remove('selected'); + selectedPiece = null; + return; + } + + toSquare = clickedSquare; + // Move the piece to the new square + movePiece(selectedPiece, clickedSquare); + selectedPiece.classList.remove('selected'); + selectedPiece = null; + } + }); + + document.body.addEventListener('htmx:responseError', function(event) { + // Check if the event is for the element you're interested in + if (event.target.id === 'submitMove') { + // Swap the pieces back + let fromSqaureHtml = fromSquare.innerHTML; + fromSquare.innerHTML = toSquare.innerHTML; + toSquare.innerHTML = fromSqaureHtml; + } + }); +} + +document.addEventListener('DOMContentLoaded', function() { + initBoard(); +}); + +document.body.addEventListener('htmx:afterSwap', function(event) { + selectedPiece = null; + fromSquare = null; + toSquare = null; +}) \ No newline at end of file diff --git a/static/js/sse.js b/static/js/sse.js new file mode 100644 index 0000000..4aabc93 --- /dev/null +++ b/static/js/sse.js @@ -0,0 +1,306 @@ +/* +Copied from: https://raw.githubusercontent.com/bigskysoftware/htmx-extensions/main/ext/sse.js + +Server Sent Events Extension +============================ +This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. + +*/ + +(function() { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + htmx.defineExtension('sse', { + + /** + * Init saves the provided reference to the internal HTMX API. + * + * @param {import("../htmx").HtmxInternalApi} api + * @returns void + */ + init: function(apiRef) { + // store a reference to the internal API. + api = apiRef + + // set a function in the public API for creating new EventSource objects + if (htmx.createEventSource == undefined) { + htmx.createEventSource = createEventSource + } + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + * @returns void + */ + onEvent: function(name, evt) { + switch (name) { + case 'htmx:beforeCleanupElement': + var internalData = api.getInternalData(evt.target) + // Try to remove remove an EventSource when elements are removed + if (internalData.sseEventSource) { + internalData.sseEventSource.close() + } + + return + + // Try to create EventSources when elements are processed + case 'htmx:afterProcessNode': + ensureEventSourceOnElement(evt.target) + registerSSE(evt.target) + } + } + }) + + /// //////////////////////////////////////////// + // HELPER FUNCTIONS + /// //////////////////////////////////////////// + + /** + * createEventSource is the default method for creating new EventSource objects. + * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * + * @param {string} url + * @returns EventSource + */ + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }) + } + + /** + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * + * @param {HTMLElement} elt + */ + function registerSSE(elt) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + // Add message handlers for every `sse-swap` attribute + queryAttributeOnThisOrChildren(elt, 'sse-swap').forEach(function(child) { + var sseSwapAttr = api.getAttributeValue(child, 'sse-swap') + var sseEventNames = sseSwapAttr.split(',') + + for (var i = 0; i < sseEventNames.length; i++) { + var sseEventName = sseEventNames[i].trim() + var listener = function(event) { + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { + return + } + + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener) + } + + // swap the response into the DOM and trigger a notification + if(!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { + return + } + swap(child, event.data) + api.triggerEvent(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(child).sseEventListener = listener + source.addEventListener(sseEventName, listener) + } + }) + + // Add message handlers for every `hx-trigger="sse:*"` attribute + queryAttributeOnThisOrChildren(elt, 'hx-trigger').forEach(function(child) { + var sseEventName = api.getAttributeValue(child, 'hx-trigger') + if (sseEventName == null) { + return + } + + // Only process hx-triggers for events with the "sse:" prefix + if (sseEventName.slice(0, 4) != 'sse:') { + return + } + + var listener = function(event) { + if (maybeCloseSSESource(sourceElement)) { + return + } + + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener) + } + + // Trigger events to be handled by the rest of htmx + htmx.trigger(child, sseEventName, event) + htmx.trigger(child, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(sseEventName.slice(4), listener) + }) + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + if (elt == null) { + return null + } + + // handle extension source creation attribute + queryAttributeOnThisOrChildren(elt, 'sse-connect').forEach(function(child) { + var sseURL = api.getAttributeValue(child, 'sse-connect') + if (sseURL == null) { + return + } + + ensureEventSource(child, sseURL, retryCount) + }) + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url) + + source.onerror = function(err) { + // Log an error event + api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0 + var timeout = Math.random() * (2 ^ retryCount) * 500 + window.setTimeout(function() { + ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)) + }, timeout) + } + } + + source.onopen = function(evt) { + api.triggerEvent(elt, 'htmx:sseOpen', { source }) + } + + api.getInternalData(elt).sseEventSource = source + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource + if (source != undefined) { + source.close() + // source = null + return true + } + } + return false + } + + /** + * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. + * + * @param {HTMLElement} elt + * @param {string} attributeName + */ + function queryAttributeOnThisOrChildren(elt, attributeName) { + var result = [] + + // If the parent element also contains the requested attribute, then add it to the results too. + if (api.hasAttribute(elt, attributeName)) { + result.push(elt) + } + + // Search all child nodes that match the requested attribute + elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + ']').forEach(function(node) { + result.push(node) + }) + + return result + } + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + api.withExtensions(elt, function(extension) { + content = extension.transformResponse(content, null, elt) + }) + + var swapSpec = api.getSwapSpecification(elt) + var target = api.getTarget(elt) + var settleInfo = api.makeSettleInfo(elt) + + api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo) + + settleInfo.elts.forEach(function(elt) { + if (elt.classList) { + elt.classList.add(htmx.config.settlingClass) + } + api.triggerEvent(elt, 'htmx:beforeSettle') + }) + + // Handle settle tasks (with delay if requested) + if (swapSpec.settleDelay > 0) { + setTimeout(doSettle(settleInfo), swapSpec.settleDelay) + } else { + doSettle(settleInfo)() + } + } + + /** + * doSettle mirrors much of the functionality in htmx that + * settles elements after their content has been swapped. + * TODO: this should be published by htmx, and not duplicated here + * @param {import("../htmx").HtmxSettleInfo} settleInfo + * @returns () => void + */ + function doSettle(settleInfo) { + return function() { + settleInfo.tasks.forEach(function(task) { + task.call() + }) + + settleInfo.elts.forEach(function(elt) { + if (elt.classList) { + elt.classList.remove(htmx.config.settlingClass) + } + api.triggerEvent(elt, 'htmx:afterSettle') + }) + } + } + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null + } + })() + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 82895b9..bb7aae4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,10 +1,9 @@ - - + + + K-Chess {% block head %}{% endblock %} diff --git a/templates/game_board.html b/templates/game_board.html index 013fe89..a30f990 100644 --- a/templates/game_board.html +++ b/templates/game_board.html @@ -1,31 +1,32 @@ - -
- - +
{% let board_html = api_game_board.board_html() %} {% let game_id = api_game_board.game_id() %} - - {% if api_game_board.status() == "complete" %} -

Game over!

-

Winner: {{ api_game_board.winner() }}

-

Outcome: {{ api_game_board.outcome() }}

- {% else %} -

Turn: {{ api_game_board.turn() }}

- {% endif %} - {{ board_html|safe }} + {% if api_game_board.status() == "complete" %} +

Game over!

+

Winner: {{ api_game_board.winner() }}

+

Outcome: {{ api_game_board.outcome() }}

+ {% else %} +

Turn: {{ api_game_board.turn() }}

+ {% endif %} - {% if api_game_board.status() == "active" %} + {{ board_html|safe }} + + + + {% if api_game_board.status() == "active" || api_game_board.status() == "created" %} + {% endif %} + {% if api_game_board.status() == "active" %}
- +
{% endif %} diff --git a/templates/game_index.html b/templates/game_index.html index 174a3f9..04b8ca5 100644 --- a/templates/game_index.html +++ b/templates/game_index.html @@ -2,21 +2,20 @@ {% block content %} -

Board

+{% let game_id = api_game_board.game_id() %} + +

Game {{ game_id }}

- - -{% let game_id = api_game_board.game_id() %} -
- -
- - - {% include "game_board.html" %} -
+ + + + + +
+ {% include "game_board.html" %}
{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 67c39d2..697ab80 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,4 +1,3 @@ - {% extends "base.html" %} {% block content %}