From 287872d9aa569a292176892a9e7a2929762b6085 Mon Sep 17 00:00:00 2001 From: Merlik787 Droi Date: Wed, 29 Jul 2026 13:10:22 +0100 Subject: [PATCH 1/3] refactor(service): consolidate auth to Argon2, add authenticate_player --- backend/modules/service/Cargo.toml | 1 - backend/modules/service/src/lib.rs | 5 ++-- backend/modules/service/src/players.rs | 32 +++++++++++++++++++++-- backend/modules/service/src/user.rs | 35 ++++++++++++-------------- 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/backend/modules/service/Cargo.toml b/backend/modules/service/Cargo.toml index 9443d206..f44eed2e 100644 --- a/backend/modules/service/Cargo.toml +++ b/backend/modules/service/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" [dependencies] sea-orm = { version = "1.1.0", features = [ "sqlx-postgres", "runtime-tokio-native-tls", "macros", "mock" ] } uuid = { version = "1", features = ["v4", "serde"] } -bcrypt = "0.15" argon2 = "0.5" rand = "0.8" chrono = { version = "0.4", features = ["serde"] } diff --git a/backend/modules/service/src/lib.rs b/backend/modules/service/src/lib.rs index 53a67079..22dd26a3 100644 --- a/backend/modules/service/src/lib.rs +++ b/backend/modules/service/src/lib.rs @@ -1,4 +1,5 @@ -pub mod helper; -pub mod players; pub mod engine_service; pub mod games; +pub mod helper; +pub mod players; +pub mod user; diff --git a/backend/modules/service/src/players.rs b/backend/modules/service/src/players.rs index 93d02e8b..bc679bca 100644 --- a/backend/modules/service/src/players.rs +++ b/backend/modules/service/src/players.rs @@ -1,7 +1,7 @@ use crate::helper::password; use db::db::db::get_db; -use dto::players::{NewPlayer, UpdatePlayer}; use db_entity::player::{self, Model}; +use dto::players::{NewPlayer, UpdatePlayer}; use error::error::ApiError; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; @@ -67,7 +67,10 @@ pub async fn add_player(payload: NewPlayer) -> Result { id: Uuid::new_v4(), username: payload.username, email: payload.email, - password_hash: password::hash_password(&payload.password).ok().map(|h| h.into_bytes()).unwrap_or_default(), + password_hash: password::hash_password(&payload.password) + .ok() + .map(|h| h.into_bytes()) + .unwrap_or_default(), biography: String::new(), country: String::new(), flair: String::new(), @@ -153,6 +156,31 @@ pub async fn update_player(id: Uuid, payload: UpdatePlayer) -> Result Result { + let db = get_db().await; + + let user = player::Entity::find() + .filter(player::Column::Username.eq(username)) + .filter(player::Column::IsEnabled.eq(true)) + .one(&db) + .await?; + + match user { + Some(usr) => { + let stored_hash = String::from_utf8(usr.password_hash.clone()) + .map_err(|_| ApiError::InvalidCredentials)?; + match password::verify_password(password, &stored_hash) { + Ok(()) => Ok(usr), + Err(_) => Err(ApiError::InvalidCredentials), + } + } + None => Err(ApiError::InvalidCredentials), + } +} + pub async fn delete_player(id: Uuid) -> Result<(), ApiError> { let db = get_db().await; let existing_player = find_player_by_id(id).await?; diff --git a/backend/modules/service/src/user.rs b/backend/modules/service/src/user.rs index 21c0a115..553ccbbc 100644 --- a/backend/modules/service/src/user.rs +++ b/backend/modules/service/src/user.rs @@ -1,10 +1,9 @@ +use crate::helper::password; +use chrono::Utc; +use db_entity::user; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, ActiveValue, + ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, }; -use db_entity::user::{self, Entity as UserEntity}; -use chrono::Utc; -use bcrypt::{hash, verify, DEFAULT_COST}; - /// User service for authentication and user management pub struct UserService; @@ -37,8 +36,7 @@ impl UserService { return Err(DbErr::Custom("Email already exists".to_string())); } - // Hash password - let password_hash = hash(password, DEFAULT_COST) + let password_hash = password::hash_password(password) .map_err(|_| DbErr::Custom("Failed to hash password".to_string()))?; let now = Utc::now(); @@ -70,16 +68,9 @@ impl UserService { match user { Some(user_model) => { - // Verify password - match verify(password, &user_model.password_hash) { - Ok(is_valid) => { - if is_valid { - Ok(user_model) - } else { - Err(DbErr::Custom("Invalid password".to_string())) - } - } - Err(_) => Err(DbErr::Custom("Authentication failed".to_string())), + match password::verify_password(password, &user_model.password_hash) { + Ok(()) => Ok(user_model), + Err(_) => Err(DbErr::Custom("Invalid password".to_string())), } } None => Err(DbErr::Custom("User not found".to_string())), @@ -87,7 +78,10 @@ impl UserService { } /// Get user by ID - pub async fn get_by_id(db: &DatabaseConnection, user_id: i32) -> Result, DbErr> { + pub async fn get_by_id( + db: &DatabaseConnection, + user_id: i32, + ) -> Result, DbErr> { user::Entity::find_by_id(user_id).one(db).await } @@ -103,7 +97,10 @@ impl UserService { } /// Get user by email - pub async fn get_by_email(db: &DatabaseConnection, email: &str) -> Result, DbErr> { + pub async fn get_by_email( + db: &DatabaseConnection, + email: &str, + ) -> Result, DbErr> { user::Entity::find() .filter(user::Column::Email.eq(email)) .one(db) From 887ff34bde866d488e91fedcf450c8352bb99bf9 Mon Sep 17 00:00:00 2001 From: Merlik787 Droi Date: Wed, 29 Jul 2026 13:27:05 +0100 Subject: [PATCH 2/3] fix: apply formatting and clippy fixes across workspace --- backend/Cargo.lock | 43 -- backend/modules/api/src/ai.rs | 68 ++-- backend/modules/api/src/auth.rs | 144 ++++--- backend/modules/api/src/games.rs | 125 +++--- backend/modules/api/src/lib.rs | 14 +- backend/modules/api/src/openapi.rs | 26 +- backend/modules/api/src/players.rs | 11 +- backend/modules/api/src/server.rs | 97 +++-- backend/modules/api/src/test/mod.rs | 12 +- backend/modules/api/src/test/rate_limit.rs | 30 +- backend/modules/api/src/ws.rs | 173 ++++++--- backend/modules/challenge/src/api.rs | 90 ++--- .../challenge/src/puzzle_validation.rs | 125 +++--- .../modules/chess/src/bitboard/bitboard.rs | 1 + backend/modules/chess/src/bitboard/board.rs | 102 +++-- backend/modules/chess/src/bitboard/mod.rs | 3 +- backend/modules/chess/src/lib.rs | 11 +- backend/modules/chess/src/pgn.rs | 96 +++-- backend/modules/chess/src/rating.rs | 127 +++--- backend/modules/chess/tests/board_tests.rs | 31 +- .../chess/tests/rating_integration_test.rs | 8 +- backend/modules/chess/tests/rating_tests.rs | 1 + .../modules/chess/tests/time_control_tests.rs | 2 +- .../db/entity/src/bin/game_benchmark.rs | 94 +++-- backend/modules/db/entity/src/game.rs | 1 - backend/modules/db/entity/src/lib.rs | 4 +- backend/modules/db/entity/src/player.rs | 4 +- .../modules/db/entity/src/refresh_token.rs | 2 +- .../db/entity/tests/game_smoke_test.rs | 37 +- backend/modules/db/entity/user.rs | 2 +- backend/modules/db/migrations/src/lib.rs | 6 +- .../m20250123_000001_create_users_table.rs | 6 +- .../src/m20250324_add_elo_rating_to_player.rs | 2 +- .../m20250428_121011_create_players_table.rs | 20 +- .../m20250429_163843_create_games_table.rs | 64 ++- .../m20250429_192832_add_common_indexes.rs | 26 +- ...20250605_090000_add_game_search_indexes.rs | 15 +- ...m20260127_180000_add_game_imported_flag.rs | 8 +- .../m20260127_create_refresh_tokens_table.rs | 32 +- backend/modules/db/src/bin/seeder.rs | 82 ++-- backend/modules/db/src/db.rs | 6 +- backend/modules/db/src/lib.rs | 53 +-- backend/modules/dto/src/ai.rs | 65 ++-- backend/modules/dto/src/auth.rs | 24 +- backend/modules/dto/src/games.rs | 94 +++-- backend/modules/dto/src/lib.rs | 6 +- backend/modules/dto/src/players.rs | 14 +- backend/modules/dto/src/responses.rs | 7 +- backend/modules/engine/src/parser.rs | 55 ++- backend/modules/engine/src/process.rs | 77 +++- backend/modules/error/src/error.rs | 33 +- backend/modules/error/src/lib.rs | 2 +- backend/modules/matchmaking/elo.rs | 1 - backend/modules/matchmaking/mod.rs | 6 +- backend/modules/matchmaking/models.rs | 7 +- backend/modules/matchmaking/routes.rs | 5 +- backend/modules/matchmaking/service.rs | 21 +- backend/modules/security/src/jwt.rs | 45 +-- backend/modules/security/src/lib.rs | 2 +- backend/modules/security/src/token_service.rs | 42 +- backend/modules/service/src/engine_service.rs | 24 +- backend/modules/service/src/games.rs | 178 +++++---- backend/modules/service/src/helper/mod.rs | 2 +- .../modules/service/src/helper/password.rs | 4 +- backend/modules/st_core/src/endpoint.rs | 93 +++-- backend/modules/st_core/src/lib.rs | 4 +- backend/modules/st_core/src/main.rs | 10 +- backend/modules/st_core/src/models.rs | 19 +- backend/modules/st_core/src/nft.rs | 95 ++--- .../st_core/src/transaction_builder.rs | 81 ++-- .../modules/st_core/tests/integration_test.rs | 8 +- backend/modules/tournament/src/arena.rs | 44 ++- backend/modules/tournament/src/bracket.rs | 100 ++++- backend/modules/tournament/src/lib.rs | 16 +- backend/modules/tournament/src/pairing.rs | 2 +- backend/modules/tournament/src/swiss/mod.rs | 34 +- .../modules/tournament/src/swiss/pairer.rs | 75 ++-- backend/modules/tournament/src/swiss/tests.rs | 110 +++--- backend/src/chess960/api.rs | 324 ++++++++-------- backend/src/chess960/generator.rs | 366 +++++++++--------- backend/src/chess960/mod.rs | 14 +- backend/src/chess960/models.rs | 56 +-- backend/src/engine/lc0_orchestrator.rs | 6 + backend/src/main.rs | 2 +- contracts/game_contract/src/lib.rs | 99 ++--- 85 files changed, 2260 insertions(+), 1816 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index bddd3519..ac402f6f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -665,19 +665,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bcrypt" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" -dependencies = [ - "base64 0.22.1", - "blowfish", - "getrandom 0.2.17", - "subtle", - "zeroize", -] - [[package]] name = "bigdecimal" version = "0.4.10" @@ -750,16 +737,6 @@ dependencies = [ "piper", ] -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher", -] - [[package]] name = "borsh" version = "1.6.1" @@ -941,16 +918,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.6.1" @@ -2012,15 +1979,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3556,7 +3514,6 @@ version = "0.1.0" dependencies = [ "argon2", "base64 0.22.1", - "bcrypt", "chess", "chrono", "db", diff --git a/backend/modules/api/src/ai.rs b/backend/modules/api/src/ai.rs index 8a87cf53..3e1d4ef6 100644 --- a/backend/modules/api/src/ai.rs +++ b/backend/modules/api/src/ai.rs @@ -1,12 +1,11 @@ -use actix_web::{ - HttpResponse, post, - web::Json, -}; +use actix_web::{post, web::Json, HttpResponse}; use dto::{ - ai::{AiSuggestionRequest, AiSuggestionResponse, PositionAnalysisRequest, PositionAnalysisResponse}, + ai::{ + AiSuggestionRequest, AiSuggestionResponse, PositionAnalysisRequest, + PositionAnalysisResponse, + }, responses::ValidationErrorResponse, }; -use error::error::ApiError; use serde_json::json; use validator::Validate; @@ -32,25 +31,21 @@ pub async fn get_ai_suggestion(payload: Json) -> HttpRespon Ok(_) => { let engine_path = env::var("ENGINE_PATH").unwrap_or_else(|_| "stockfish".to_string()); let engine_service = EngineService::new(engine_path); - + let start_time = std::time::Instant::now(); - let result = engine_service.get_suggestion( - &payload.0.fen, - payload.0.depth, - payload.0.time_limit_ms - ).await; + let result = engine_service + .get_suggestion(&payload.0.fen, payload.0.depth, payload.0.time_limit_ms) + .await; let elapsed = u32::try_from(start_time.elapsed().as_millis()).unwrap_or(u32::MAX); - + match result { - Ok(result) => { - HttpResponse::Ok().json(AiSuggestionResponse { - best_move: result.best_move, - evaluation: result.evaluation.unwrap_or(0.0), - depth: result.depth.unwrap_or(payload.0.depth.unwrap_or(10)), - principal_variation: result.principal_variation, - computation_time_ms: elapsed, - }) - } + Ok(result) => HttpResponse::Ok().json(AiSuggestionResponse { + best_move: result.best_move, + evaluation: result.evaluation.unwrap_or(0.0), + depth: result.depth.unwrap_or(payload.0.depth.unwrap_or(10)), + principal_variation: result.principal_variation, + computation_time_ms: elapsed, + }), Err(e) => { log::error!("Engine error in get_ai_suggestion: {}", e); HttpResponse::InternalServerError().json(json!({ @@ -62,14 +57,17 @@ pub async fn get_ai_suggestion(payload: Json) -> HttpRespon Err(errors) => { let error_strings: Vec = errors .field_errors() - .iter() - .flat_map(|(_, errs)| errs.iter().map(|err| err.message.clone().unwrap_or_default().to_string())) + .values() + .flat_map(|errs| { + errs.iter() + .map(|err| err.message.clone().unwrap_or_default().to_string()) + }) .collect(); - + HttpResponse::BadRequest().json(ValidationErrorResponse { error: "Invalid FEN position or parameters".to_string(), code: 400, - details: Some(error_strings) + details: Some(error_strings), }) } } @@ -94,8 +92,11 @@ pub async fn analyze_position(payload: Json) -> HttpRes Ok(_) => { let engine_path = env::var("ENGINE_PATH").unwrap_or_else(|_| "stockfish".to_string()); let engine_service = EngineService::new(engine_path); - - match engine_service.analyze_position(&payload.0.fen, payload.0.depth).await { + + match engine_service + .analyze_position(&payload.0.fen, payload.0.depth) + .await + { Ok(result) => { HttpResponse::Ok().json(PositionAnalysisResponse { evaluation: result.evaluation.unwrap_or(0.0), @@ -115,14 +116,17 @@ pub async fn analyze_position(payload: Json) -> HttpRes Err(errors) => { let error_strings: Vec = errors .field_errors() - .iter() - .flat_map(|(_, errs)| errs.iter().map(|err| err.message.clone().unwrap_or_default().to_string())) + .values() + .flat_map(|errs| { + errs.iter() + .map(|err| err.message.clone().unwrap_or_default().to_string()) + }) .collect(); - + HttpResponse::BadRequest().json(ValidationErrorResponse { error: "Invalid FEN position or parameters".to_string(), code: 400, - details: Some(error_strings) + details: Some(error_strings), }) } } diff --git a/backend/modules/api/src/auth.rs b/backend/modules/api/src/auth.rs index b09ccd0e..8290f229 100644 --- a/backend/modules/api/src/auth.rs +++ b/backend/modules/api/src/auth.rs @@ -1,12 +1,18 @@ -use actix_web::{web, HttpResponse, HttpRequest, post, cookie::{Cookie, time::Duration}}; -use validator::Validate; +use actix_web::{ + cookie::{time::Duration, Cookie}, + post, web, HttpRequest, HttpResponse, +}; use std::env; use uuid::Uuid; +use validator::Validate; -use dto::auth::{RegisterRequest, LoginRequest, AuthResponse, ErrorResponse, RefreshTokenRequest, RefreshResponse, LogoutResponse}; -use security::{JwtService, TokenService, TokenServiceError}; -use sea_orm::{DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter}; use db_entity::player; +use dto::auth::{ + AuthResponse, ErrorResponse, LoginRequest, LogoutResponse, RefreshResponse, + RefreshTokenRequest, RegisterRequest, +}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use security::{JwtService, TokenService, TokenServiceError}; use service::helper::password; /// Register a new user @@ -117,28 +123,30 @@ pub async fn login( .parse::() .unwrap_or(7); - let refresh_token = match TokenService::generate_refresh_token(db.get_ref(), user_id, family_id, refresh_ttl).await { - Ok(t) => t, - Err(e) => { - log::error!("Failed to generate refresh token: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - message: "Failed to generate refresh token".to_string(), - code: "TOKEN_ERROR".to_string(), - }); - } - }; + let refresh_token = + match TokenService::generate_refresh_token(db.get_ref(), user_id, family_id, refresh_ttl) + .await + { + Ok(t) => t, + Err(e) => { + log::error!("Failed to generate refresh token: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + message: "Failed to generate refresh token".to_string(), + code: "TOKEN_ERROR".to_string(), + }); + } + }; // Build response with cookie - let mut response = HttpResponse::Ok() - .json(AuthResponse { - access_token, - refresh_token: refresh_token.clone(), - token_type: "Bearer".to_string(), - expires_in: 3600, - refresh_token_expires_in: (refresh_ttl * 86400) as usize, - user_id, - username, - }); + let mut response = HttpResponse::Ok().json(AuthResponse { + access_token, + refresh_token: refresh_token.clone(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token_expires_in: (refresh_ttl * 86400) as usize, + user_id, + username, + }); // Set HTTP-only secure cookie let cookie = Cookie::build("refresh_token", refresh_token) @@ -202,13 +210,14 @@ pub async fn refresh( }; // Extract Bearer token - let token = if auth_header.starts_with("Bearer ") { - &auth_header[7..] - } else { - return HttpResponse::Unauthorized().json(ErrorResponse { - message: "Invalid authorization format".to_string(), - code: "INVALID_AUTH_FORMAT".to_string(), - }); + let token = match auth_header.strip_prefix("Bearer ") { + Some(t) => t, + None => { + return HttpResponse::Unauthorized().json(ErrorResponse { + message: "Invalid authorization format".to_string(), + code: "INVALID_AUTH_FORMAT".to_string(), + }); + } }; // Validate access token and get user info @@ -223,7 +232,13 @@ pub async fn refresh( }; // Verify refresh token and mark as used - let family_id = match TokenService::verify_and_mark_used(db.get_ref(), &refresh_token, claims.user_id).await { + let family_id = match TokenService::verify_and_mark_used( + db.get_ref(), + &refresh_token, + claims.user_id, + ) + .await + { Ok(fid) => fid, Err(TokenServiceError::TokenReuseDetected) => { log::warn!("Token reuse detected for player {}", claims.user_id); @@ -247,15 +262,16 @@ pub async fn refresh( }; // Generate new access token - let new_access_token = match jwt_service.generate_token(claims.user_id, &claims.username, claims.player_id) { - Ok(t) => t, - Err(_) => { - return HttpResponse::InternalServerError().json(ErrorResponse { - message: "Failed to generate new access token".to_string(), - code: "TOKEN_ERROR".to_string(), - }); - } - }; + let new_access_token = + match jwt_service.generate_token(claims.user_id, &claims.username, claims.player_id) { + Ok(t) => t, + Err(_) => { + return HttpResponse::InternalServerError().json(ErrorResponse { + message: "Failed to generate new access token".to_string(), + code: "TOKEN_ERROR".to_string(), + }); + } + }; // Generate new refresh token in same family let refresh_ttl = env::var("REFRESH_TOKEN_TTL_DAYS") @@ -263,7 +279,14 @@ pub async fn refresh( .parse::() .unwrap_or(7); - let new_refresh_token = match TokenService::generate_refresh_token(db.get_ref(), claims.user_id, family_id, refresh_ttl).await { + let new_refresh_token = match TokenService::generate_refresh_token( + db.get_ref(), + claims.user_id, + family_id, + refresh_ttl, + ) + .await + { Ok(t) => t, Err(e) => { log::error!("Failed to generate new refresh token: {}", e); @@ -275,13 +298,12 @@ pub async fn refresh( }; // Build response with new cookie - let mut response = HttpResponse::Ok() - .json(RefreshResponse { - access_token: new_access_token, - refresh_token: new_refresh_token.clone(), - token_type: "Bearer".to_string(), - expires_in: 3600, - }); + let mut response = HttpResponse::Ok().json(RefreshResponse { + access_token: new_access_token, + refresh_token: new_refresh_token.clone(), + token_type: "Bearer".to_string(), + expires_in: 3600, + }); // Set new HTTP-only cookie let cookie = Cookie::build("refresh_token", new_refresh_token) @@ -330,13 +352,14 @@ pub async fn logout( } }; - let token = if auth_header.starts_with("Bearer ") { - &auth_header[7..] - } else { - return HttpResponse::Unauthorized().json(ErrorResponse { - message: "Invalid authorization format".to_string(), - code: "INVALID_AUTH_FORMAT".to_string(), - }); + let token = match auth_header.strip_prefix("Bearer ") { + Some(t) => t, + None => { + return HttpResponse::Unauthorized().json(ErrorResponse { + message: "Invalid authorization format".to_string(), + code: "INVALID_AUTH_FORMAT".to_string(), + }); + } }; // Validate the token and extract the actual user ID @@ -362,10 +385,9 @@ pub async fn logout( } // Clear the refresh token cookie - let mut response = HttpResponse::Ok() - .json(LogoutResponse { - message: "Logged out successfully".to_string(), - }); + let mut response = HttpResponse::Ok().json(LogoutResponse { + message: "Logged out successfully".to_string(), + }); let cookie = Cookie::build("refresh_token", "") .http_only(true) diff --git a/backend/modules/api/src/games.rs b/backend/modules/api/src/games.rs index 868aeb76..db245d4a 100644 --- a/backend/modules/api/src/games.rs +++ b/backend/modules/api/src/games.rs @@ -1,22 +1,19 @@ use actix_web::{ - HttpResponse, HttpRequest, HttpMessage, delete, get, post, put, + delete, get, post, put, web::{self, Json, Path, Query}, + HttpMessage, HttpRequest, HttpResponse, }; -use dto::{ - games::{ - CreateGameRequest, GameDisplayDTO, MakeMoveRequest, JoinGameRequest, - GameStatus, ListGamesQuery, ImportGameRequest, ImportGameResponse, - CompleteGameRequest, CompleteGameResponse, - }, - responses::{InvalidCredentialsResponse, NotFoundResponse}, +use dto::games::{ + CompleteGameRequest, CompleteGameResponse, CreateGameRequest, GameStatus, ImportGameRequest, + ImportGameResponse, JoinGameRequest, ListGamesQuery, MakeMoveRequest, }; use error::error::ApiError; +use sea_orm::DatabaseConnection; use security::jwt::Claims; use serde_json::json; -use validator::Validate; -use uuid::Uuid; -use sea_orm::DatabaseConnection; use service::games::GameService; +use uuid::Uuid; +use validator::Validate; // --------------------------------------------------------------------------- // Helper: extract authenticated player UUID from JWT claims. @@ -93,10 +90,7 @@ pub async fn create_game( tag = "Games" )] #[get("/{id}")] -pub async fn get_game( - id: Path, - db: web::Data, -) -> HttpResponse { +pub async fn get_game(id: Path, db: web::Data) -> HttpResponse { let game_id = id.into_inner(); match GameService::get_game(db.get_ref(), game_id).await { @@ -199,25 +193,17 @@ pub async fn list_games( db: web::Data, ) -> HttpResponse { let status_enum: Option = query.status.as_deref().and_then(|s| match s { - "waiting" => Some(GameStatus::Waiting), + "waiting" => Some(GameStatus::Waiting), "in_progress" => Some(GameStatus::InProgress), - "completed" => Some(GameStatus::Completed), - "aborted" => Some(GameStatus::Aborted), - _ => None, + "completed" => Some(GameStatus::Completed), + "aborted" => Some(GameStatus::Aborted), + _ => None, }); - let limit = query.limit.unwrap_or(10); + let limit = query.limit.unwrap_or(10); let cursor = query.cursor.clone(); - match GameService::list_games( - db.get_ref(), - cursor, - limit, - query.player_id, - status_enum, - ) - .await - { + match GameService::list_games(db.get_ref(), cursor, limit, query.player_id, status_enum).await { Ok((games, next_cursor)) => { let game_dtos: Vec = games .into_iter() @@ -291,7 +277,7 @@ pub async fn join_game( Ok(id) => id, Err(resp) => return resp, }; - let game_id = id.into_inner(); + let game_id = id.into_inner(); match GameService::join_game(db.get_ref(), game_id, player_id).await { Ok(game_dto) => HttpResponse::Ok().json(json!({ @@ -397,14 +383,14 @@ pub async fn import_game( Ok(p) => p, Err(e) => { return HttpResponse::BadRequest().json(ImportGameResponse { - success: false, - game_id: None, + success: false, + game_id: None, white_player: String::new(), black_player: String::new(), - result: String::new(), - move_count: 0, - final_fen: None, - error: Some(e.to_string()), + result: String::new(), + move_count: 0, + final_fen: None, + error: Some(e.to_string()), }); } }; @@ -414,14 +400,14 @@ pub async fn import_game( Ok(v) => v, Err(e) => { return HttpResponse::UnprocessableEntity().json(ImportGameResponse { - success: false, - game_id: None, + success: false, + game_id: None, white_player: parsed.headers.white.clone(), black_player: parsed.headers.black.clone(), - result: String::new(), - move_count: 0, - final_fen: None, - error: Some(e.to_string()), + result: String::new(), + move_count: 0, + final_fen: None, + error: Some(e.to_string()), }); } }; @@ -431,26 +417,26 @@ pub async fn import_game( // Persist in DB with is_imported = true. match GameService::import_game(db.get_ref(), importer_id, &validated).await { Ok(game_id) => HttpResponse::Created().json(ImportGameResponse { - success: true, - game_id: Some(game_id), + success: true, + game_id: Some(game_id), white_player: validated.headers.white, black_player: validated.headers.black, - result: result_str, - move_count: validated.ply_count, - final_fen: Some(validated.final_fen), - error: None, + result: result_str, + move_count: validated.ply_count, + final_fen: Some(validated.final_fen), + error: None, }), Err(e) => { eprintln!("import_game DB error: {e}"); HttpResponse::InternalServerError().json(ImportGameResponse { - success: false, - game_id: None, + success: false, + game_id: None, white_player: validated.headers.white, black_player: validated.headers.black, - result: result_str, - move_count: validated.ply_count, - final_fen: Some(validated.final_fen), - error: Some("Failed to persist imported game".to_string()), + result: result_str, + move_count: validated.ply_count, + final_fen: Some(validated.final_fen), + error: Some("Failed to persist imported game".to_string()), }) } } @@ -513,10 +499,12 @@ pub async fn complete_game( // Get current ratings before update for calculating changes let white_old_rating = match service::games::GameService::get_player_rating_for_game( - db.get_ref(), - game_id, - true // white player - ).await { + db.get_ref(), + game_id, + true, // white player + ) + .await + { Ok(rating) => rating, Err(e) => { eprintln!("Failed to get white player rating: {e}"); @@ -527,10 +515,12 @@ pub async fn complete_game( }; let black_old_rating = match service::games::GameService::get_player_rating_for_game( - db.get_ref(), - game_id, - false // black player - ).await { + db.get_ref(), + game_id, + false, // black player + ) + .await + { Ok(rating) => rating, Err(e) => { eprintln!("Failed to get black player rating: {e}"); @@ -541,7 +531,14 @@ pub async fn complete_game( }; // Complete the game and update ratings - match GameService::complete_game(db.get_ref(), game_id, result_enum.clone(), Some(rating_config)).await { + match GameService::complete_game( + db.get_ref(), + game_id, + result_enum.clone(), + Some(rating_config), + ) + .await + { Ok((white_new_rating, black_new_rating)) => { let white_change = white_new_rating - white_old_rating; let black_change = black_new_rating - black_old_rating; @@ -591,4 +588,4 @@ pub async fn complete_game( }) } } -} \ No newline at end of file +} diff --git a/backend/modules/api/src/lib.rs b/backend/modules/api/src/lib.rs index d898451e..cd093c52 100644 --- a/backend/modules/api/src/lib.rs +++ b/backend/modules/api/src/lib.rs @@ -1,16 +1,16 @@ -pub mod auth; pub mod ai; -pub mod openapi; -pub mod ws; -mod test; +pub mod auth; pub mod config; -pub mod server; -pub mod players; pub mod games; +pub mod openapi; +pub mod players; +pub mod server; +mod test; +pub mod ws; // External modules extern crate challenge; // Re-export server module for external use +pub use auth::{login, logout, refresh, register}; pub use server::main; -pub use auth::{login, register, refresh, logout}; \ No newline at end of file diff --git a/backend/modules/api/src/openapi.rs b/backend/modules/api/src/openapi.rs index 8e3c899d..c1c3550b 100644 --- a/backend/modules/api/src/openapi.rs +++ b/backend/modules/api/src/openapi.rs @@ -1,9 +1,7 @@ -use utoipa::OpenApi; -use crate::{players, games, auth, ai}; -use st_core::endpoint::{mint_nft, format_ai_metadata, generate_stellar_toml}; -use st_core::models::{AIMetadata, NFTMintRequest, NFTMintResponse}; -use utoipa::openapi::security::{SecurityScheme, HttpAuthScheme, HttpBuilder}; +use crate::{ai, auth, games, players}; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::Modify; +use utoipa::OpenApi; // Security scheme definition for JWT authentication pub struct SecurityAddon; @@ -33,7 +31,7 @@ impl Modify for SecurityAddon { players::find_player_by_id, players::update_player, players::delete_player, - + // Game endpoints games::create_game, games::get_game, @@ -41,15 +39,15 @@ impl Modify for SecurityAddon { games::list_games, games::join_game, games::abandon_game, - + // Authentication endpoints auth::login, auth::register, - + // AI suggestion endpoints ai::get_ai_suggestion, ai::analyze_position, - + // NFT endpoints st_core::endpoint::mint_nft, st_core::endpoint::format_ai_metadata, @@ -62,7 +60,7 @@ impl Modify for SecurityAddon { dto::players::UpdatePlayer, dto::players::DisplayPlayer, dto::players::UpdatedPlayer, - + // Game schemas dto::games::CreateGameRequest, dto::games::GameDisplayDTO, @@ -71,23 +69,23 @@ impl Modify for SecurityAddon { dto::games::GameStatus, dto::games::GameResult, dto::games::ListGamesQuery, - + // Auth schemas dto::auth::LoginRequest, dto::auth::LoginResponse, dto::auth::RegisterRequest, dto::auth::TokenResponse, dto::auth::UserInfo, - + // AI schemas dto::ai::AiSuggestionRequest, dto::ai::AiSuggestionResponse, dto::ai::PositionAnalysisRequest, dto::ai::PositionAnalysisResponse, dto::ai::AlternativeMove, - + // NFT schemas (st_core models excluded to avoid utoipa version mismatch) - + // Response schemas dto::responses::PlayerAdded, dto::responses::PlayerFound, diff --git a/backend/modules/api/src/players.rs b/backend/modules/api/src/players.rs index 0505e65d..b9e36421 100644 --- a/backend/modules/api/src/players.rs +++ b/backend/modules/api/src/players.rs @@ -1,14 +1,9 @@ use actix_web::{ - HttpResponse, delete, get, post, put, + delete, get, post, put, web::{Json, Path}, + HttpResponse, }; -use dto::{ - players::{DisplayPlayer, NewPlayer, UpdatePlayer, UpdatedPlayer}, - responses::{ - InvalidCredentialsResponse, NotFoundResponse, PlayerAdded, PlayerDeleted, PlayerFound, - PlayerUpdated, - }, -}; +use dto::players::{DisplayPlayer, NewPlayer, UpdatePlayer, UpdatedPlayer}; use error::error::ApiError; use serde_json::json; use validator::Validate; diff --git a/backend/modules/api/src/server.rs b/backend/modules/api/src/server.rs index eee62b69..e7cb0f52 100644 --- a/backend/modules/api/src/server.rs +++ b/backend/modules/api/src/server.rs @@ -1,29 +1,32 @@ // src/server.rs -use actix_web::{web, App, HttpResponse, HttpServer, Responder}; +use crate::ai::{analyze_position, get_ai_suggestion}; +use crate::auth::{login, logout, refresh, register}; +use crate::config::AppConfig; +use crate::games::{ + abandon_game, complete_game, create_game, get_game, import_game, join_game, list_games, + make_move, +}; +use crate::players::{add_player, delete_player, find_player_by_id, update_player}; +use crate::ws::{ws_route, LobbyState}; +use actix::Actor; use actix_cors::Cors; +use actix_governor::{Governor, GovernorConfigBuilder}; +use actix_web::{web, App, HttpResponse, HttpServer, Responder}; +use challenge::api::configure_puzzle_routes; +use challenge::puzzle_validation::PuzzleValidationService; use dotenv::dotenv; -use sea_orm::{Database, DatabaseConnection}; +use matchmaking::redis::{create_redis_pool, test_redis_connection}; +use matchmaking::service::MatchmakingService; +use sea_orm::Database; +use security::JwtAuthMiddleware; +use security::JwtService; +use st_core::endpoint::configure as configure_nft_routes; use std::env; use std::sync::Arc; -use security::JwtService; -use security::JwtAuthMiddleware; use utoipa::OpenApi; -use utoipa_swagger_ui::SwaggerUi; use utoipa_redoc::{Redoc, Servable}; -use actix::Actor; -use crate::players::{add_player, delete_player, find_player_by_id, update_player}; -use crate::games::{create_game, get_game, make_move, list_games, join_game, abandon_game, import_game, complete_game}; -use crate::auth::{login, register, refresh, logout}; -use crate::ai::{get_ai_suggestion, analyze_position}; -use crate::ws::{LobbyState, ws_route}; -use crate::config::AppConfig; -use actix_governor::{Governor, GovernorConfigBuilder}; -use matchmaking::service::MatchmakingService; -use matchmaking::redis::{create_redis_pool, test_redis_connection}; -use challenge::puzzle_validation::PuzzleValidationService; -use challenge::api::configure_puzzle_routes; -use st_core::endpoint::configure as configure_nft_routes; +use utoipa_swagger_ui::SwaggerUi; use crate::openapi::ApiDoc; @@ -70,10 +73,7 @@ pub async fn main() -> std::io::Result<()> { } Err(e) => { eprintln!("Failed to connect to database: {}", e); - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - "Database connection failed", - )); + return Err(std::io::Error::other("Database connection failed")); } }; @@ -90,12 +90,12 @@ pub async fn main() -> std::io::Result<()> { // Initialize Matchmaking eprintln!("Connecting to Redis for matchmaking at {}", redis_url); let redis_pool = create_redis_pool(&redis_url).expect("Failed to create Redis pool"); - + // Optional: test connection if let Err(e) = test_redis_connection(&redis_pool).await { eprintln!("Warning: Redis connection test failed: {}", e); } - + let matchmaking_service = MatchmakingService::new(redis_pool); // Initialize Puzzle Validation Service @@ -110,14 +110,14 @@ pub async fn main() -> std::io::Result<()> { let jwt_secret = jwt_secret.clone(); let matchmaking_service = matchmaking_service.clone(); let puzzle_service = puzzle_service.clone(); - + // Configure CORS middleware with environment variables for flexibility let cors = { let mut cors = Cors::default() .allow_any_method() .allow_any_header() .max_age(3600); - + // Get allowed origins from environment variable, fallback to all origins in development if let Ok(allowed_origins) = env::var("ALLOWED_ORIGINS") { // Parse comma-separated list of allowed origins @@ -130,10 +130,10 @@ pub async fn main() -> std::io::Result<()> { // In development, allow all origins by default cors = cors.allow_any_origin(); } - + cors }; - + // Configure Governor for Auth (Strict) let auth_governor_conf = GovernorConfigBuilder::default() .per_second(config.auth_rate_limit_per_sec) @@ -151,7 +151,10 @@ pub async fn main() -> std::io::Result<()> { .unwrap(); App::new() - .wrap(actix_web::middleware::DefaultHeaders::new().add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"))) + .wrap(actix_web::middleware::DefaultHeaders::new().add(( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ))) // Global middleware .wrap(cors) // App data @@ -195,18 +198,12 @@ pub async fn main() -> std::io::Result<()> { .service(login) .service(register) .service(refresh) - .service(logout) + .service(logout), ) // WebSocket routes - .service( - web::scope("/v1/ws") - .route("/game/{game_id}", web::get().to(ws_route)) - ) + .service(web::scope("/v1/ws").route("/game/{game_id}", web::get().to(ws_route))) // Matchmaking routes - .service( - web::scope("/v1") - .configure(matchmaking::routes::config) - ) + .service(web::scope("/v1").configure(matchmaking::routes::config)) // AI routes .service( web::scope("/v1/ai") @@ -215,26 +212,24 @@ pub async fn main() -> std::io::Result<()> { .service(analyze_position), ) // NFT routes - .service( - web::scope("/api/v1") - .configure(configure_nft_routes) - ) + .service(web::scope("/api/v1").configure(configure_nft_routes)) // Swagger UI integration .service( SwaggerUi::new("/api/docs/{_:.*}") .url("/api/docs/openapi.json", openapi.clone()) - .config(utoipa_swagger_ui::Config::default().try_it_out_enabled(true)) + .config(utoipa_swagger_ui::Config::default().try_it_out_enabled(true)), ) // ReDoc integration (alternative documentation UI) - .service( - Redoc::with_url("/api/redoc", openapi.clone()) - ) + .service(Redoc::with_url("/api/redoc", openapi.clone())) // WebSocket documentation as static HTML - .route("/api/docs/websocket", web::get().to(|| async { - HttpResponse::Ok() - .content_type("text/markdown") - .body(crate::openapi::websocket_documentation()) - })) + .route( + "/api/docs/websocket", + web::get().to(|| async { + HttpResponse::Ok() + .content_type("text/markdown") + .body(crate::openapi::websocket_documentation()) + }), + ) }; let mut server = HttpServer::new(app_factory).bind(&server_addr)?; diff --git a/backend/modules/api/src/test/mod.rs b/backend/modules/api/src/test/mod.rs index 8877b7e2..4e9e1a44 100644 --- a/backend/modules/api/src/test/mod.rs +++ b/backend/modules/api/src/test/mod.rs @@ -3,14 +3,14 @@ mod rate_limit; #[cfg(test)] mod tests { - use actix_web::{App, dev::Service, http::StatusCode, test, web}; + use actix_web::{dev::Service, http::StatusCode, test, web, App}; use dto::players::{InvalidPlayer, NewPlayer}; use crate::players::add_player; #[actix_web::test] async fn test_index_post_no_body() { - std::env::set_var("TEST_NO_DB", "1"); + std::env::set_var("TEST_NO_DB", "1"); let app = test::init_service(App::new().service(web::scope("/v1/players").service(add_player))) .await; @@ -21,7 +21,7 @@ mod tests { #[actix_web::test] async fn test_index_post_with_body() { - std::env::set_var("TEST_NO_DB", "1"); + std::env::set_var("TEST_NO_DB", "1"); let app = test::init_service(App::new().service(web::scope("/v1/players").service(add_player))) .await; @@ -63,7 +63,7 @@ mod tests { #[actix_web::test] async fn test_index_post_with_invalid_username() { - std::env::set_var("TEST_NO_DB", "1"); + std::env::set_var("TEST_NO_DB", "1"); let app = test::init_service(App::new().service(web::scope("/v1/players").service(add_player))) .await; @@ -94,7 +94,7 @@ mod tests { #[actix_web::test] async fn test_index_post_with_invalid_email() { - std::env::set_var("TEST_NO_DB", "1"); + std::env::set_var("TEST_NO_DB", "1"); let app = test::init_service(App::new().service(web::scope("/v1/players").service(add_player))) .await; @@ -126,7 +126,7 @@ mod tests { #[actix_web::test] async fn test_index_post_with_invalid_password() { - std::env::set_var("TEST_NO_DB", "1"); + std::env::set_var("TEST_NO_DB", "1"); let app = test::init_service(App::new().service(web::scope("/v1/players").service(add_player))) .await; diff --git a/backend/modules/api/src/test/rate_limit.rs b/backend/modules/api/src/test/rate_limit.rs index a5152979..b60fa769 100644 --- a/backend/modules/api/src/test/rate_limit.rs +++ b/backend/modules/api/src/test/rate_limit.rs @@ -1,7 +1,5 @@ use actix_governor::{Governor, GovernorConfigBuilder}; use actix_web::{test, web, App, HttpResponse, Responder}; -use std::time::Duration; -use std::thread; async fn mock_handler() -> impl Responder { HttpResponse::Ok().body("OK") @@ -19,13 +17,13 @@ async fn test_auth_rate_limiting() { .unwrap(); let app = test::init_service( - App::new() - .service( - web::scope("/v1/auth") - .wrap(Governor::new(&auth_governor_conf)) - .route("/login", web::post().to(mock_handler)) - ) - ).await; + App::new().service( + web::scope("/v1/auth") + .wrap(Governor::new(&auth_governor_conf)) + .route("/login", web::post().to(mock_handler)), + ), + ) + .await; // Request 1: Should pass let req = test::TestRequest::post() @@ -64,13 +62,13 @@ async fn test_game_rate_limiting() { .unwrap(); let app = test::init_service( - App::new() - .service( - web::scope("/v1/games") - .wrap(Governor::new(&game_governor_conf)) - .route("/create", web::post().to(mock_handler)) - ) - ).await; + App::new().service( + web::scope("/v1/games") + .wrap(Governor::new(&game_governor_conf)) + .route("/create", web::post().to(mock_handler)), + ), + ) + .await; // Send 3 requests, all should pass for _ in 0..3 { diff --git a/backend/modules/api/src/ws.rs b/backend/modules/api/src/ws.rs index 6467165f..687fa5dd 100644 --- a/backend/modules/api/src/ws.rs +++ b/backend/modules/api/src/ws.rs @@ -1,13 +1,13 @@ use actix::prelude::*; -use actix_web::{HttpRequest, HttpResponse, Error, web}; +use actix_web::error::ErrorUnauthorized; +use actix_web::{web, Error, HttpRequest, HttpResponse}; use actix_web_actors::ws; -use serde::{Serialize, Deserialize}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use security::jwt::{Claims, TokenType}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::env; -use security::jwt::{Claims, TokenType}; -use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; -use actix_web::error::ErrorUnauthorized; -use serde_json::{Value, json}; use uuid::Uuid; // For Redis Pub/Sub @@ -19,11 +19,28 @@ use tokio::task::JoinHandle; #[rtype(result = "()")] #[serde(tag = "type", content = "payload")] pub enum WsMessage { - Move { from: String, to: String, san: String, fen: String }, - Clock { white: u32, black: u32 }, - End { result: String, final_fen: String }, - Error { code: u16, message: String }, - ReconnectToken { token: String, expires_in: u32 }, + Move { + from: String, + to: String, + san: String, + fen: String, + }, + Clock { + white: u32, + black: u32, + }, + End { + result: String, + final_fen: String, + }, + Error { + code: u16, + message: String, + }, + ReconnectToken { + token: String, + expires_in: u32, + }, } /// Actor messages @@ -53,9 +70,17 @@ pub struct LobbyState { sessions: HashMap>>, } +impl Default for LobbyState { + fn default() -> Self { + Self::new() + } +} + impl LobbyState { pub fn new() -> Self { - LobbyState { sessions: HashMap::new() } + LobbyState { + sessions: HashMap::new(), + } } } @@ -92,7 +117,7 @@ impl Handler for LobbyState { if let Some(set) = self.sessions.get(&msg.game_id) { for recipient in set.iter() { // backpressure: drop if send fails - let _ = recipient.do_send(msg.message.clone()); + recipient.do_send(msg.message.clone()); } } } @@ -107,7 +132,7 @@ pub struct WsSession { pub player_id: Uuid, pub username: String, pub session_id: String, - pub redis_sub_task: Option>, // Placeholder for compatibility + pub redis_sub_task: Option>, // Placeholder for compatibility } impl WsSession { @@ -118,9 +143,15 @@ impl WsSession { /// Generate a reconnection token for this session fn generate_reconnect_token(&self) -> Result { - let secret = env::var("JWT_SECRET_KEY").unwrap_or_else(|_| "development_secret_key".to_string()); + let secret = + env::var("JWT_SECRET_KEY").unwrap_or_else(|_| "development_secret_key".to_string()); let jwt_service = security::jwt::JwtService::new(secret, 3600); - jwt_service.generate_reconnect_token(self.user_id, &self.username, self.player_id, &self.session_id) + jwt_service.generate_reconnect_token( + self.user_id, + &self.username, + self.player_id, + &self.session_id, + ) } fn hb(&self, ctx: &mut ws::WebsocketContext) { @@ -146,7 +177,10 @@ impl Actor for WsSession { fn started(&mut self, ctx: &mut Self::Context) { self.hb(ctx); let addr = ctx.address().recipient(); - self.lobby.do_send(Connect { game_id: self.game_id.clone(), addr }); + self.lobby.do_send(Connect { + game_id: self.game_id.clone(), + addr, + }); // Redis pub/sub subscription intentionally disabled here; leave placeholder self.redis_sub_task = None; @@ -154,23 +188,29 @@ impl Actor for WsSession { fn stopped(&mut self, ctx: &mut Self::Context) { log::info!("WebSocket disconnected for game: {}", self.game_id); - + // Send reconnection token to client for seamless reconnection if let Ok(reconnect_token) = self.generate_reconnect_token() { - let reconnect_msg = WsMessage::ReconnectToken { - token: reconnect_token, - expires_in: 30 + let reconnect_msg = WsMessage::ReconnectToken { + token: reconnect_token, + expires_in: 30, }; - + // Try to send the reconnection token - let _ = ctx.address().do_send(reconnect_msg); + ctx.address().do_send(reconnect_msg); log::info!("Sent reconnection token for user: {}", self.username); } else { - log::error!("Failed to generate reconnection token for user: {}", self.username); + log::error!( + "Failed to generate reconnection token for user: {}", + self.username + ); } - + let addr = ctx.address().recipient(); - self.lobby.do_send(Disconnect { game_id: self.game_id.clone(), addr }); + self.lobby.do_send(Disconnect { + game_id: self.game_id.clone(), + addr, + }); // Cancel Redis subscription task if running if let Some(handle) = self.redis_sub_task.take() { handle.abort(); @@ -190,7 +230,7 @@ impl StreamHandler> for WsSession { } Ok(ws::Message::Text(text)) => { // Try to parse as WsMessage - if let Ok(ws_msg) = serde_json::from_str::(&text) { + if let Ok(_ws_msg) = serde_json::from_str::(&text) { // Redis publishing disabled in tests/CI environment } } @@ -224,9 +264,12 @@ pub async fn ws_route( stream: web::Payload, lobby: web::Data>, ) -> Result { - let auth_header = req.headers().get("Authorization").and_then(|h| h.to_str().ok()); + let auth_header = req + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()); let mut reconnect_token: Option = None; - + // Parse query string manually let query_string = req.query_string(); if !query_string.is_empty() { @@ -239,7 +282,7 @@ pub async fn ws_route( } } } - + let claims = if let Some(ref reconnect_token_str) = reconnect_token { // Validate reconnection token validate_reconnect_token(reconnect_token_str)? @@ -258,11 +301,11 @@ pub async fn ws_route( let game_id = req.match_info().get("game_id").unwrap_or("").to_string(); let session_id = Uuid::new_v4().to_string(); - + ws::start( - WsSession { - game_id, - lobby: lobby.get_ref().clone(), + WsSession { + game_id, + lobby: lobby.get_ref().clone(), hb: std::time::Instant::now(), user_id: claims.user_id, player_id: claims.player_id, @@ -277,36 +320,46 @@ pub async fn ws_route( /// Validate access token fn validate_access_token(token: &str) -> Result { - let secret = env::var("JWT_SECRET_KEY").unwrap_or_else(|_| "development_secret_key".to_string()); + let secret = + env::var("JWT_SECRET_KEY").unwrap_or_else(|_| "development_secret_key".to_string()); let validation = Validation::new(Algorithm::HS256); - let token_data = decode::(token, &DecodingKey::from_secret(secret.as_bytes()), &validation) - .map_err(|_| ErrorUnauthorized("Invalid or expired token"))?; - + let token_data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .map_err(|_| ErrorUnauthorized("Invalid or expired token"))?; + // Ensure it's an access token if token_data.claims.token_type != TokenType::Access { return Err(ErrorUnauthorized("Invalid token type")); } - + Ok(token_data.claims) } /// Validate reconnection token fn validate_reconnect_token(token: &str) -> Result { - let secret = env::var("JWT_SECRET_KEY").unwrap_or_else(|_| "development_secret_key".to_string()); + let secret = + env::var("JWT_SECRET_KEY").unwrap_or_else(|_| "development_secret_key".to_string()); let validation = Validation::new(Algorithm::HS256); - let token_data = decode::(token, &DecodingKey::from_secret(secret.as_bytes()), &validation) - .map_err(|_| ErrorUnauthorized("Invalid or expired reconnection token"))?; - + let token_data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .map_err(|_| ErrorUnauthorized("Invalid or expired reconnection token"))?; + // Ensure it's a reconnection token if token_data.claims.token_type != TokenType::Reconnect { return Err(ErrorUnauthorized("Invalid token type")); } - + // Check if reconnection token has JTI (session identifier) if token_data.claims.jti.is_none() { return Err(ErrorUnauthorized("Invalid reconnection token format")); } - + Ok(token_data.claims) } @@ -314,7 +367,6 @@ fn validate_reconnect_token(token: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use actix::prelude::*; use tokio::sync::mpsc::unbounded_channel; struct TestRecipient { @@ -341,10 +393,31 @@ mod tests { let recipient1 = TestRecipient { tx: tx1 }.start().recipient(); let recipient2 = TestRecipient { tx: tx2 }.start().recipient(); let game_id = "game123".to_string(); - lobby.send(Connect { game_id: game_id.clone(), addr: recipient1.clone() }).await.unwrap(); - lobby.send(Connect { game_id: game_id.clone(), addr: recipient2.clone() }).await.unwrap(); - let msg = WsMessage::Clock { white: 60, black: 60 }; - lobby.send(Broadcast { game_id: game_id.clone(), message: msg.clone() }).await.unwrap(); + lobby + .send(Connect { + game_id: game_id.clone(), + addr: recipient1.clone(), + }) + .await + .unwrap(); + lobby + .send(Connect { + game_id: game_id.clone(), + addr: recipient2.clone(), + }) + .await + .unwrap(); + let msg = WsMessage::Clock { + white: 60, + black: 60, + }; + lobby + .send(Broadcast { + game_id: game_id.clone(), + message: msg.clone(), + }) + .await + .unwrap(); let received1 = rx1.recv().await.unwrap(); let received2 = rx2.recv().await.unwrap(); assert_eq!(received1, msg); diff --git a/backend/modules/challenge/src/api.rs b/backend/modules/challenge/src/api.rs index 068dcb89..b3d8677d 100644 --- a/backend/modules/challenge/src/api.rs +++ b/backend/modules/challenge/src/api.rs @@ -1,12 +1,12 @@ -use actix_web::{web, HttpResponse, Result, Error, HttpMessage}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::puzzle_validation::{ - PuzzleValidationService, PuzzleSubmission, PuzzleValidationResult, - PuzzleRewardToken, Puzzle, ChessMove + ChessMove, Puzzle, PuzzleRewardToken, PuzzleSubmission, PuzzleValidationResult, + PuzzleValidationService, }; +use actix_web::{web, Error, HttpMessage, HttpResponse, Result}; use security::jwt::Claims; +use serde::{Deserialize, Serialize}; use std::sync::Arc; +use uuid::Uuid; /// API request/response types #[derive(Debug, Deserialize)] @@ -45,10 +45,12 @@ pub async fn submit_solution( solution_request: web::Json, ) -> Result { // Extract user info from JWT claims - let claims = req.extensions().get::() - .ok_or_else(|| Error::from(actix_web::error::ErrorUnauthorized("User not authenticated")))? + let claims = req + .extensions() + .get::() + .ok_or_else(|| actix_web::error::ErrorUnauthorized("User not authenticated"))? .clone(); - + let user_id = claims.user_id; let username = claims.username; @@ -62,24 +64,20 @@ pub async fn submit_solution( // Validate the solution match puzzle_service.validate_puzzle_solution(submission) { - Ok(result) => { - Ok(HttpResponse::Ok().json(SubmitSolutionResponse { - success: true, - result, - })) - } - Err(e) => { - Ok(HttpResponse::BadRequest().json(SubmitSolutionResponse { + Ok(result) => Ok(HttpResponse::Ok().json(SubmitSolutionResponse { + success: true, + result, + })), + Err(e) => Ok(HttpResponse::BadRequest().json(SubmitSolutionResponse { + success: false, + result: PuzzleValidationResult { success: false, - result: PuzzleValidationResult { - success: false, - correct: false, - message: format!("Validation error: {}", e), - reward_token: None, - reward_amount: None, - }, - })) - } + correct: false, + message: format!("Validation error: {}", e), + reward_token: None, + reward_amount: None, + }, + })), } } @@ -88,7 +86,7 @@ pub async fn get_puzzles( puzzle_service: web::Data>, ) -> Result { let puzzles = puzzle_service.get_puzzles(); - + Ok(HttpResponse::Ok().json(PuzzleListResponse { puzzles: puzzles.clone(), })) @@ -100,16 +98,12 @@ pub async fn get_puzzle_by_id( path: web::Path, ) -> Result { let puzzle_id = path.into_inner(); - + match puzzle_service.get_puzzle_by_id(&puzzle_id) { - Ok(puzzle) => { - Ok(HttpResponse::Ok().json(puzzle)) - } - Err(e) => { - Ok(HttpResponse::NotFound().json(serde_json::json!({ - "error": format!("Puzzle not found: {}", e) - }))) - } + Ok(puzzle) => Ok(HttpResponse::Ok().json(puzzle)), + Err(e) => Ok(HttpResponse::NotFound().json(serde_json::json!({ + "error": format!("Puzzle not found: {}", e) + }))), } } @@ -119,20 +113,16 @@ pub async fn verify_reward_token( token_request: web::Json, ) -> Result { match puzzle_service.verify_reward_token(&token_request.token) { - Ok(reward_token) => { - Ok(HttpResponse::Ok().json(VerifyTokenResponse { - success: true, - reward_token: Some(reward_token), - error: None, - })) - } - Err(e) => { - Ok(HttpResponse::BadRequest().json(VerifyTokenResponse { - success: false, - reward_token: None, - error: Some(format!("Token verification failed: {}", e)), - })) - } + Ok(reward_token) => Ok(HttpResponse::Ok().json(VerifyTokenResponse { + success: true, + reward_token: Some(reward_token), + error: None, + })), + Err(e) => Ok(HttpResponse::BadRequest().json(VerifyTokenResponse { + success: false, + reward_token: None, + error: Some(format!("Token verification failed: {}", e)), + })), } } @@ -143,6 +133,6 @@ pub fn configure_puzzle_routes(cfg: &mut web::ServiceConfig) { .route("", web::get().to(get_puzzles)) .route("/{puzzle_id}", web::get().to(get_puzzle_by_id)) .route("/submit", web::post().to(submit_solution)) - .route("/verify-token", web::post().to(verify_reward_token)) + .route("/verify-token", web::post().to(verify_reward_token)), ); } diff --git a/backend/modules/challenge/src/puzzle_validation.rs b/backend/modules/challenge/src/puzzle_validation.rs index c706639a..2218dc0b 100644 --- a/backend/modules/challenge/src/puzzle_validation.rs +++ b/backend/modules/challenge/src/puzzle_validation.rs @@ -1,9 +1,9 @@ +use chrono::{DateTime, Utc}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use security::jwt::JwtService; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use jsonwebtoken::{encode, Header, EncodingKey}; -use security::jwt::JwtService; /// Error types for puzzle validation #[derive(Error, Debug)] @@ -95,7 +95,7 @@ impl PuzzleValidationService { pub fn new(jwt_secret: String) -> Self { let jwt_service = JwtService::new(jwt_secret, 3600); let puzzles = Self::create_default_puzzles(); - + Self { jwt_service, puzzles, @@ -107,39 +107,38 @@ impl PuzzleValidationService { vec![ Puzzle { id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(), - fen: "r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 4".to_string(), + fen: "r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 4" + .to_string(), title: "Fork Attack".to_string(), difficulty: PuzzleDifficulty::Easy, description: "Find the knight fork that wins material".to_string(), - solution: vec![ - ChessMove { - from: "f3".to_string(), - to: "g5".to_string(), - promotion: None, - } - ], + solution: vec![ChessMove { + from: "f3".to_string(), + to: "g5".to_string(), + promotion: None, + }], hint: Some("Look for a knight move that attacks two pieces".to_string()), created_at: Utc::now(), }, Puzzle { id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap(), - fen: "rnbqkb1r/pppp1ppp/5n2/2B1p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 4".to_string(), + fen: "rnbqkb1r/pppp1ppp/5n2/2B1p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 4" + .to_string(), title: "Pin and Win".to_string(), difficulty: PuzzleDifficulty::Medium, description: "Use a pin to create a winning advantage".to_string(), - solution: vec![ - ChessMove { - from: "c4".to_string(), - to: "f7".to_string(), - promotion: None, - } - ], + solution: vec![ChessMove { + from: "c4".to_string(), + to: "f7".to_string(), + promotion: None, + }], hint: Some("The bishop can pin the knight to the king".to_string()), created_at: Utc::now(), }, Puzzle { id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440003").unwrap(), - fen: "r1bqk2r/pppp1ppp/2n2n2/2B1p3/4P3/3N1N2/PPPP1PPP/R1BQK2R w KQkq - 0 6".to_string(), + fen: "r1bqk2r/pppp1ppp/2n2n2/2B1p3/4P3/3N1N2/PPPP1PPP/R1BQK2R w KQkq - 0 6" + .to_string(), title: "Discovered Attack".to_string(), difficulty: PuzzleDifficulty::Hard, description: "Execute a discovered attack for checkmate".to_string(), @@ -153,24 +152,23 @@ impl PuzzleValidationService { from: "c4".to_string(), to: "f7".to_string(), promotion: None, - } + }, ], hint: Some("Move the knight first to reveal the bishop's attack".to_string()), created_at: Utc::now(), }, Puzzle { id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440004").unwrap(), - fen: "rnbqkbnr/pp1ppppp/2p5/3p4/3PP3/2N5/PP1PPPPP/R1BQKBNR w KQkq - 0 3".to_string(), + fen: "rnbqkbnr/pp1ppppp/2p5/3p4/3PP3/2N5/PP1PPPPP/R1BQKBNR w KQkq - 0 3" + .to_string(), title: "Center Control".to_string(), difficulty: PuzzleDifficulty::Easy, description: "Control the center with your knight".to_string(), - solution: vec![ - ChessMove { - from: "c3".to_string(), - to: "d5".to_string(), - promotion: None, - } - ], + solution: vec![ChessMove { + from: "c3".to_string(), + to: "d5".to_string(), + promotion: None, + }], hint: Some("Knights are excellent in the center".to_string()), created_at: Utc::now(), }, @@ -180,13 +178,11 @@ impl PuzzleValidationService { title: "Double Attack".to_string(), difficulty: PuzzleDifficulty::Medium, description: "Create a double attack with your bishop".to_string(), - solution: vec![ - ChessMove { - from: "c4".to_string(), - to: "e6".to_string(), - promotion: None, - } - ], + solution: vec![ChessMove { + from: "c4".to_string(), + to: "e6".to_string(), + promotion: None, + }], hint: Some("Look for squares that attack multiple pieces".to_string()), created_at: Utc::now(), }, @@ -215,12 +211,12 @@ impl PuzzleValidationService { let puzzle = self.get_puzzle_by_id(&submission.puzzle_id)?; // Validate the solution - let is_correct = self.validate_solution_moves(&puzzle, &submission.moves)?; + let is_correct = self.validate_solution_moves(puzzle, &submission.moves)?; if is_correct { // Generate reward token - let reward_token = self.generate_reward_token(&puzzle, &submission)?; - + let reward_token = self.generate_reward_token(puzzle, &submission)?; + Ok(PuzzleValidationResult { success: true, correct: true, @@ -263,9 +259,7 @@ impl PuzzleValidationService { /// Check if two moves are equivalent (simple comparison) fn moves_equivalent_simple(&self, move1: &ChessMove, move2: &ChessMove) -> bool { - move1.from == move2.from && - move1.to == move2.to && - move1.promotion == move2.promotion + move1.from == move2.from && move1.to == move2.to && move1.promotion == move2.promotion } /// Generate reward token for completed puzzle @@ -290,7 +284,8 @@ impl PuzzleValidationService { &Header::default(), &token_claims, &EncodingKey::from_secret(self.get_jwt_secret().as_ref()), - ).map_err(|_| PuzzleValidationError::TokenGenerationFailed)?; + ) + .map_err(|_| PuzzleValidationError::TokenGenerationFailed)?; Ok(token) } @@ -301,15 +296,21 @@ impl PuzzleValidationService { } /// Verify reward token - pub fn verify_reward_token(&self, token: &str) -> Result { + pub fn verify_reward_token( + &self, + token: &str, + ) -> Result { let token_data = jsonwebtoken::decode::( token, &jsonwebtoken::DecodingKey::from_secret(self.get_jwt_secret().as_ref()), &jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256), - ).map_err(|_| PuzzleValidationError::InvalidFormat("Invalid token".to_string()))?; + ) + .map_err(|_| PuzzleValidationError::InvalidFormat("Invalid token".to_string()))?; - let reward_token: PuzzleRewardToken = serde_json::from_value(token_data.claims) - .map_err(|_| PuzzleValidationError::InvalidFormat("Invalid token format".to_string()))?; + let reward_token: PuzzleRewardToken = + serde_json::from_value(token_data.claims).map_err(|_| { + PuzzleValidationError::InvalidFormat("Invalid token format".to_string()) + })?; Ok(reward_token) } @@ -341,16 +342,14 @@ mod tests { fn test_validate_correct_solution() { let service = create_test_service(); let puzzle_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(); - + let submission = PuzzleSubmission { puzzle_id, - moves: vec![ - ChessMove { - from: "f3".to_string(), - to: "g5".to_string(), - promotion: None, - } - ], + moves: vec![ChessMove { + from: "f3".to_string(), + to: "g5".to_string(), + promotion: None, + }], user_id: 1, username: "testuser".to_string(), }; @@ -365,16 +364,14 @@ mod tests { fn test_validate_incorrect_solution() { let service = create_test_service(); let puzzle_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(); - + let submission = PuzzleSubmission { puzzle_id, - moves: vec![ - ChessMove { - from: "f3".to_string(), - to: "f4".to_string(), - promotion: None, - } - ], + moves: vec![ChessMove { + from: "f3".to_string(), + to: "f4".to_string(), + promotion: None, + }], user_id: 1, username: "testuser".to_string(), }; diff --git a/backend/modules/chess/src/bitboard/bitboard.rs b/backend/modules/chess/src/bitboard/bitboard.rs index 16321ed6..6fe201ee 100644 --- a/backend/modules/chess/src/bitboard/bitboard.rs +++ b/backend/modules/chess/src/bitboard/bitboard.rs @@ -26,6 +26,7 @@ impl Bitboard { (self.0 & (1 << square)) != 0 } + #[allow(clippy::should_implement_trait)] pub fn add(self, square: u64) -> Bitboard { Bitboard(self.0 | (1 << square)) } diff --git a/backend/modules/chess/src/bitboard/board.rs b/backend/modules/chess/src/bitboard/board.rs index 4c38bbba..b18796d2 100644 --- a/backend/modules/chess/src/bitboard/board.rs +++ b/backend/modules/chess/src/bitboard/board.rs @@ -1,8 +1,6 @@ - use std::collections::HashMap; use std::ops::{BitAnd, BitOr, BitXor, Not}; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Bitboard(pub u64); @@ -121,7 +119,6 @@ pub struct Piece { // Write a function that, given a square, an attacking color, and an occupied bitboard, // returns a Bitboard representing all pieces of that color that can attack the square. // - Consider all piece attack patterns: rook, bishop, knight, king, and pawn. - pub color: Color, pub role: Role, } @@ -431,7 +428,6 @@ impl Board { self.king_of(color).single_square() } - // ISSUE #1: Implement the `attackers` function. pub fn attackers() -> Bitboard { //Write your code here @@ -440,8 +436,8 @@ impl Board { /// Returns true if there is any attack on the square. pub fn attacks() -> bool { - //Write your code here - false // Temporary placeholder + //Write your code here + false // Temporary placeholder } // ISSUE #2: Implement the `slider_blockers` function. @@ -536,48 +532,48 @@ impl Board { if self.is_occupied_square(dest) { return None; } - + // Get the piece at the origin square let piece_opt = self.piece_at(orig); - if piece_opt.is_none() { - return None; - } - + piece_opt?; + let piece = piece_opt.unwrap(); let piece_color = piece.color; - + // Create a new board with the piece moved let new_board = self.discard_by_square(orig).put_or_replace(piece, dest); - + // Find our king's position let king_pos = new_board.king_pos_of(piece_color); if king_pos.is_none() { // If there's no king, just return the new board return Some(new_board); } - + let king_square = king_pos.unwrap(); - + // Find all blockers between our king and attacking slider pieces let _blockers = Self::find_slider_blockers(&new_board, king_square, piece_color); - + // Store the blockers information somewhere or use it for move validation // For now, we'll just return the new board Some(new_board) } - + // Helper function to find slider blockers fn find_slider_blockers(board: &Board, our_king: Square, us: Color) -> Bitboard { let them = us.opposite(); let mut blockers = Bitboard::EMPTY; - + // Get enemy bishops, rooks, and queens (all slider pieces) - let enemy_bishops_and_queens = board.by_color.get(them) & (board.by_role.bishop | board.by_role.queen); - let enemy_rooks_and_queens = board.by_color.get(them) & (board.by_role.rook | board.by_role.queen); - + let enemy_bishops_and_queens = + board.by_color.get(them) & (board.by_role.bishop | board.by_role.queen); + let enemy_rooks_and_queens = + board.by_color.get(them) & (board.by_role.rook | board.by_role.queen); + // Get all pieces except our king let occupied_except_king = board.occupied ^ our_king.bitboard(); - + // Check for potential bishop-like attackers (bishops and queens on diagonals) let mut potential_bishop_attackers = enemy_bishops_and_queens.0; while potential_bishop_attackers != 0 { @@ -585,43 +581,44 @@ impl Board { let attacker_square = Square { value: potential_bishop_attackers.trailing_zeros() as u8, }; - + // Check if the attacker is on the same diagonal or anti-diagonal as our king let king_file = our_king.value % 8; let king_rank = our_king.value / 8; let attacker_file = attacker_square.value % 8; let attacker_rank = attacker_square.value / 8; - + // Check if they're on the same diagonal or anti-diagonal let file_diff = (attacker_file as i8 - king_file as i8).abs(); let rank_diff = (attacker_rank as i8 - king_rank as i8).abs(); - + if file_diff == rank_diff { // They're on the same diagonal or anti-diagonal // Calculate the ray between them let mut ray = Bitboard::EMPTY; - + // Determine the direction let file_step = if attacker_file > king_file { 1 } else { -1 }; let rank_step = if attacker_rank > king_rank { 1 } else { -1 }; - + // Start from the king and move towards the attacker let mut current_file = king_file as i8 + file_step; let mut current_rank = king_rank as i8 + rank_step; - + // Add all squares between king and attacker to the ray - while current_file >= 0 && current_file < 8 && - current_rank >= 0 && current_rank < 8 && - (current_file != attacker_file as i8 || current_rank != attacker_rank as i8) { + while (0..8).contains(¤t_file) + && (0..8).contains(¤t_rank) + && (current_file != attacker_file as i8 || current_rank != attacker_rank as i8) + { let square = Square { - value: ((current_rank as u8) * 8 + (current_file as u8)) as u8, + value: ((current_rank as u8) * 8 + (current_file as u8)), }; ray = ray | square.bitboard(); - + current_file += file_step; current_rank += rank_step; } - + // Check if there's exactly one piece on the ray let pieces_on_ray = ray & occupied_except_king; if pieces_on_ray.count() == 1 { @@ -633,11 +630,11 @@ impl Board { } } } - + // Clear the least significant bit potential_bishop_attackers &= potential_bishop_attackers - 1; } - + // Check for potential rook-like attackers (rooks and queens on ranks/files) let mut potential_rook_attackers = enemy_rooks_and_queens.0; while potential_rook_attackers != 0 { @@ -645,49 +642,49 @@ impl Board { let attacker_square = Square { value: potential_rook_attackers.trailing_zeros() as u8, }; - + // Check if the attacker is on the same file or rank as our king let king_file = our_king.value % 8; let king_rank = our_king.value / 8; let attacker_file = attacker_square.value % 8; let attacker_rank = attacker_square.value / 8; - + // Check if they're on the same file or rank if king_file == attacker_file || king_rank == attacker_rank { // They're on the same file or rank // Calculate the ray between them let mut ray = Bitboard::EMPTY; - + if king_file == attacker_file { // Same file let rank_step = if attacker_rank > king_rank { 1 } else { -1 }; let mut current_rank = king_rank as i8 + rank_step; - + // Add all squares between king and attacker to the ray - while current_rank >= 0 && current_rank < 8 && current_rank != attacker_rank as i8 { + while (0..8).contains(¤t_rank) && current_rank != attacker_rank as i8 { let square = Square { - value: ((current_rank as u8) * 8 + (king_file as u8)) as u8, + value: ((current_rank as u8) * 8 + king_file), }; ray = ray | square.bitboard(); - + current_rank += rank_step; } } else { // Same rank let file_step = if attacker_file > king_file { 1 } else { -1 }; let mut current_file = king_file as i8 + file_step; - + // Add all squares between king and attacker to the ray - while current_file >= 0 && current_file < 8 && current_file != attacker_file as i8 { + while (0..8).contains(¤t_file) && current_file != attacker_file as i8 { let square = Square { - value: ((king_rank as u8) * 8 + (current_file as u8)) as u8, + value: (king_rank * 8 + (current_file as u8)), }; ray = ray | square.bitboard(); - + current_file += file_step; } } - + // Check if there's exactly one piece on the ray let pieces_on_ray = ray & occupied_except_king; if pieces_on_ray.count() == 1 { @@ -699,11 +696,11 @@ impl Board { } } } - + // Clear the least significant bit potential_rook_attackers &= potential_rook_attackers - 1; } - + blockers } @@ -731,13 +728,13 @@ impl Board { // ISSUE #5: Implement the `piece_map` function. pub fn piece_map(&self) -> HashMap { let mut map_of_pieces = HashMap::new(); - + for square in self.occupied.to_squares() { if let Some(piece) = self.piece_at(square) { map_of_pieces.insert(square, piece); } } - + map_of_pieces } @@ -763,4 +760,3 @@ impl Board { self.by_color.get(color) } } - diff --git a/backend/modules/chess/src/bitboard/mod.rs b/backend/modules/chess/src/bitboard/mod.rs index dacf0917..77cfd3bd 100644 --- a/backend/modules/chess/src/bitboard/mod.rs +++ b/backend/modules/chess/src/bitboard/mod.rs @@ -1,2 +1,3 @@ +#[allow(clippy::module_inception)] +pub mod bitboard; pub mod board; -pub mod bitboard; \ No newline at end of file diff --git a/backend/modules/chess/src/lib.rs b/backend/modules/chess/src/lib.rs index ea285281..ac520d20 100644 --- a/backend/modules/chess/src/lib.rs +++ b/backend/modules/chess/src/lib.rs @@ -1,8 +1,11 @@ pub mod bitboard; -pub mod time_control; pub mod pgn; pub mod rating; +pub mod time_control; -pub use time_control::{TimeControl, PlayerClock}; -pub use pgn::{parse_pgn, validate_game, ParsedGame, ValidatedGame, PgnError, PgnHeaders, GameResult as PgnGameResult}; -pub use rating::{RatingService, RatingConfig, GameOutcome}; +pub use pgn::{ + parse_pgn, validate_game, GameResult as PgnGameResult, ParsedGame, PgnError, PgnHeaders, + ValidatedGame, +}; +pub use rating::{GameOutcome, RatingConfig, RatingService}; +pub use time_control::{PlayerClock, TimeControl}; diff --git a/backend/modules/chess/src/pgn.rs b/backend/modules/chess/src/pgn.rs index fe912faa..2e832a71 100644 --- a/backend/modules/chess/src/pgn.rs +++ b/backend/modules/chess/src/pgn.rs @@ -35,20 +35,15 @@ pub enum PgnError { } /// Represents the result of a chess game -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum GameResult { WhiteWins, BlackWins, Draw, + #[default] Ongoing, } -impl Default for GameResult { - fn default() -> Self { - GameResult::Ongoing - } -} - impl GameResult { /// Parse a result string from PGN format pub fn from_pgn_string(s: &str) -> Result { @@ -111,17 +106,17 @@ pub struct ValidatedGame { /// Parse PGN headers from the input string fn parse_headers(pgn: &str) -> Result<(PgnHeaders, &str), PgnError> { let header_regex = Regex::new(r#"\[(\w+)\s+"([^"]+)"\]"#).unwrap(); - + let mut headers = PgnHeaders::default(); let mut last_header_end = 0; - + for cap in header_regex.captures_iter(pgn) { let full_match = cap.get(0).unwrap(); last_header_end = full_match.end(); - + let key = cap.get(1).unwrap().as_str(); let value = cap.get(2).unwrap().as_str().to_string(); - + match key.to_lowercase().as_str() { "event" => headers.event = Some(value), "site" => headers.site = Some(value), @@ -135,7 +130,7 @@ fn parse_headers(pgn: &str) -> Result<(PgnHeaders, &str), PgnError> { } } } - + // Validate required headers if headers.white.is_empty() { return Err(PgnError::MissingHeader("White".to_string())); @@ -143,10 +138,10 @@ fn parse_headers(pgn: &str) -> Result<(PgnHeaders, &str), PgnError> { if headers.black.is_empty() { return Err(PgnError::MissingHeader("Black".to_string())); } - + // Get the move text (everything after headers) let move_text = &pgn[last_header_end..]; - + Ok((headers, move_text)) } @@ -159,24 +154,24 @@ fn parse_moves(move_text: &str) -> Vec { let without_semicolon_comments = Regex::new(r";[^\n]*") .unwrap() .replace_all(&without_curly_comments, " "); - + // Remove NAGs (Numeric Annotation Glyphs like $1, $2, etc.) let without_nags = Regex::new(r"\$\d+") .unwrap() .replace_all(&without_semicolon_comments, " "); - + // Remove variations (recursive parentheses - simplified, only top-level) let without_variations = Regex::new(r"\([^()]*\)") .unwrap() .replace_all(&without_nags, " "); - + // Split into tokens let tokens: Vec<&str> = without_variations.split_whitespace().collect(); - + // Filter out move numbers, results, and other non-move tokens let move_number_regex = Regex::new(r"^\d+\.+$").unwrap(); let result_regex = Regex::new(r"^(1-0|0-1|1/2-1/2|\*)$").unwrap(); - + tokens .into_iter() .filter(|token| { @@ -189,14 +184,14 @@ fn parse_moves(move_text: &str) -> Vec { /// Parse a PGN string into a ParsedGame pub fn parse_pgn(pgn_string: &str) -> Result { let pgn = pgn_string.trim(); - + if pgn.is_empty() { return Err(PgnError::EmptyPgn); } - + let (headers, move_text) = parse_headers(pgn)?; let moves = parse_moves(move_text); - + Ok(ParsedGame { headers, moves, @@ -209,37 +204,40 @@ pub fn parse_pgn(pgn_string: &str) -> Result { pub fn validate_game(parsed: &ParsedGame) -> Result { let mut position: Chess = Chess::default(); let mut validated_moves = Vec::new(); - + for (idx, move_san) in parsed.moves.iter().enumerate() { let move_number = (idx / 2) + 1; - + // Parse the SAN move let san: San = move_san.parse().map_err(|_| PgnError::IllegalMove { move_number, move_text: move_san.clone(), reason: "Invalid move notation".to_string(), })?; - + // Try to play the move let chess_move = san.to_move(&position).map_err(|_| PgnError::IllegalMove { move_number, move_text: move_san.clone(), reason: "Move is not legal in this position".to_string(), })?; - - position = position.play(&chess_move).map_err(|_| PgnError::IllegalMove { - move_number, - move_text: move_san.clone(), - reason: "Move leaves king in check".to_string(), - })?; - + + position = position + .play(&chess_move) + .map_err(|_| PgnError::IllegalMove { + move_number, + move_text: move_san.clone(), + reason: "Move leaves king in check".to_string(), + })?; + validated_moves.push(move_san.clone()); } - + // Get final FEN - let final_fen = shakmaty::fen::Fen::from_position(position.clone(), shakmaty::EnPassantMode::Legal) - .to_string(); - + let final_fen = + shakmaty::fen::Fen::from_position(position.clone(), shakmaty::EnPassantMode::Legal) + .to_string(); + Ok(ValidatedGame { headers: parsed.headers.clone(), moves: validated_moves, @@ -263,7 +261,7 @@ mod tests { let result = parse_pgn(pgn); assert!(result.is_ok()); - + let parsed = result.unwrap(); assert_eq!(parsed.headers.white, "Magnus Carlsen"); assert_eq!(parsed.headers.black, "Hikaru Nakamura"); @@ -281,7 +279,7 @@ mod tests { let parsed = parse_pgn(pgn).unwrap(); let validated = validate_game(&parsed); - + assert!(validated.is_ok()); let game = validated.unwrap(); assert!(game.is_valid); @@ -300,7 +298,7 @@ mod tests { let parsed = parse_pgn(pgn).unwrap(); let validated = validate_game(&parsed); - + assert!(validated.is_err()); if let Err(PgnError::IllegalMove { move_text, .. }) = validated { assert_eq!(move_text, "Ke3"); @@ -333,9 +331,21 @@ mod tests { #[test] fn test_game_result_parsing() { - assert_eq!(GameResult::from_pgn_string("1-0").unwrap(), GameResult::WhiteWins); - assert_eq!(GameResult::from_pgn_string("0-1").unwrap(), GameResult::BlackWins); - assert_eq!(GameResult::from_pgn_string("1/2-1/2").unwrap(), GameResult::Draw); - assert_eq!(GameResult::from_pgn_string("*").unwrap(), GameResult::Ongoing); + assert_eq!( + GameResult::from_pgn_string("1-0").unwrap(), + GameResult::WhiteWins + ); + assert_eq!( + GameResult::from_pgn_string("0-1").unwrap(), + GameResult::BlackWins + ); + assert_eq!( + GameResult::from_pgn_string("1/2-1/2").unwrap(), + GameResult::Draw + ); + assert_eq!( + GameResult::from_pgn_string("*").unwrap(), + GameResult::Ongoing + ); } } diff --git a/backend/modules/chess/src/rating.rs b/backend/modules/chess/src/rating.rs index a9e87a2e..f8e718a9 100644 --- a/backend/modules/chess/src/rating.rs +++ b/backend/modules/chess/src/rating.rs @@ -1,8 +1,11 @@ -use sea_orm::{DatabaseConnection, DatabaseTransaction, DbErr, TransactionTrait, EntityTrait, ActiveModelTrait, Set}; -use uuid::Uuid; -use db_entity::{player, game}; +use db_entity::{game, player}; use error::error::ApiError; use matchmaking::elo::calculate_new_ratings; +use sea_orm::{ + ActiveModelTrait, DatabaseConnection, DatabaseTransaction, DbErr, EntityTrait, Set, + TransactionTrait, +}; +use uuid::Uuid; /// Service for handling Elo rating calculations and updates after game completion pub struct RatingService; @@ -38,22 +41,22 @@ impl Default for RatingConfig { impl RatingService { /// Updates player ratings after a game completion using a database transaction - /// + /// /// # Arguments /// * `db` - Database connection /// * `game_id` - UUID of the completed game /// * `config` - Rating configuration (K-factor, min/max ratings) - /// + /// /// # Returns /// * `Ok((white_new_rating, black_new_rating))` - New ratings for both players /// * `Err(ApiError)` - If game not found, players not found, or database error - /// + /// /// # Example /// ```rust /// let config = RatingConfig::default(); /// let (white_rating, black_rating) = RatingService::update_ratings_after_game( - /// &db, - /// game_id, + /// &db, + /// game_id, /// &config /// ).await?; /// ``` @@ -63,16 +66,21 @@ impl RatingService { config: &RatingConfig, ) -> Result<(i32, i32), ApiError> { // Start a database transaction to ensure atomicity - let txn = db.begin().await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to start transaction: {}", e))))?; + let txn = db.begin().await.map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!("Failed to start transaction: {}", e))) + })?; let result = Self::update_ratings_in_transaction(&txn, game_id, config).await; match result { Ok(ratings) => { // Commit the transaction if everything succeeded - txn.commit().await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to commit transaction: {}", e))))?; + txn.commit().await.map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!( + "Failed to commit transaction: {}", + e + ))) + })?; Ok(ratings) } Err(e) => { @@ -93,11 +101,14 @@ impl RatingService { let game_model = game::Entity::find_by_id(game_id) .one(txn) .await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to fetch game: {}", e))))? + .map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!("Failed to fetch game: {}", e))) + })? .ok_or_else(|| ApiError::NotFound("Game not found".to_string()))?; // 2. Check if game is completed - let game_result = game_model.result + let game_result = game_model + .result .ok_or_else(|| ApiError::BadRequest("Game is not completed yet".to_string()))?; // 3. Determine game outcome @@ -110,7 +121,9 @@ impl RatingService { } db_entity::game::ResultSide::Abandoned => { // For abandoned games, we don't update ratings - return Err(ApiError::BadRequest("Ratings not updated for abandoned games".to_string())); + return Err(ApiError::BadRequest( + "Ratings not updated for abandoned games".to_string(), + )); } }; @@ -118,13 +131,23 @@ impl RatingService { let white_player = player::Entity::find_by_id(game_model.white_player) .one(txn) .await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to fetch white player: {}", e))))? + .map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!( + "Failed to fetch white player: {}", + e + ))) + })? .ok_or_else(|| ApiError::NotFound("White player not found".to_string()))?; let black_player = player::Entity::find_by_id(game_model.black_player) .one(txn) .await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to fetch black player: {}", e))))? + .map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!( + "Failed to fetch black player: {}", + e + ))) + })? .ok_or_else(|| ApiError::NotFound("Black player not found".to_string()))?; // 5. Calculate new ratings based on game outcome @@ -149,11 +172,19 @@ impl RatingService { }; // Execute both updates in the same transaction - white_active_model.update(txn).await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to update white player rating: {}", e))))?; - - black_active_model.update(txn).await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to update black player rating: {}", e))))?; + white_active_model.update(txn).await.map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!( + "Failed to update white player rating: {}", + e + ))) + })?; + + black_active_model.update(txn).await.map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!( + "Failed to update black player rating: {}", + e + ))) + })?; Ok((new_white_rating, new_black_rating)) } @@ -172,7 +203,11 @@ impl RatingService { } GameOutcome::Loss => { // White loses, black wins - let (new_black, new_white) = calculate_new_ratings(black_rating as u32, white_rating as u32, config.k_factor); + let (new_black, new_white) = calculate_new_ratings( + black_rating as u32, + white_rating as u32, + config.k_factor, + ); (new_white, new_black) } GameOutcome::Draw => { @@ -216,7 +251,9 @@ impl RatingService { let player = player::Entity::find_by_id(player_id) .one(db) .await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to fetch player: {}", e))))? + .map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!("Failed to fetch player: {}", e))) + })? .ok_or_else(|| ApiError::NotFound("Player not found".to_string()))?; Ok(player.elo_rating) @@ -237,8 +274,12 @@ impl RatingService { ..Default::default() }; - active_model.update(db).await - .map_err(|e| ApiError::DatabaseError(DbErr::Custom(format!("Failed to update player rating: {}", e))))?; + active_model.update(db).await.map_err(|e| { + ApiError::DatabaseError(DbErr::Custom(format!( + "Failed to update player rating: {}", + e + ))) + })?; Ok(()) } @@ -251,10 +292,9 @@ mod tests { #[test] fn test_calculate_rating_changes_white_wins() { let config = RatingConfig::default(); - let (new_white, new_black) = RatingService::calculate_rating_changes( - 1500, 1500, GameOutcome::Win, &config - ); - + let (new_white, new_black) = + RatingService::calculate_rating_changes(1500, 1500, GameOutcome::Win, &config); + // Equal ratings, white wins: white gains ~16, black loses ~16 assert!(new_white > 1500); assert!(new_black < 1500); @@ -264,10 +304,9 @@ mod tests { #[test] fn test_calculate_rating_changes_draw() { let config = RatingConfig::default(); - let (new_white, new_black) = RatingService::calculate_rating_changes( - 1600, 1400, GameOutcome::Draw, &config - ); - + let (new_white, new_black) = + RatingService::calculate_rating_changes(1600, 1400, GameOutcome::Draw, &config); + // Higher rated player loses points in draw, lower rated gains assert!(new_white < 1600); assert!(new_black > 1400); @@ -280,11 +319,10 @@ mod tests { min_rating: 100, max_rating: 2000, }; - - let (new_white, new_black) = RatingService::calculate_rating_changes( - 50, 2500, GameOutcome::Win, &config - ); - + + let (new_white, new_black) = + RatingService::calculate_rating_changes(50, 2500, GameOutcome::Win, &config); + // Ratings should be clamped to bounds assert!(new_white >= config.min_rating); assert!(new_white <= config.max_rating); @@ -295,16 +333,15 @@ mod tests { #[test] fn test_upset_victory_large_rating_change() { let config = RatingConfig::default(); - let (new_white, new_black) = RatingService::calculate_rating_changes( - 1200, 1800, GameOutcome::Win, &config - ); - + let (new_white, new_black) = + RatingService::calculate_rating_changes(1200, 1800, GameOutcome::Win, &config); + // Lower rated player beating higher rated should gain significant points let white_gain = new_white - 1200; let black_loss = 1800 - new_black; - + assert!(white_gain > 20); // Significant gain for upset assert!(black_loss > 20); // Significant loss for upset assert_eq!(white_gain, black_loss); // Zero-sum } -} \ No newline at end of file +} diff --git a/backend/modules/chess/tests/board_tests.rs b/backend/modules/chess/tests/board_tests.rs index 65011e72..47dda7a5 100644 --- a/backend/modules/chess/tests/board_tests.rs +++ b/backend/modules/chess/tests/board_tests.rs @@ -1,5 +1,5 @@ +use chess::bitboard::board::{Bitboard, Board, ByColor, ByRole, Color, Piece, Role, Square}; use std::collections::HashMap; -use chess::bitboard::board::{Board, Bitboard, ByColor, ByRole, Color, Piece, Role, Square}; #[cfg(test)] mod tests { @@ -9,33 +9,42 @@ mod tests { fn test_piece_map() { // Create an empty board let mut board = Board::empty(); - + // Add some pieces to specific squares let e2 = Square { value: 12 }; // e2 square let e4 = Square { value: 28 }; // e4 square let d8 = Square { value: 59 }; // d8 square - - let white_pawn = Piece { color: Color::White, role: Role::Pawn }; - let white_king = Piece { color: Color::White, role: Role::King }; - let black_queen = Piece { color: Color::Black, role: Role::Queen }; - + + let white_pawn = Piece { + color: Color::White, + role: Role::Pawn, + }; + let white_king = Piece { + color: Color::White, + role: Role::King, + }; + let black_queen = Piece { + color: Color::Black, + role: Role::Queen, + }; + // Place pieces on the board board = board.put_or_replace(white_pawn, e2); board = board.put_or_replace(white_king, e4); board = board.put_or_replace(black_queen, d8); - + // Get the piece map let piece_map = board.piece_map(); - + // Verify the map contains our pieces at the correct squares assert_eq!(piece_map.len(), 3); assert_eq!(piece_map.get(&e2), Some(&white_pawn)); assert_eq!(piece_map.get(&e4), Some(&white_king)); assert_eq!(piece_map.get(&d8), Some(&black_queen)); - + // Test with an empty board let empty_board = Board::empty(); let empty_map = empty_board.piece_map(); assert_eq!(empty_map.len(), 0); } -} \ No newline at end of file +} diff --git a/backend/modules/chess/tests/rating_integration_test.rs b/backend/modules/chess/tests/rating_integration_test.rs index 38c16908..cd36df80 100644 --- a/backend/modules/chess/tests/rating_integration_test.rs +++ b/backend/modules/chess/tests/rating_integration_test.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod rating_integration_tests { - use chess::{RatingService, RatingConfig}; - + use chess::{RatingConfig, RatingService}; + #[test] fn test_rating_config_defaults() { let config = RatingConfig::default(); @@ -17,9 +17,9 @@ mod rating_integration_tests { min_rating: 800, max_rating: 2400, }; - + // Test that ratings are properly clamped assert!(config.min_rating <= config.max_rating); assert!(config.k_factor > 0); } -} \ No newline at end of file +} diff --git a/backend/modules/chess/tests/rating_tests.rs b/backend/modules/chess/tests/rating_tests.rs index e69de29b..8b137891 100644 --- a/backend/modules/chess/tests/rating_tests.rs +++ b/backend/modules/chess/tests/rating_tests.rs @@ -0,0 +1 @@ + diff --git a/backend/modules/chess/tests/time_control_tests.rs b/backend/modules/chess/tests/time_control_tests.rs index 5c7fd0d7..3a742b7d 100644 --- a/backend/modules/chess/tests/time_control_tests.rs +++ b/backend/modules/chess/tests/time_control_tests.rs @@ -1,4 +1,4 @@ -use chess::{TimeControl, PlayerClock}; +use chess::{PlayerClock, TimeControl}; use std::time::Duration; #[cfg(test)] diff --git a/backend/modules/db/entity/src/bin/game_benchmark.rs b/backend/modules/db/entity/src/bin/game_benchmark.rs index ec75761d..532b7dc2 100644 --- a/backend/modules/db/entity/src/bin/game_benchmark.rs +++ b/backend/modules/db/entity/src/bin/game_benchmark.rs @@ -1,15 +1,15 @@ -use sea_orm::{*, ActiveValue::Set, EntityTrait, QueryFilter, QuerySelect, sea_query::Expr}; +use db_entity::game::{GameVariant, ResultSide}; // Added imports use db_entity::prelude::{Game, Player}; use db_entity::{game, player}; -use db_entity::game::{ResultSide, GameVariant}; // Added imports +use dotenv::dotenv; +use rand::distributions::Alphanumeric; +use rand::prelude::*; +use sea_orm::{sea_query::Expr, ActiveValue::Set, EntityTrait, QueryFilter, QuerySelect, *}; use serde_json::{json, Value as JsonValue}; -use uuid::Uuid; use std::env; use std::time::Instant; -use rand::prelude::*; -use rand::distributions::Alphanumeric; use tokio::time::{sleep, Duration}; -use dotenv::dotenv; +use uuid::Uuid; // Configuration const NUM_PLAYERS_TO_CREATE: usize = 100; @@ -19,8 +19,8 @@ const BATCH_SIZE: usize = 100; // Insert games in batches // Helper to connect to the database async fn setup_db() -> Result { dotenv().ok(); // load .env if present - let db_url = env::var("DATABASE_URL") - .expect("DATABASE_URL environment variable not set for benchmark"); + let db_url = + env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set for benchmark"); Database::connect(&db_url).await } @@ -31,9 +31,9 @@ fn generate_random_pgn(rng: &mut ThreadRng) -> JsonValue { .map(|_| { let len = rng.gen_range(2..6); // Calculate len first rng.sample_iter(&Alphanumeric) - .take(len) - .map(char::from) - .collect() + .take(len) + .map(char::from) + .collect() }) .collect(); @@ -55,12 +55,12 @@ fn generate_random_pgn(rng: &mut ThreadRng) -> JsonValue { fn generate_random_fen(rng: &mut ThreadRng) -> String { let len = rng.gen_range(40..70); // Calculate len first rng.sample_iter(&Alphanumeric) - .take(len) - .map(char::from) - .collect::() + " w KQkq - 0 1" + .take(len) + .map(char::from) + .collect::() + + " w KQkq - 0 1" } - #[tokio::main] async fn main() -> Result<(), Box> { println!("Starting game benchmark..."); @@ -75,15 +75,19 @@ async fn main() -> Result<(), Box> { player_models.push(player::ActiveModel { id: Set(player_id), // Explicitly set the ID username: Set(format!("bench_user_{}_{}", i, Uuid::new_v4().simple())), - email: Set(format!("bench_email_{}_{}@bench.com", i, Uuid::new_v4().simple())), + email: Set(format!( + "bench_email_{}_{}@bench.com", + i, + Uuid::new_v4().simple() + )), password_hash: Set(b"bench_hash".to_vec()), biography: Set("Benchmark player biography".to_string()), // Provide a non-null value - country: Set("Unknown".to_string()), // Add default - flair: Set("Bench Flair".to_string()), // Add default - real_name: Set("Bench Real Name".to_string()), // Add default - location: Set(Some("Bench Location".to_string())), // Add default - fide_rating: Set(Some(1500)), // Add default - social_links: Set(Some(vec![])), // Add default (empty vec) + country: Set("Unknown".to_string()), // Add default + flair: Set("Bench Flair".to_string()), // Add default + real_name: Set("Bench Real Name".to_string()), // Add default + location: Set(Some("Bench Location".to_string())), // Add default + fide_rating: Set(Some(1500)), // Add default + social_links: Set(Some(vec![])), // Add default (empty vec) ..Default::default() }); } @@ -104,10 +108,23 @@ async fn main() -> Result<(), Box> { println!("Fetched {} player IDs for game creation.", player_ids.len()); // === Benchmark: Insertions === - println!("Inserting {} games in batches of {}...", NUM_GAMES_TO_INSERT, BATCH_SIZE); + println!( + "Inserting {} games in batches of {}...", + NUM_GAMES_TO_INSERT, BATCH_SIZE + ); let mut game_models = Vec::with_capacity(BATCH_SIZE); - let variants = [GameVariant::Standard, GameVariant::Chess960, GameVariant::Blitz, GameVariant::Rapid, GameVariant::Classical]; // Update variants list - let results = [ResultSide::WhiteWins, ResultSide::BlackWins, ResultSide::Draw]; // Update results list + let variants = [ + GameVariant::Standard, + GameVariant::Chess960, + GameVariant::Blitz, + GameVariant::Rapid, + GameVariant::Classical, + ]; // Update variants list + let results = [ + ResultSide::WhiteWins, + ResultSide::BlackWins, + ResultSide::Draw, + ]; // Update results list let insert_start = Instant::now(); for i in 0..NUM_GAMES_TO_INSERT { @@ -130,8 +147,9 @@ async fn main() -> Result<(), Box> { if game_models.len() >= BATCH_SIZE || i == NUM_GAMES_TO_INSERT - 1 { Game::insert_many(game_models.drain(..)).exec(&db).await?; - if (i + 1) % (BATCH_SIZE * 10) == 0 { // Print progress - println!(" Inserted {} games...", i + 1); + if (i + 1) % (BATCH_SIZE * 10) == 0 { + // Print progress + println!(" Inserted {} games...", i + 1); } } } @@ -200,26 +218,34 @@ async fn main() -> Result<(), Box> { ); // === Cleanup (Optional but recommended) === - println!("\nStarting cleanup (deleting benchmark games and players)... This might take a while."); + println!( + "\nStarting cleanup (deleting benchmark games and players)... This might take a while." + ); let cleanup_start = Instant::now(); // Delete games associated with the benchmark players let delete_games_res = Game::delete_many() .filter( - game::Column::WhitePlayer.is_in(player_ids.clone()) - .or(game::Column::BlackPlayer.is_in(player_ids.clone())) + game::Column::WhitePlayer + .is_in(player_ids.clone()) + .or(game::Column::BlackPlayer.is_in(player_ids.clone())), ) - .exec(&db).await?; + .exec(&db) + .await?; println!(" Deleted {} game records.", delete_games_res.rows_affected); // Delete benchmark players let delete_players_res = Player::delete_many() .filter(player::Column::Username.starts_with("bench_user_")) - .exec(&db).await?; - println!(" Deleted {} player records.", delete_players_res.rows_affected); + .exec(&db) + .await?; + println!( + " Deleted {} player records.", + delete_players_res.rows_affected + ); let cleanup_duration = cleanup_start.elapsed(); println!("Cleanup finished in {:.2?}.", cleanup_duration); Ok(()) -} \ No newline at end of file +} diff --git a/backend/modules/db/entity/src/game.rs b/backend/modules/db/entity/src/game.rs index ccbdf6f9..01857a56 100644 --- a/backend/modules/db/entity/src/game.rs +++ b/backend/modules/db/entity/src/game.rs @@ -35,7 +35,6 @@ pub enum GameVariant { Classical, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)] - #[sea_orm(table_name = "game", schema_name = "smdb")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/backend/modules/db/entity/src/lib.rs b/backend/modules/db/entity/src/lib.rs index 48898a2a..8cab91f0 100644 --- a/backend/modules/db/entity/src/lib.rs +++ b/backend/modules/db/entity/src/lib.rs @@ -1,7 +1,7 @@ -pub mod prelude; pub mod game; pub mod player; +pub mod prelude; pub mod refresh_token; #[path = "../user.rs"] -pub mod user; \ No newline at end of file +pub mod user; diff --git a/backend/modules/db/entity/src/player.rs b/backend/modules/db/entity/src/player.rs index 96c4adf1..bdf82929 100644 --- a/backend/modules/db/entity/src/player.rs +++ b/backend/modules/db/entity/src/player.rs @@ -22,11 +22,9 @@ pub struct Model { pub fide_rating: Option, pub elo_rating: i32, pub social_links: Option>, - pub is_enabled: bool + pub is_enabled: bool, } - - #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} diff --git a/backend/modules/db/entity/src/refresh_token.rs b/backend/modules/db/entity/src/refresh_token.rs index 6ece490d..87cfe31b 100644 --- a/backend/modules/db/entity/src/refresh_token.rs +++ b/backend/modules/db/entity/src/refresh_token.rs @@ -1,5 +1,5 @@ -use sea_orm::entity::prelude::*; use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; #[derive(Clone, Debug, DeriveEntityModel, PartialEq, Eq)] #[sea_orm(table_name = "refresh_tokens")] diff --git a/backend/modules/db/entity/tests/game_smoke_test.rs b/backend/modules/db/entity/tests/game_smoke_test.rs index 02fa9166..99be7620 100644 --- a/backend/modules/db/entity/tests/game_smoke_test.rs +++ b/backend/modules/db/entity/tests/game_smoke_test.rs @@ -1,8 +1,8 @@ -use sea_orm::*; -use sea_orm::prelude::Uuid; +use db_entity::game::{GameVariant, ResultSide}; use db_entity::prelude::*; use db_entity::{game, player}; -use db_entity::game::{ResultSide, GameVariant}; +use sea_orm::prelude::Uuid; +use sea_orm::*; use serde_json::json; use std::env; @@ -10,8 +10,8 @@ use std::env; // Ensure the DATABASE_URL environment variable is set when running tests. // Example: export DATABASE_URL="postgres://briechuser:admin@82.29.169.187/starkwager-backend_db" async fn setup_db() -> Result { - let db_url = env::var("DATABASE_URL") - .expect("DATABASE_URL environment variable not set for tests"); + let db_url = + env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set for tests"); Database::connect(&db_url).await } @@ -35,7 +35,10 @@ async fn test_insert_and_verify_game() -> Result<(), Box> }; let player_insert_result = player_model.insert(&db).await?; let player_id = player_insert_result.id; - println!("Smoke test: Created temporary player with ID: {}", player_id); + println!( + "Smoke test: Created temporary player with ID: {}", + player_id + ); // 2. Prepare sample game data let game_fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"; @@ -74,7 +77,10 @@ async fn test_insert_and_verify_game() -> Result<(), Box> println!("Smoke test: Inserted game with ID: {}", game_id); // 5. Verify the insertion by fetching the record - assert!(game_id != Uuid::nil(), "Generated game ID should not be nil"); + assert!( + game_id != Uuid::nil(), + "Generated game ID should not be nil" + ); let fetched_game = Game::find_by_id(game_id) .one(&db) @@ -86,7 +92,10 @@ async fn test_insert_and_verify_game() -> Result<(), Box> assert_eq!(fetched_game.white_player, player_id); assert_eq!(fetched_game.black_player, player_id); assert_eq!(fetched_game.fen, game_fen); - assert_eq!(fetched_game.pgn, game_pgn, "Fetched PGN JSON does not match"); + assert_eq!( + fetched_game.pgn, game_pgn, + "Fetched PGN JSON does not match" + ); assert_eq!(fetched_game.result, Some(game_result)); assert_eq!(fetched_game.variant, game_variant); assert_eq!(fetched_game.duration_sec, game_duration); @@ -96,12 +105,18 @@ async fn test_insert_and_verify_game() -> Result<(), Box> // 6. Clean up: Delete the created records let game_delete_result = Game::delete_by_id(game_id).exec(&db).await?; - assert_eq!(game_delete_result.rows_affected, 1, "Should delete 1 game record"); + assert_eq!( + game_delete_result.rows_affected, 1, + "Should delete 1 game record" + ); let player_delete_result = Player::delete_by_id(player_id).exec(&db).await?; - assert_eq!(player_delete_result.rows_affected, 1, "Should delete 1 player record"); + assert_eq!( + player_delete_result.rows_affected, 1, + "Should delete 1 player record" + ); println!("Smoke test: Cleaned up temporary game and player records."); Ok(()) -} \ No newline at end of file +} diff --git a/backend/modules/db/entity/user.rs b/backend/modules/db/entity/user.rs index ebd553a9..6caadccd 100644 --- a/backend/modules/db/entity/user.rs +++ b/backend/modules/db/entity/user.rs @@ -1,5 +1,5 @@ -use sea_orm::entity::prelude::*; use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; #[derive(Clone, Debug, DeriveEntityModel)] #[sea_orm(table_name = "users")] diff --git a/backend/modules/db/migrations/src/lib.rs b/backend/modules/db/migrations/src/lib.rs index 246b265f..650a4727 100644 --- a/backend/modules/db/migrations/src/lib.rs +++ b/backend/modules/db/migrations/src/lib.rs @@ -1,15 +1,14 @@ pub use sea_orm_migration::prelude::*; mod m20250123_000001_create_users_table; +mod m20250324_add_elo_rating_to_player; mod m20250428_121011_create_players_table; mod m20250429_163843_create_games_table; mod m20250429_192832_add_common_indexes; mod m20250604_160341_create_games_and_moves; mod m20250605_090000_add_game_search_indexes; -mod m20260127_create_refresh_tokens_table; mod m20260127_180000_add_game_imported_flag; -mod m20250324_add_elo_rating_to_player; - +mod m20260127_create_refresh_tokens_table; pub struct Migrator; @@ -29,4 +28,3 @@ impl MigratorTrait for Migrator { ] } } - diff --git a/backend/modules/db/migrations/src/m20250123_000001_create_users_table.rs b/backend/modules/db/migrations/src/m20250123_000001_create_users_table.rs index adf15d6a..1fa28c1e 100644 --- a/backend/modules/db/migrations/src/m20250123_000001_create_users_table.rs +++ b/backend/modules/db/migrations/src/m20250123_000001_create_users_table.rs @@ -30,11 +30,7 @@ impl MigrationTrait for Migration { .not_null() .unique_key(), ) - .col( - ColumnDef::new(Users::PasswordHash) - .string() - .not_null(), - ) + .col(ColumnDef::new(Users::PasswordHash).string().not_null()) .col( ColumnDef::new(Users::CreatedAt) .timestamp() diff --git a/backend/modules/db/migrations/src/m20250324_add_elo_rating_to_player.rs b/backend/modules/db/migrations/src/m20250324_add_elo_rating_to_player.rs index 6241611f..309352ee 100644 --- a/backend/modules/db/migrations/src/m20250324_add_elo_rating_to_player.rs +++ b/backend/modules/db/migrations/src/m20250324_add_elo_rating_to_player.rs @@ -45,4 +45,4 @@ impl MigrationTrait for Migration { enum Player { Table, EloRating, -} \ No newline at end of file +} diff --git a/backend/modules/db/migrations/src/m20250428_121011_create_players_table.rs b/backend/modules/db/migrations/src/m20250428_121011_create_players_table.rs index 2e00f4ad..c6e1ade0 100644 --- a/backend/modules/db/migrations/src/m20250428_121011_create_players_table.rs +++ b/backend/modules/db/migrations/src/m20250428_121011_create_players_table.rs @@ -12,8 +12,18 @@ impl MigrationTrait for Migration { .table(Player::Table) .if_not_exists() .col(ColumnDef::new(Player::Id).uuid().not_null().primary_key()) - .col(ColumnDef::new(Player::Username).string().not_null().unique_key()) - .col(ColumnDef::new(Player::Email).string().not_null().unique_key()) + .col( + ColumnDef::new(Player::Username) + .string() + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(Player::Email) + .string() + .not_null() + .unique_key(), + ) .col(ColumnDef::new(Player::PasswordHash).binary().not_null()) .col(ColumnDef::new(Player::Biography).text().not_null()) .col(ColumnDef::new(Player::Country).string().not_null()) @@ -22,7 +32,11 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(Player::Location).string().null()) .col(ColumnDef::new(Player::FideRating).integer().null()) // Storing vector of strings as Array of text - .col(ColumnDef::new(Player::SocialLinks).array(ColumnType::Text).null()) + .col( + ColumnDef::new(Player::SocialLinks) + .array(ColumnType::Text) + .null(), + ) .col(ColumnDef::new(Player::IsEnabled).boolean().not_null()) .to_owned(), ) diff --git a/backend/modules/db/migrations/src/m20250429_163843_create_games_table.rs b/backend/modules/db/migrations/src/m20250429_163843_create_games_table.rs index 990d9cc5..0d45d7f7 100644 --- a/backend/modules/db/migrations/src/m20250429_163843_create_games_table.rs +++ b/backend/modules/db/migrations/src/m20250429_163843_create_games_table.rs @@ -1,4 +1,4 @@ -use sea_orm_migration::{prelude::*, schema::*, prelude::extension::postgres::Type}; +use sea_orm_migration::{prelude::extension::postgres::Type, prelude::*}; // Import Player Iden from the player creation migration use super::m20250428_121011_create_players_table::Player; use sea_orm_migration::prelude::ForeignKeyAction; // Import ForeignKeyAction @@ -21,7 +21,12 @@ impl MigrationTrait for Migration { .create_type( Type::create() .as_enum(ResultSide::Type) - .values([ResultSide::White, ResultSide::Black, ResultSide::Draw, ResultSide::None]) + .values([ + ResultSide::White, + ResultSide::Black, + ResultSide::Draw, + ResultSide::None, + ]) .to_owned(), ) .await?; @@ -31,7 +36,11 @@ impl MigrationTrait for Migration { .create_type( Type::create() .as_enum(GameVariant::Type) - .values([GameVariant::Standard, GameVariant::Chess960, GameVariant::ThreeCheck]) + .values([ + GameVariant::Standard, + GameVariant::Chess960, + GameVariant::ThreeCheck, + ]) .to_owned(), ) .await?; @@ -42,22 +51,21 @@ impl MigrationTrait for Migration { Table::create() .table((Smdb, Game::Table)) .if_not_exists() - .col( - ColumnDef::new(Game::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Game::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Game::WhitePlayer).uuid().not_null()) .col(ColumnDef::new(Game::BlackPlayer).uuid().not_null()) .col(ColumnDef::new(Game::Fen).text().not_null()) + .col(ColumnDef::new(Game::Pgn).json_binary().not_null()) + .col( + ColumnDef::new(Game::Result) + .custom(ResultSide::Type) + .not_null(), + ) .col( - ColumnDef::new(Game::Pgn) - .json_binary() + ColumnDef::new(Game::Variant) + .custom(GameVariant::Type) .not_null(), ) - .col(ColumnDef::new(Game::Result).custom(ResultSide::Type).not_null()) - .col(ColumnDef::new(Game::Variant).custom(GameVariant::Type).not_null()) .col( ColumnDef::new(Game::StartedAt) .timestamp_with_time_zone() @@ -119,10 +127,20 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { // Drop indexes (including GIN) manager - .drop_index(Index::drop().name("idx_games_started_at").table((Smdb, Game::Table)).to_owned()) + .drop_index( + Index::drop() + .name("idx_games_started_at") + .table((Smdb, Game::Table)) + .to_owned(), + ) .await?; manager - .drop_index(Index::drop().name("idx_games_variant").table((Smdb, Game::Table)).to_owned()) + .drop_index( + Index::drop() + .name("idx_games_variant") + .table((Smdb, Game::Table)) + .to_owned(), + ) .await?; manager .get_connection() @@ -131,10 +149,20 @@ impl MigrationTrait for Migration { // Drop Foreign Keys manager - .drop_foreign_key(ForeignKey::drop().name("fk_game_white_player").table((Smdb, Game::Table)).to_owned()) + .drop_foreign_key( + ForeignKey::drop() + .name("fk_game_white_player") + .table((Smdb, Game::Table)) + .to_owned(), + ) .await?; manager - .drop_foreign_key(ForeignKey::drop().name("fk_game_black_player").table((Smdb, Game::Table)).to_owned()) + .drop_foreign_key( + ForeignKey::drop() + .name("fk_game_black_player") + .table((Smdb, Game::Table)) + .to_owned(), + ) .await?; // Drop the table @@ -198,4 +226,4 @@ enum GameVariant { // Define the schema identifier #[derive(DeriveIden)] -struct Smdb; \ No newline at end of file +struct Smdb; diff --git a/backend/modules/db/migrations/src/m20250429_192832_add_common_indexes.rs b/backend/modules/db/migrations/src/m20250429_192832_add_common_indexes.rs index a159cba9..1dcfa1b2 100644 --- a/backend/modules/db/migrations/src/m20250429_192832_add_common_indexes.rs +++ b/backend/modules/db/migrations/src/m20250429_192832_add_common_indexes.rs @@ -1,6 +1,5 @@ use sea_orm_migration::{prelude::*, MigrationTrait}; - #[derive(DeriveMigrationName)] pub struct Migration; @@ -30,7 +29,9 @@ impl MigrationTrait for Migration { // Create GIN index using raw SQL as IndexType::Gin is not available/standard in all sea-orm versions or requires specific features manager .get_connection() - .execute_unprepared(r#"CREATE INDEX IF NOT EXISTS "idx_game_pgn" ON "smdb"."game" USING GIN ("pgn")"#) + .execute_unprepared( + r#"CREATE INDEX IF NOT EXISTS "idx_game_pgn" ON "smdb"."game" USING GIN ("pgn")"#, + ) .await?; manager .create_index( @@ -59,13 +60,28 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager - .drop_index(Index::drop().name("idx_game_white_player").table(Game::Table).to_owned()) + .drop_index( + Index::drop() + .name("idx_game_white_player") + .table(Game::Table) + .to_owned(), + ) .await?; manager - .drop_index(Index::drop().name("idx_game_black_player").table(Game::Table).to_owned()) + .drop_index( + Index::drop() + .name("idx_game_black_player") + .table(Game::Table) + .to_owned(), + ) .await?; manager - .drop_index(Index::drop().name("idx_game_pgn").table(Game::Table).to_owned()) + .drop_index( + Index::drop() + .name("idx_game_pgn") + .table(Game::Table) + .to_owned(), + ) .await?; manager .drop_index( diff --git a/backend/modules/db/migrations/src/m20250605_090000_add_game_search_indexes.rs b/backend/modules/db/migrations/src/m20250605_090000_add_game_search_indexes.rs index 92f6d4a0..83ad083d 100644 --- a/backend/modules/db/migrations/src/m20250605_090000_add_game_search_indexes.rs +++ b/backend/modules/db/migrations/src/m20250605_090000_add_game_search_indexes.rs @@ -1,5 +1,4 @@ - -use sea_orm_migration::{prelude::*, schema::*}; +use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; @@ -24,7 +23,7 @@ impl MigrationTrait for Migration { // For now, I will keep it but assume it allows NULL. // Wait, the previous migration added `ALTER TABLE "game" ADD CONSTRAINT "check_game_result" CHECK ("result" IN ('white', 'black', 'draw'))`. // If result is NULL, `NULL IN (...)` is NULL, which passes. So we don't need to drop the constraint for NULL support. - + // 3. Create composite indexes // idx_games_white_player_created_at_id: (white_player, created_at DESC, id DESC) manager @@ -42,14 +41,14 @@ impl MigrationTrait for Migration { // sea-orm-migration doesn't natively support DESC in Index::create() builder easily without raw SQL or specific backend features in some versions. // But let's check if we can do it. Use raw SQL for safety and precision regarding DESC order which is critical for optimization. // The builder above creates ASC by default. - + // Let's drop the index I just created (if it was created in a real run, but here I am writing the script). // Actually, I will just use raw SQL for the indexes to ensure DESC ordering. - + // Drop the index I defined above in the builder pattern? No, I'll just replace the builder call with raw SQL. - + // Re-doing step 3 with Raw SQL for DESC support - manager + manager .get_connection() .execute_unprepared( r#"CREATE INDEX "idx_games_white_player_created_at_id" ON "game" ("white_player", "created_at" DESC, "id" DESC)"# @@ -72,7 +71,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared(r#"DROP INDEX IF EXISTS "idx_games_white_player_created_at_id""#) .await?; - + manager .get_connection() .execute_unprepared(r#"DROP INDEX IF EXISTS "idx_games_black_player_created_at_id""#) diff --git a/backend/modules/db/migrations/src/m20260127_180000_add_game_imported_flag.rs b/backend/modules/db/migrations/src/m20260127_180000_add_game_imported_flag.rs index 41c79f55..f62fb53c 100644 --- a/backend/modules/db/migrations/src/m20260127_180000_add_game_imported_flag.rs +++ b/backend/modules/db/migrations/src/m20260127_180000_add_game_imported_flag.rs @@ -1,4 +1,4 @@ -use sea_orm_migration::{prelude::*, schema::*}; +use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; @@ -17,11 +17,7 @@ impl MigrationTrait for Migration { .not_null() .default(false), ) - .add_column( - ColumnDef::new(Game::OriginalPgn) - .text() - .null(), - ) + .add_column(ColumnDef::new(Game::OriginalPgn).text().null()) .to_owned(), ) .await?; diff --git a/backend/modules/db/migrations/src/m20260127_create_refresh_tokens_table.rs b/backend/modules/db/migrations/src/m20260127_create_refresh_tokens_table.rs index 24b0644c..aea02a3f 100644 --- a/backend/modules/db/migrations/src/m20260127_create_refresh_tokens_table.rs +++ b/backend/modules/db/migrations/src/m20260127_create_refresh_tokens_table.rs @@ -31,9 +31,11 @@ impl MigrationTrait for Migration { .not_null() .default(Expr::current_timestamp()), ) - .col(ColumnDef::new(RefreshTokens::UsedAt) - .timestamp_with_time_zone() - .null()) + .col( + ColumnDef::new(RefreshTokens::UsedAt) + .timestamp_with_time_zone() + .null(), + ) .col( ColumnDef::new(RefreshTokens::ExpiresAt) .timestamp_with_time_zone() @@ -52,15 +54,21 @@ impl MigrationTrait for Migration { .to(Players::Table, Players::Id) .on_delete(ForeignKeyAction::Cascade), ) - .index(Index::create() - .name("idx_refresh_tokens_family_id") - .col(RefreshTokens::FamilyId)) - .index(Index::create() - .name("idx_refresh_tokens_player_id") - .col(RefreshTokens::PlayerId)) - .index(Index::create() - .name("idx_refresh_tokens_token_hash") - .col(RefreshTokens::TokenHash)) + .index( + Index::create() + .name("idx_refresh_tokens_family_id") + .col(RefreshTokens::FamilyId), + ) + .index( + Index::create() + .name("idx_refresh_tokens_player_id") + .col(RefreshTokens::PlayerId), + ) + .index( + Index::create() + .name("idx_refresh_tokens_token_hash") + .col(RefreshTokens::TokenHash), + ) .to_owned(), ) .await diff --git a/backend/modules/db/src/bin/seeder.rs b/backend/modules/db/src/bin/seeder.rs index eca91eb8..c5460722 100644 --- a/backend/modules/db/src/bin/seeder.rs +++ b/backend/modules/db/src/bin/seeder.rs @@ -1,13 +1,13 @@ +use chrono::{Duration, Utc}; +use db_entity::game::{GameVariant, ResultSide}; // Added imports use db_entity::prelude::*; -use db_entity::{player, game}; -use db_entity::game::{ResultSide, GameVariant}; // Added imports -use sea_orm::{*, prelude::*}; -use std::env; +use db_entity::{game, player}; use dotenv::dotenv; use rand::seq::SliceRandom; use rand::Rng; -use chrono::{Utc, Duration}; +use sea_orm::{prelude::*, *}; use serde_json::json; +use std::env; const NUM_PLAYERS: usize = 100; const NUM_GAMES: usize = 5000; @@ -25,31 +25,34 @@ async fn main() -> Result<(), DbErr> { // Use execute_unprepared for TRUNCATE as it's not directly supported by query builder // Make sure the schema is correct if not using the default 'public' // Using CASCADE to handle foreign keys if necessary - db.execute_unprepared("TRUNCATE TABLE smdb.game, smdb.player CASCADE;").await?; + db.execute_unprepared("TRUNCATE TABLE smdb.game, smdb.player CASCADE;") + .await?; println!("Existing data cleared."); println!("Seeding database..."); // --- Seed Players --- println!("Seeding {} players...", NUM_PLAYERS); - let models: Vec = (0..NUM_PLAYERS).map(|i| { - let player_id = Uuid::new_v4(); - player::ActiveModel { - id: Set(player_id), - username: Set(format!("Player_{}", i + 1)), - email: Set(format!("player{}@example.com", i + 1)), - password_hash: Set(b"dummy_hash".to_vec()), - biography: Set(format!("Biography for Player {}", i + 1)), - country: Set("USA".to_string()), - flair: Set("GM".to_string()), - real_name: Set(format!("Real Name {}", i + 1)), - location: Set(Some("New York, NY".to_string())), - fide_rating: Set(Some(rand::thread_rng().gen_range(800..2800))), - social_links: Set(Some(vec!["http://twitter.com/player".to_string()])), - is_enabled: Set(true), - ..Default::default() - } - }).collect(); + let models: Vec = (0..NUM_PLAYERS) + .map(|i| { + let player_id = Uuid::new_v4(); + player::ActiveModel { + id: Set(player_id), + username: Set(format!("Player_{}", i + 1)), + email: Set(format!("player{}@example.com", i + 1)), + password_hash: Set(b"dummy_hash".to_vec()), + biography: Set(format!("Biography for Player {}", i + 1)), + country: Set("USA".to_string()), + flair: Set("GM".to_string()), + real_name: Set(format!("Real Name {}", i + 1)), + location: Set(Some("New York, NY".to_string())), + fide_rating: Set(Some(rand::thread_rng().gen_range(800..2800))), + social_links: Set(Some(vec!["http://twitter.com/player".to_string()])), + is_enabled: Set(true), + ..Default::default() + } + }) + .collect(); // Extract player IDs before inserting for game seeding let player_ids: Vec = models.iter().map(|m| m.id.clone().unwrap()).collect(); @@ -59,22 +62,22 @@ async fn main() -> Result<(), DbErr> { // --- Seed Games --- let mut rng = rand::thread_rng(); - + // We will generate random variants/results inside the loop or define vectors with new Enum variants - // But main's loop uses match blocks. We can stick to match blocks or arrays. + // But main's loop uses match blocks. We can stick to match blocks or arrays. // Arrays are cleaner. - let variants = vec![ - GameVariant::Standard, - GameVariant::Chess960, - GameVariant::ThreeCheck, - GameVariant::Blitz, - GameVariant::Rapid, - GameVariant::Classical + let variants = [ + GameVariant::Standard, + GameVariant::Chess960, + GameVariant::ThreeCheck, + GameVariant::Blitz, + GameVariant::Rapid, + GameVariant::Classical, ]; - let results = vec![ - ResultSide::WhiteWins, - ResultSide::BlackWins, - ResultSide::Draw + let results = [ + ResultSide::WhiteWins, + ResultSide::BlackWins, + ResultSide::Draw, ]; println!("Seeding {} games...", NUM_GAMES); @@ -82,7 +85,8 @@ async fn main() -> Result<(), DbErr> { let white_player_id = *player_ids.choose(&mut rng).unwrap(); let black_player_id = loop { let id = *player_ids.choose(&mut rng).unwrap(); - if id != white_player_id { // Ensure players are different + if id != white_player_id { + // Ensure players are different break id; } }; @@ -116,4 +120,4 @@ async fn main() -> Result<(), DbErr> { println!("Database seeding complete!"); Ok(()) -} \ No newline at end of file +} diff --git a/backend/modules/db/src/db.rs b/backend/modules/db/src/db.rs index c8457b2c..151ce9c8 100644 --- a/backend/modules/db/src/db.rs +++ b/backend/modules/db/src/db.rs @@ -1,3 +1,4 @@ +#[allow(clippy::module_inception)] pub mod db { use sea_orm::{ConnectOptions, Database, DatabaseConnection}; @@ -8,10 +9,7 @@ pub mod db { ) .to_owned(); - let db: DatabaseConnection = - Database::connect(connect_options) - .await - .unwrap(); + let db: DatabaseConnection = Database::connect(connect_options).await.unwrap(); db } diff --git a/backend/modules/db/src/lib.rs b/backend/modules/db/src/lib.rs index 2ae81dc4..726fd11c 100644 --- a/backend/modules/db/src/lib.rs +++ b/backend/modules/db/src/lib.rs @@ -23,11 +23,13 @@ mod tests { ), false, ), - _ => (format!(""), false) + _ => (format!(""), false), }; - - let result = db.query_one(Statement::from_string(db_backend, query)).await?; - + + let result = db + .query_one(Statement::from_string(db_backend, query)) + .await?; + if is_count { Ok(result .map(|row| row.try_get_by_index::(0)) @@ -41,7 +43,7 @@ mod tests { .unwrap_or(false)) } } - + #[async_std::test] async fn test_table_exists() -> Result<(), DbErr> { if std::env::var("DATABASE_URL").is_err() { @@ -49,20 +51,20 @@ mod tests { } let db = get_db().await; - + assert!( table_exists(&db, DATABASE_NAME).await?, - "Table '{}' should exist", + "Table '{}' should exist", DATABASE_NAME ); - + Ok(()) } async fn get_column_type( - db: &DbConn, - table_name: &str, - column_name: &str + db: &DbConn, + table_name: &str, + column_name: &str, ) -> Result, DbErr> { let db_backend = db.get_database_backend(); let query = match db_backend { @@ -71,16 +73,15 @@ mod tests { WHERE table_name = '{}' AND column_name = '{}'", table_name, column_name ), - _ => format!("") + _ => format!(""), }; - + db.query_one(Statement::from_string(db_backend, query)) .await? .map(|row| row.try_get_by_index::(0)) .transpose() } - #[async_std::test] async fn accurate_column_types() -> Result<(), DbErr> { if std::env::var("DATABASE_URL").is_err() { @@ -90,20 +91,20 @@ mod tests { let db = get_db().await; let columns_and_types = HashMap::from([ - ("id","uuid"), - ("username","character varying"), - ("email","character varying"), - ("password_hash","bytea"), - ("biography","text"), - ("country","character varying"), - ("flair","character varying"), - ("real_name","character varying"), - ("location","character varying"), - ("fide_rating","integer"), - ("social_links","ARRAY") + ("id", "uuid"), + ("username", "character varying"), + ("email", "character varying"), + ("password_hash", "bytea"), + ("biography", "text"), + ("country", "character varying"), + ("flair", "character varying"), + ("real_name", "character varying"), + ("location", "character varying"), + ("fide_rating", "integer"), + ("social_links", "ARRAY"), ]); - for (column, colunn_type) in columns_and_types.iter(){ + for (column, colunn_type) in columns_and_types.iter() { assert_eq!( get_column_type(&db, DATABASE_NAME, column).await?, Some(colunn_type.to_string()) diff --git a/backend/modules/dto/src/ai.rs b/backend/modules/dto/src/ai.rs index ac5a1b57..ebbdf937 100644 --- a/backend/modules/dto/src/ai.rs +++ b/backend/modules/dto/src/ai.rs @@ -1,30 +1,44 @@ -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use validator::Validate; use once_cell::sync::Lazy; use regex::Regex; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::{Validate, ValidationError}; -// Define a regex for validating FEN chess position notation -static FEN_REGEX: Lazy = Lazy::new(|| { +static FEN_STRUCTURE: Lazy = Lazy::new(|| { Regex::new( - r"^(?=\S*K)(?=\S*k)([rnbqkpRNBQKP1-8]+/){7}[rnbqkpRNBQKP1-8]+\s[bw]\s(-|[KQkq]+)\s(-|[a-h][36])\s\d+\s\d+$" - ).unwrap() + r"^([rnbqkpRNBQKP1-8]+/){7}[rnbqkpRNBQKP1-8]+\s[bw]\s(-|[KQkq]+)\s(-|[a-h][36])\s\d+\s\d+$", + ) + .unwrap() }); +fn validate_fen(fen: &str) -> Result<(), ValidationError> { + if !FEN_STRUCTURE.is_match(fen) { + return Err(ValidationError::new("Must be a valid FEN string")); + } + if !fen.contains('K') { + return Err(ValidationError::new("FEN must contain a white king")); + } + if !fen.contains('k') { + return Err(ValidationError::new("FEN must contain a black king")); + } + Ok(()) +} + #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AiSuggestionRequest { - #[validate(regex( - path = "FEN_REGEX", - message = "Must be a valid FEN string in format: [piece placement] [active color] [castling] [en passant] [halfmove clock] [fullmove number]" - ))] + #[validate(custom(function = "validate_fen"))] #[schema(example = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")] pub fen: String, - + #[validate(range(min = 1, max = 20, message = "Depth must be between 1 and 20"))] #[schema(example = 10)] pub depth: Option, - - #[validate(range(min = 1000, max = 60000, message = "Time limit must be between 1 and 60 seconds"))] + + #[validate(range( + min = 1000, + max = 60000, + message = "Time limit must be between 1 and 60 seconds" + ))] #[schema(example = 5000)] pub time_limit_ms: Option, } @@ -33,28 +47,25 @@ pub struct AiSuggestionRequest { pub struct AiSuggestionResponse { #[schema(example = "e2e4")] pub best_move: String, - + #[schema(example = 0.3)] pub evaluation: f32, - + #[schema(example = 12)] pub depth: u8, - + pub principal_variation: Vec, - + #[schema(example = 2345)] pub computation_time_ms: u32, } #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct PositionAnalysisRequest { - #[validate(regex( - path = "FEN_REGEX", - message = "Must be a valid FEN string in format: [piece placement] [active color] [castling] [en passant] [halfmove clock] [fullmove number]" - ))] + #[validate(custom(function = "validate_fen"))] #[schema(example = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")] pub fen: String, - + #[validate(range(min = 1, max = 30, message = "Depth must be between 1 and 30"))] #[schema(example = 15)] pub depth: u8, @@ -64,11 +75,11 @@ pub struct PositionAnalysisRequest { pub struct PositionAnalysisResponse { #[schema(example = 0.3)] pub evaluation: f32, - + pub best_line: Vec, - + pub alternatives: Vec, - + #[schema(example = "Open Game")] pub position_type: String, } @@ -77,7 +88,7 @@ pub struct PositionAnalysisResponse { pub struct AlternativeMove { #[schema(example = "e2e4")] pub chess_move: String, - + #[schema(example = 0.25)] pub evaluation: f32, } diff --git a/backend/modules/dto/src/auth.rs b/backend/modules/dto/src/auth.rs index db9c71f4..54f7e0ae 100644 --- a/backend/modules/dto/src/auth.rs +++ b/backend/modules/dto/src/auth.rs @@ -1,11 +1,15 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use validator::Validate; use uuid::Uuid; +use validator::Validate; #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct RegisterRequest { - #[validate(length(min = 3, max = 32, message = "Username must be between 3 and 32 characters"))] + #[validate(length( + min = 3, + max = 32, + message = "Username must be between 3 and 32 characters" + ))] #[schema(example = "chess_master")] pub username: String, @@ -72,10 +76,10 @@ pub struct ErrorResponse { pub struct UserInfo { #[schema(value_type = String, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174000")] pub id: Uuid, - + #[schema(example = "chess_master")] pub username: String, - + #[schema(example = "chess@example.com")] pub email: String, } @@ -84,13 +88,13 @@ pub struct UserInfo { pub struct TokenResponse { #[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")] pub access_token: String, - + #[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")] pub refresh_token: String, - + #[schema(example = "Bearer")] pub token_type: String, - + #[schema(example = 3600)] pub expires_in: i32, } @@ -99,13 +103,13 @@ pub struct TokenResponse { pub struct RefreshResponse { #[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")] pub access_token: String, - + #[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")] pub refresh_token: String, - + #[schema(example = "Bearer")] pub token_type: String, - + #[schema(example = 3600)] pub expires_in: i32, } diff --git a/backend/modules/dto/src/games.rs b/backend/modules/dto/src/games.rs index c5aa362d..e9820b89 100644 --- a/backend/modules/dto/src/games.rs +++ b/backend/modules/dto/src/games.rs @@ -1,15 +1,14 @@ +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use regex::Regex; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use validator::{Validate, ValidationError}; -use chrono::{DateTime, Utc}; -use once_cell::sync::Lazy; -use regex::Regex; // Define a regex for validating chess moves in algebraic notation -static CHESS_MOVE_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"^[a-h][1-8][a-h][1-8][qrbnQRBN]?$").unwrap() -}); +static CHESS_MOVE_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^[a-h][1-8][a-h][1-8][qrbnQRBN]?$").unwrap()); #[derive(Debug, Serialize, Deserialize, ToSchema)] pub enum PlayerColor { @@ -21,7 +20,6 @@ pub enum PlayerColor { Random, } - #[derive(Debug, Serialize, Deserialize, ToSchema)] pub enum GameStatus { #[serde(rename = "waiting")] @@ -48,12 +46,20 @@ pub enum GameResult { #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct CreateGameRequest { - #[validate(range(min = 60, max = 7200, message = "Time control must be between 1 minute and 2 hours"))] + #[validate(range( + min = 60, + max = 7200, + message = "Time control must be between 1 minute and 2 hours" + ))] pub time_control: i32, - - #[validate(range(min = 0, max = 60, message = "Increment must be between 0 and 60 seconds"))] + + #[validate(range( + min = 0, + max = 60, + message = "Increment must be between 0 and 60 seconds" + ))] pub increment: i32, - + pub player_color: Option, pub opponent_id: Option, } @@ -62,31 +68,31 @@ pub struct CreateGameRequest { pub struct GameDisplayDTO { #[schema(value_type = String, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174000")] pub id: Uuid, - + #[schema(value_type = String, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174001")] pub white_player_id: Uuid, - + #[schema(value_type = Option, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174002")] pub black_player_id: Option, - + pub status: GameStatus, pub result: GameResult, - + #[schema(example = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")] pub current_fen: String, - + pub move_history: Vec, pub time_control: i32, pub increment: i32, pub white_time_remaining: i32, pub black_time_remaining: i32, - + #[schema(value_type = String, format = "date-time")] pub created_at: DateTime, - + #[schema(value_type = Option, format = "date-time")] pub started_at: Option>, - + #[schema(value_type = String, format = "date-time")] pub updated_at: DateTime, } @@ -120,26 +126,34 @@ pub fn validate_uuid(uuid: &Uuid) -> Result<(), ValidationError> { pub struct ListGamesQuery { #[schema(example = "waiting")] pub status: Option, - + #[schema(value_type = Option, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174000")] pub player_id: Option, - + #[schema(default = 1, example = 1)] /// Deprecated: Use cursor-based pagination pub page: Option, - + #[schema(default = 10, example = 10)] pub limit: Option, - #[schema(example = "MjAyNS0wNS0zMVQxMDowMDowMC4wMDAwMDBaLDEyM2U0NTY3LWU4OWItMTJkMy1hNDU2LTQyNjYxNDE3NDAwMA==")] + #[schema( + example = "MjAyNS0wNS0zMVQxMDowMDowMC4wMDAwMDBaLDEyM2U0NTY3LWU4OWItMTJkMy1hNDU2LTQyNjYxNDE3NDAwMA==" + )] pub cursor: Option, } /// Request body for importing a game from PGN format #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct ImportGameRequest { - #[validate(length(min = 10, max = 50000, message = "PGN must be between 10 and 50000 characters"))] - #[schema(example = "[White \"Magnus Carlsen\"]\n[Black \"Hikaru Nakamura\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 1-0")] + #[validate(length( + min = 10, + max = 50000, + message = "PGN must be between 10 and 50000 characters" + ))] + #[schema( + example = "[White \"Magnus Carlsen\"]\n[Black \"Hikaru Nakamura\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 1-0" + )] pub pgn: String, } @@ -147,25 +161,25 @@ pub struct ImportGameRequest { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ImportGameResponse { pub success: bool, - + #[schema(value_type = Option, format = "uuid")] pub game_id: Option, - + #[schema(example = "Magnus Carlsen")] pub white_player: String, - + #[schema(example = "Hikaru Nakamura")] pub black_player: String, - + #[schema(example = "white_win")] pub result: String, - + #[schema(example = 42)] pub move_count: usize, - + #[schema(example = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")] pub final_fen: Option, - + pub error: Option, } @@ -174,7 +188,7 @@ pub struct ImportGameResponse { pub struct CompleteGameRequest { #[schema(example = "white_wins")] pub result: String, - + #[validate(range(min = 16, max = 64, message = "K-factor must be between 16 and 64"))] #[schema(default = 32, example = 32)] pub k_factor: Option, @@ -184,24 +198,24 @@ pub struct CompleteGameRequest { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CompleteGameResponse { pub success: bool, - + #[schema(value_type = String, format = "uuid")] pub game_id: Uuid, - + #[schema(example = "white_wins")] pub result: String, - + #[schema(example = 1532)] pub white_new_rating: i32, - + #[schema(example = 1468)] pub black_new_rating: i32, - + #[schema(example = 16)] pub rating_change_white: i32, - + #[schema(example = -16)] pub rating_change_black: i32, - + pub error: Option, } diff --git a/backend/modules/dto/src/lib.rs b/backend/modules/dto/src/lib.rs index 5a2a2503..922ee487 100644 --- a/backend/modules/dto/src/lib.rs +++ b/backend/modules/dto/src/lib.rs @@ -1,5 +1,5 @@ +pub mod ai; +pub mod auth; +pub mod games; pub mod players; pub mod responses; -pub mod games; -pub mod auth; -pub mod ai; \ No newline at end of file diff --git a/backend/modules/dto/src/players.rs b/backend/modules/dto/src/players.rs index 99d99925..0e3078a6 100644 --- a/backend/modules/dto/src/players.rs +++ b/backend/modules/dto/src/players.rs @@ -39,8 +39,8 @@ impl NewPlayer { Self { username: format!("Player {}", rnd), email: format!("player{}@gmail.com", rnd), - password: format!("PasswordIsVeryStrong"), - real_name: format!("A new player"), + password: "PasswordIsVeryStrong".to_string(), + real_name: "A new player".to_string(), } } @@ -48,18 +48,18 @@ impl NewPlayer { let rnd: i32 = rand::random(); let mut username = format!("Player {}", rnd); let mut email = format!("player{}@gmail.com", rnd); - let mut password = format!("PasswordIsVeryStrong"); + let mut password = "PasswordIsVeryStrong".to_string(); match invalid_choice { - InvalidPlayer::Username => username = format!("1"), - InvalidPlayer::Password => password = format!("pswrd"), - InvalidPlayer::Email => email = format!("mail"), + InvalidPlayer::Username => username = "1".to_string(), + InvalidPlayer::Password => password = "pswrd".to_string(), + InvalidPlayer::Email => email = "mail".to_string(), } Self { username, email, password, - real_name: format!("A new player"), + real_name: "A new player".to_string(), } } } diff --git a/backend/modules/dto/src/responses.rs b/backend/modules/dto/src/responses.rs index 51f53e8b..447de90a 100644 --- a/backend/modules/dto/src/responses.rs +++ b/backend/modules/dto/src/responses.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use serde_json::json; use utoipa::ToSchema; use validator::Validate; @@ -40,10 +39,10 @@ pub struct PlayerUpdated { } #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] -pub struct PlayerDeleted{ +pub struct PlayerDeleted { #[schema(example = "Player deleted")] - pub message: String, - pub body: DeletedBody + pub message: String, + pub body: DeletedBody, } #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] diff --git a/backend/modules/engine/src/parser.rs b/backend/modules/engine/src/parser.rs index 6532f5ae..25edb451 100644 --- a/backend/modules/engine/src/parser.rs +++ b/backend/modules/engine/src/parser.rs @@ -1,4 +1,4 @@ -use crate::{EngineResult}; +use crate::EngineResult; pub fn parse_uci_line(line: &str) -> Option { let parts: Vec<&str> = line.split_whitespace().collect(); @@ -38,7 +38,7 @@ pub fn parse_uci_line(line: &str) -> Option { let mut score_cp = None; let mut score_mate = None; let mut pv = Vec::new(); - + let mut i = 1; while i < parts.len() { match parts[i] { @@ -46,7 +46,9 @@ pub fn parse_uci_line(line: &str) -> Option { if i + 1 < parts.len() { depth = parts[i + 1].parse::().ok(); i += 2; - } else { i += 1; } + } else { + i += 1; + } } "score" => { if i + 2 < parts.len() { @@ -59,9 +61,13 @@ pub fn parse_uci_line(line: &str) -> Option { score_mate = parts[i + 2].parse::().ok(); i += 3; } - _ => { i += 1; } + _ => { + i += 1; + } } - } else { i += 1; } + } else { + i += 1; + } } "pv" => { i += 1; @@ -70,10 +76,17 @@ pub fn parse_uci_line(line: &str) -> Option { i += 1; } } - _ => { i += 1; } + _ => { + i += 1; + } } } - Some(UciMessage::Info { depth, score_cp, score_mate, pv }) + Some(UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + }) } _ => Some(UciMessage::Unknown(line.to_string())), } @@ -85,8 +98,16 @@ pub enum UciMessage { IdAuthor(String), UciOk, ReadyOk, - BestMove { best_move: String, ponder: Option }, - Info { depth: Option, score_cp: Option, score_mate: Option, pv: Vec }, + BestMove { + best_move: String, + ponder: Option, + }, + Info { + depth: Option, + score_cp: Option, + score_mate: Option, + pv: Vec, + }, Unknown(String), } @@ -134,7 +155,13 @@ mod tests { #[test] fn test_parse_info() { let msg = parse_uci_line("info depth 12 score cp 35 pv e2e4 e7e5 Ng1f3").unwrap(); - if let UciMessage::Info { depth, score_cp, score_mate, pv } = msg { + if let UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + } = msg + { assert_eq!(depth, Some(12)); assert_eq!(score_cp, Some(35)); assert_eq!(score_mate, None); @@ -147,7 +174,13 @@ mod tests { #[test] fn test_parse_info_mate() { let msg = parse_uci_line("info depth 12 score mate 3 pv e2e4 e7e5 Ng1f3").unwrap(); - if let UciMessage::Info { depth, score_cp, score_mate, pv } = msg { + if let UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + } = msg + { assert_eq!(depth, Some(12)); assert_eq!(score_cp, None); assert_eq!(score_mate, Some(3)); diff --git a/backend/modules/engine/src/process.rs b/backend/modules/engine/src/process.rs index 8f690b91..0c8dacb7 100644 --- a/backend/modules/engine/src/process.rs +++ b/backend/modules/engine/src/process.rs @@ -1,10 +1,10 @@ -use tokio::process::{Command, Child}; -use tokio::io::{BufReader, AsyncBufReadExt, AsyncWriteExt}; -use std::process::Stdio; -use async_trait::async_trait; +use crate::parser::{UciMessage, parse_uci_line}; use crate::{Engine, EngineError, EngineResult, GoParams}; -use crate::parser::{parse_uci_line, UciMessage}; +use async_trait::async_trait; +use std::process::Stdio; use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; use tokio::sync::Mutex; pub struct ProcessEngine { @@ -33,7 +33,7 @@ impl ProcessEngine { // Initialize UCI engine.send_command("uci").await?; - + // Wait for uciok with 5-second timeout tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { @@ -43,13 +43,17 @@ impl ProcessEngine { } } Ok::<(), EngineError>(()) - }).await.map_err(|_| EngineError::Timeout)??; + }) + .await + .map_err(|_| EngineError::Timeout)??; Ok(engine) } async fn send_command(&mut self, cmd: &str) -> Result<(), EngineError> { - self.stdin.write_all(format!("{}\n", cmd).as_bytes()).await?; + self.stdin + .write_all(format!("{}\n", cmd).as_bytes()) + .await?; self.stdin.flush().await?; Ok(()) } @@ -75,11 +79,14 @@ impl Engine for ProcessEngine { if let Some(time) = params.time_limit_ms { cmd.push_str(&format!(" movetime {}", time)); } - + self.send_command(&cmd).await?; let mut last_info = None; - let timeout_duration = params.time_limit_ms.map(|t| std::time::Duration::from_millis(t as u64 + 1000)).unwrap_or(std::time::Duration::from_secs(30)); + let timeout_duration = params + .time_limit_ms + .map(|t| std::time::Duration::from_millis(t as u64 + 1000)) + .unwrap_or(std::time::Duration::from_secs(30)); let result = tokio::time::timeout(timeout_duration, async { loop { @@ -92,20 +99,37 @@ impl Engine for ProcessEngine { depth: None, principal_variation: Vec::new(), }; - if let Some(UciMessage::Info { depth, score_cp, score_mate: _, pv }) = last_info.clone() { + if let Some(UciMessage::Info { + depth, + score_cp, + score_mate: _, + pv, + }) = last_info.clone() + { result.depth = depth; result.evaluation = score_cp.map(|cp| cp as f32 / 100.0); result.principal_variation = pv; } return Ok(result); } - Some(UciMessage::Info { depth, score_cp, score_mate, pv }) => { - last_info = Some(UciMessage::Info { depth, score_cp, score_mate, pv }); + Some(UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + }) => { + last_info = Some(UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + }); } _ => {} } } - }).await; + }) + .await; match result { Ok(res) => res, @@ -122,15 +146,31 @@ impl Engine for ProcessEngine { depth: None, principal_variation: Vec::new(), }; - if let Some(UciMessage::Info { depth, score_cp, score_mate: _, pv }) = last_info { + if let Some(UciMessage::Info { + depth, + score_cp, + score_mate: _, + pv, + }) = last_info + { result.depth = depth; result.evaluation = score_cp.map(|cp| cp as f32 / 100.0); result.principal_variation = pv; } return Err(EngineError::Timeout); } - Some(UciMessage::Info { depth, score_cp, score_mate, pv }) => { - last_info = Some(UciMessage::Info { depth, score_cp, score_mate, pv }); + Some(UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + }) => { + last_info = Some(UciMessage::Info { + depth, + score_cp, + score_mate, + pv, + }); } _ => {} } @@ -156,7 +196,8 @@ impl Engine for ProcessEngine { return Ok(true); } } - }).await; + }) + .await; match result { Ok(res) => res, diff --git a/backend/modules/error/src/error.rs b/backend/modules/error/src/error.rs index 9443267b..4df450f1 100644 --- a/backend/modules/error/src/error.rs +++ b/backend/modules/error/src/error.rs @@ -1,4 +1,4 @@ -use actix_web::{Error, HttpRequest, HttpResponse, error::JsonPayloadError}; +use actix_web::{error::JsonPayloadError, Error, HttpRequest, HttpResponse}; use argon2::password_hash::Error as Argon2HashError; use core::fmt; use sea_orm::DbErr; @@ -55,10 +55,10 @@ impl fmt::Display for ApiError { match self { ApiError::InvalidCredentials => write!(f, "Invalid credentials"), ApiError::NotFound(v) => write!(f, "{} not found", v), - ApiError::DatabaseError(err) => write!(f, "Database error {}", err.to_string()), + ApiError::DatabaseError(err) => write!(f, "Database error {}", err), ApiError::ValidationError(errs) => { let mut s = String::new(); - for (_, error_kind) in errs.errors() { + for error_kind in errs.errors().values() { match error_kind { ValidationErrorsKind::Field(field) => { if let Some(message) = &field[0].message { @@ -69,14 +69,17 @@ impl fmt::Display for ApiError { } ValidationErrorsKind::Struct(strct) => { strct.errors().iter().for_each(|(field_name, error_kind)| { - s.push_str(&parse_validation_error(error_kind, &field_name)) + s.push_str(&parse_validation_error(error_kind, field_name)) }) } ValidationErrorsKind::List(tree) => { - tree.iter().for_each(|(_, box_errors)|{ - box_errors.errors().iter().for_each(|(field_name, error_kind)|{ - s.push_str(&parse_validation_error(error_kind, &field_name)) - }) + tree.iter().for_each(|(_, box_errors)| { + box_errors + .errors() + .iter() + .for_each(|(field_name, error_kind)| { + s.push_str(&parse_validation_error(error_kind, field_name)) + }) }); } } @@ -84,13 +87,21 @@ impl fmt::Display for ApiError { write!(f, "{}", s) } ApiError::PasswordHashError(err) => { - write!(f, "Unable to hash password: {}", err.to_string()) + write!(f, "Unable to hash password: {}", err) } ApiError::PgnParseError(msg) => { write!(f, "Invalid PGN format: {}", msg) } - ApiError::IllegalMoveError { move_number, move_text, reason } => { - write!(f, "Illegal move at move {}: '{}' - {}", move_number, move_text, reason) + ApiError::IllegalMoveError { + move_number, + move_text, + reason, + } => { + write!( + f, + "Illegal move at move {}: '{}' - {}", + move_number, move_text, reason + ) } ApiError::BadRequest(msg) => write!(f, "{}", msg), ApiError::Forbidden(msg) => write!(f, "{}", msg), diff --git a/backend/modules/error/src/lib.rs b/backend/modules/error/src/lib.rs index a281f3e6..a91e7351 100644 --- a/backend/modules/error/src/lib.rs +++ b/backend/modules/error/src/lib.rs @@ -1 +1 @@ -pub mod error; \ No newline at end of file +pub mod error; diff --git a/backend/modules/matchmaking/elo.rs b/backend/modules/matchmaking/elo.rs index 93fd237b..53f51829 100644 --- a/backend/modules/matchmaking/elo.rs +++ b/backend/modules/matchmaking/elo.rs @@ -89,4 +89,3 @@ mod tests { assert_eq!(l, 0); } } - diff --git a/backend/modules/matchmaking/mod.rs b/backend/modules/matchmaking/mod.rs index 82754111..f9fb563c 100644 --- a/backend/modules/matchmaking/mod.rs +++ b/backend/modules/matchmaking/mod.rs @@ -1,10 +1,10 @@ +pub mod elo; pub mod models; +pub mod redis; pub mod routes; pub mod service; -pub mod redis; -pub mod elo; +pub use elo::*; pub use models::*; pub use routes::*; pub use service::*; -pub use elo::*; \ No newline at end of file diff --git a/backend/modules/matchmaking/models.rs b/backend/modules/matchmaking/models.rs index 6b247067..5404e899 100644 --- a/backend/modules/matchmaking/models.rs +++ b/backend/modules/matchmaking/models.rs @@ -1,8 +1,7 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::time::Duration; use uuid::Uuid; -use chrono::{DateTime, Utc}; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum MatchType { @@ -33,7 +32,7 @@ impl TimeControl { "bullet" => 60, "blitz" => 180, "rapid" => 480, - "standard" | _ => 600, + _ => 600, } } @@ -48,7 +47,6 @@ impl Default for TimeControl { } } - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Player { pub wallet_address: String, @@ -77,7 +75,6 @@ impl MatchRequest { } } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Match { pub id: Uuid, diff --git a/backend/modules/matchmaking/routes.rs b/backend/modules/matchmaking/routes.rs index db24d8fd..0fb22ed8 100644 --- a/backend/modules/matchmaking/routes.rs +++ b/backend/modules/matchmaking/routes.rs @@ -141,7 +141,10 @@ async fn accept_invite( join_time: Utc::now(), }; - match service.accept_private_invite(req.inviter_request_id, player).await { + match service + .accept_private_invite(req.inviter_request_id, player) + .await + { Ok(Some(response)) => HttpResponse::Ok().json(response), Ok(None) => HttpResponse::NotFound().json(serde_json::json!({ "status": "Invite not found" diff --git a/backend/modules/matchmaking/service.rs b/backend/modules/matchmaking/service.rs index 7c89b7b1..227a90a0 100644 --- a/backend/modules/matchmaking/service.rs +++ b/backend/modules/matchmaking/service.rs @@ -27,19 +27,14 @@ impl MatchmakingService { } } - async fn get_redis_connection( - &self, - ) -> Result { + async fn get_redis_connection(&self) -> Result { self.redis_pool .get() .await .map_err(|e| format!("Redis connection failed: {}", e)) } - pub async fn join_queue( - &self, - request: MatchRequest, - ) -> Result { + pub async fn join_queue(&self, request: MatchRequest) -> Result { let request_id = request.id; match request.match_type { @@ -65,8 +60,7 @@ impl MatchmakingService { }); } else { return Ok(MatchmakingResponse { - status: "Invalid private match request: missing invite address" - .to_string(), + status: "Invalid private match request: missing invite address".to_string(), match_id: None, request_id, }); @@ -106,7 +100,6 @@ impl MatchmakingService { Ok(()) } - async fn add_private_invite( &self, invite_address: &str, @@ -207,7 +200,6 @@ impl MatchmakingService { } Ok(None) - } pub async fn cancel_request(&self, request_id: Uuid) -> Result { @@ -274,10 +266,7 @@ impl MatchmakingService { Ok(false) } - pub async fn get_queue_status( - &self, - request_id: Uuid, - ) -> Result, String> { + pub async fn get_queue_status(&self, request_id: Uuid) -> Result, String> { let mut conn = self.get_redis_connection().await?; // Check rated queue @@ -434,7 +423,7 @@ impl MatchmakingService { .zpopmin(key, 1) .await .map_err(|e| format!("Redis ZPOPMIN failed: {}", e))?; - + let result = result.into_iter().next(); if let Some((member, _score)) = result { diff --git a/backend/modules/security/src/jwt.rs b/backend/modules/security/src/jwt.rs index 2f83c490..9c8e0157 100644 --- a/backend/modules/security/src/jwt.rs +++ b/backend/modules/security/src/jwt.rs @@ -1,14 +1,14 @@ use actix_web::{ + body::{BoxBody, MessageBody}, dev::{Service, ServiceRequest, ServiceResponse, Transform}, error::{Error, ErrorUnauthorized}, - body::{BoxBody, MessageBody}, HttpMessage, }; use futures_util::future::{ok, LocalBoxFuture, Ready}; -use std::task::{Context, Poll}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use std::rc::Rc; +use std::task::{Context, Poll}; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; @@ -46,7 +46,7 @@ pub enum TokenType { #[derive(Clone, Debug)] pub struct JwtService { pub secret_key: String, - expiration_time: usize, // in seconds + expiration_time: usize, // in seconds reconnect_expiration_time: usize, // in seconds (shorter for reconnect tokens) } @@ -61,7 +61,12 @@ impl JwtService { } /// Generate a new JWT access token for a user - pub fn generate_token(&self, user_id: i32, username: &str, player_id: Uuid) -> Result { + pub fn generate_token( + &self, + user_id: i32, + username: &str, + player_id: Uuid, + ) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -88,7 +93,13 @@ impl JwtService { } /// Generate a reconnection token for seamless WebSocket reconnection - pub fn generate_reconnect_token(&self, user_id: i32, username: &str, player_id: Uuid, session_id: &str) -> Result { + pub fn generate_reconnect_token( + &self, + user_id: i32, + username: &str, + player_id: Uuid, + session_id: &str, + ) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -127,11 +138,7 @@ impl JwtService { /// Extract token from Authorization header pub fn extract_token_from_header(auth_header: &str) -> Option { - if auth_header.starts_with("Bearer ") { - Some(auth_header[7..].to_string()) - } else { - None - } + auth_header.strip_prefix("Bearer ").map(|s| s.to_string()) } } @@ -219,28 +226,22 @@ where // Store claims in request extensions req.extensions_mut().insert(claims); let fut = self.service.call(req); - Box::pin(async move { + Box::pin(async move { let res = fut.await?; Ok(res.map_into_boxed_body()) }) } Err(_) => { - Box::pin(async move { - Err(ErrorUnauthorized("Invalid or expired token")) - }) + Box::pin( + async move { Err(ErrorUnauthorized("Invalid or expired token")) }, + ) } } } else { - Box::pin(async move { - Err(ErrorUnauthorized("Invalid authorization format")) - }) + Box::pin(async move { Err(ErrorUnauthorized("Invalid authorization format")) }) } } - None => { - Box::pin(async move { - Err(ErrorUnauthorized("Missing authorization header")) - }) - } + None => Box::pin(async move { Err(ErrorUnauthorized("Missing authorization header")) }), } } } diff --git a/backend/modules/security/src/lib.rs b/backend/modules/security/src/lib.rs index 714b18ce..26296569 100644 --- a/backend/modules/security/src/lib.rs +++ b/backend/modules/security/src/lib.rs @@ -1,5 +1,5 @@ pub mod jwt; pub mod token_service; -pub use jwt::{JwtAuthMiddleware, JwtService, Claims}; +pub use jwt::{Claims, JwtAuthMiddleware, JwtService}; pub use token_service::{TokenService, TokenServiceError}; diff --git a/backend/modules/security/src/token_service.rs b/backend/modules/security/src/token_service.rs index 94112333..dc557dd3 100644 --- a/backend/modules/security/src/token_service.rs +++ b/backend/modules/security/src/token_service.rs @@ -1,14 +1,14 @@ +use base64::Engine; use chrono::{Duration, Utc}; +use db_entity::refresh_token; use rand::Rng; +use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, Set, }; -use sea_orm::sea_query::Expr; use sha2::{Digest, Sha256}; use std::fmt; use uuid::Uuid; -use db_entity::refresh_token; -use base64::Engine; /// Errors that can occur during token operations #[derive(Debug)] @@ -46,7 +46,7 @@ pub struct TokenService; impl TokenService { /// Generate a new refresh token - /// + /// /// Returns a tuple of (plaintext_token, token_record) pub async fn generate_refresh_token( db: &DatabaseConnection, @@ -57,17 +57,17 @@ impl TokenService { // 1. Generate 32 random bytes let mut rng = rand::thread_rng(); let random_bytes: [u8; 32] = rng.gen(); - + // 2. Base64 encode for URL safety - let token = base64::engine::general_purpose::STANDARD.encode(&random_bytes); - + let token = base64::engine::general_purpose::STANDARD.encode(random_bytes); + // 3. SHA256 hash for storage let token_hash = Self::hash_token(&token); - + // 4. Calculate expiration let now = Utc::now(); let expires_at = now + Duration::days(ttl_days); - + // 5. Store in database let refresh_token = refresh_token::ActiveModel { id: Set(Uuid::new_v4()), @@ -79,14 +79,14 @@ impl TokenService { expires_at: Set(expires_at), is_revoked: Set(false), }; - + refresh_token.insert(db).await?; - + Ok(token) } /// Verify a refresh token and mark it as used - /// + /// /// Returns the family_id if valid, or an error if theft is detected pub async fn verify_and_mark_used( db: &DatabaseConnection, @@ -94,16 +94,16 @@ impl TokenService { player_id: i32, ) -> Result { let token_hash = Self::hash_token(token); - + // 1. Find the token record let token_record = refresh_token::Entity::find() .filter(refresh_token::Column::TokenHash.eq(&token_hash)) .filter(refresh_token::Column::PlayerId.eq(player_id)) .one(db) .await?; - + let token_record = token_record.ok_or(TokenServiceError::TokenNotFound)?; - + // 2. Check if already used (THEFT DETECTION!) if token_record.used_at.is_some() { // Token already used - this is token reuse! @@ -112,24 +112,24 @@ impl TokenService { Self::invalidate_token_family(db, family_id).await?; return Err(TokenServiceError::TokenReuseDetected); } - + // 3. Check if revoked if token_record.is_revoked { return Err(TokenServiceError::TokenInvalid); } - + // 4. Check if expired if token_record.expires_at < Utc::now() { return Err(TokenServiceError::TokenExpired); } - + // 5. Mark as used - update the record directly refresh_token::Entity::update_many() .col_expr(refresh_token::Column::UsedAt, Expr::value(Utc::now())) .filter(refresh_token::Column::Id.eq(token_record.id)) .exec(db) .await?; - + Ok(token_record.family_id) } @@ -143,7 +143,7 @@ impl TokenService { .filter(refresh_token::Column::FamilyId.eq(family_id)) .exec(db) .await?; - + Ok(()) } @@ -157,7 +157,7 @@ impl TokenService { .filter(refresh_token::Column::PlayerId.eq(player_id)) .exec(db) .await?; - + Ok(()) } diff --git a/backend/modules/service/src/engine_service.rs b/backend/modules/service/src/engine_service.rs index b0f81db8..748ce865 100644 --- a/backend/modules/service/src/engine_service.rs +++ b/backend/modules/service/src/engine_service.rs @@ -1,10 +1,11 @@ -use engine::{Engine, process::ProcessEngine, GoParams, EngineResult, EngineError}; +use engine::{process::ProcessEngine, Engine, EngineError, EngineResult, GoParams}; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use std::collections::HashMap; use uuid::Uuid; pub struct EngineService { + #[allow(dead_code)] engines: Arc>>>, engine_path: String, } @@ -17,26 +18,35 @@ impl EngineService { } } - pub async fn get_suggestion(&self, fen: &str, depth: Option, time_limit_ms: Option) -> Result { + pub async fn get_suggestion( + &self, + fen: &str, + depth: Option, + time_limit_ms: Option, + ) -> Result { // For now, we'll create a new engine instance for each request // In a real scenario, we might want to pool them let mut engine: ProcessEngine = ProcessEngine::new(&self.engine_path).await?; engine.is_ready().await?; engine.set_position(fen).await?; - + let params = GoParams { depth, time_limit_ms, search_moves: None, }; - + let result = engine.go(params).await?; engine.quit().await?; - + Ok(result) } - pub async fn analyze_position(&self, fen: &str, depth: u8) -> Result { + pub async fn analyze_position( + &self, + fen: &str, + depth: u8, + ) -> Result { self.get_suggestion(fen, Some(depth), None).await } } diff --git a/backend/modules/service/src/games.rs b/backend/modules/service/src/games.rs index 856dfda6..0fb69a46 100644 --- a/backend/modules/service/src/games.rs +++ b/backend/modules/service/src/games.rs @@ -1,16 +1,16 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chess::pgn::ValidatedGame; +use chess::{RatingConfig, RatingService}; +use chrono::{DateTime, TimeZone, Utc}; use db_entity::{game, prelude::Game}; +use dto::games::{CreateGameRequest, GameDisplayDTO, GameStatus, MakeMoveRequest}; +use error::error::ApiError; use sea_orm::{ - ColumnTrait, DbErr, EntityTrait, Order, QueryFilter, - QueryOrder, QuerySelect, ActiveModelTrait, Set, TransactionTrait, + ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect, + Set, TransactionTrait, }; use sea_orm::{Condition, DatabaseConnection}; use uuid::Uuid; -use chrono::{DateTime, Utc, TimeZone}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use dto::games::{GameStatus, CreateGameRequest, MakeMoveRequest, GameDisplayDTO}; -use error::error::ApiError; -use chess::pgn::ValidatedGame; -use chess::{RatingService, RatingConfig}; pub struct GameService; @@ -20,7 +20,9 @@ impl GameService { _creator_id: Uuid, _request: CreateGameRequest, ) -> Result { - Err(ApiError::NotImplemented("create_game not yet implemented".to_string())) + Err(ApiError::NotImplemented( + "create_game not yet implemented".to_string(), + )) } pub async fn get_game( @@ -36,7 +38,9 @@ impl GameService { _player_id: Uuid, _move_request: MakeMoveRequest, ) -> Result { - Err(ApiError::NotImplemented("make_move not yet implemented".to_string())) + Err(ApiError::NotImplemented( + "make_move not yet implemented".to_string(), + )) } pub async fn join_game( @@ -44,7 +48,9 @@ impl GameService { _game_id: Uuid, _player_id: Uuid, ) -> Result { - Err(ApiError::NotImplemented("join_game not yet implemented".to_string())) + Err(ApiError::NotImplemented( + "join_game not yet implemented".to_string(), + )) } pub async fn abandon_game( @@ -52,7 +58,9 @@ impl GameService { _game_id: Uuid, _player_id: Uuid, ) -> Result { - Err(ApiError::NotImplemented("abandon_game not yet implemented".to_string())) + Err(ApiError::NotImplemented( + "abandon_game not yet implemented".to_string(), + )) } pub async fn import_game( @@ -60,21 +68,23 @@ impl GameService { _importer_id: Uuid, _request: &ValidatedGame, ) -> Result { - Err(ApiError::NotImplemented("import_game not yet implemented".to_string())) + Err(ApiError::NotImplemented( + "import_game not yet implemented".to_string(), + )) } /// Complete a game with the given result and update player ratings - /// + /// /// # Arguments /// * `db` - Database connection /// * `game_id` - UUID of the game to complete /// * `result` - The final result of the game /// * `rating_config` - Optional rating configuration (uses default if None) - /// + /// /// # Returns /// * `Ok((white_new_rating, black_new_rating))` - New ratings for both players /// * `Err(ApiError)` - If game not found, already completed, or database error - /// + /// /// This method: /// 1. Updates the game result in a transaction /// 2. Calculates and updates player ratings atomically @@ -101,7 +111,9 @@ impl GameService { // Check if game is already completed if game_model.result.is_some() { let _ = txn.rollback().await; - return Err(ApiError::BadRequest("Game is already completed".to_string())); + return Err(ApiError::BadRequest( + "Game is already completed".to_string(), + )); } // Update game with result @@ -109,10 +121,14 @@ impl GameService { game_active_model.result = Set(Some(result.clone())); game_active_model.updated_at = Set(Utc::now().into()); - game_active_model.update(&txn).await.map_err(ApiError::from)?; + game_active_model + .update(&txn) + .await + .map_err(ApiError::from)?; // 2. Update player ratings using the rating service - let ratings_result = RatingService::update_ratings_in_transaction(&txn, game_id, &config).await; + let ratings_result = + RatingService::update_ratings_in_transaction(&txn, game_id, &config).await; match ratings_result { Ok(ratings) => { @@ -152,15 +168,15 @@ impl GameService { } /// List games with keyset pagination. - /// + /// /// # Arguments /// * `db` - Database connection /// * `cursor` - Optional cursor string (base64 encoded "timestamp,id") /// * `limit` - Number of items to return /// * `player_id` - Optional player ID filter (checks both white and black players) /// * `status` - Optional status filter (currently maps to result being not null for finished games, or specific status if column exists) - /// - /// Note: The current schema uses `result` to determine if a game is finished. + /// + /// Note: The current schema uses `result` to determine if a game is finished. /// Active games might have `result` as NULL (after our migration). pub async fn list_games( db: &DatabaseConnection, @@ -186,12 +202,12 @@ impl GameService { if let Some(s) = status { match s { GameStatus::Waiting | GameStatus::InProgress => { - // Active games: result is NULL - query = query.filter(game::Column::Result.is_null()); - }, + // Active games: result is NULL + query = query.filter(game::Column::Result.is_null()); + } GameStatus::Completed | GameStatus::Aborted => { // Finished games: result is NOT NULL - // Note: "Aborted" vs "Completed" might need distinguishing via ResultSide if we had it, + // Note: "Aborted" vs "Completed" might need distinguishing via ResultSide if we had it, // but for now we just check if it has a result. query = query.filter(game::Column::Result.is_not_null()); } @@ -217,25 +233,25 @@ impl GameService { // .add(game::Column::Id.lt(last_id)) // ) // ); - // Actually, SeaORM supports tuple comparison conveniently? + // Actually, SeaORM supports tuple comparison conveniently? // Not directly in the builder API widely in all versions, but the composite condition above is correct for (A, B) < (a, b) logic. // However, tuple comparison `(A, B) < (a, b)` logic is standard SQL but SeaORM DSL is explicit. - + // Constructing: (created_at, id) < (last_created_at, last_id) // Equivalent to: created_at < last_created_at OR (created_at = last_created_at AND id < last_id) (for DESC, DESC) // WAIT! For DESC sort, "next page" means values SMALLER than cursor? // Yes. Sorting DESC means newest first. Cursor is at some point. We want older stuff. // So we want `created_at < cursor.created_at`. // If created_at == cursor.created_at, then `id < cursor.id` (assuming ID also DESC). - + let condition = Condition::any() .add(game::Column::CreatedAt.lt(last_created_at)) .add( Condition::all() .add(game::Column::CreatedAt.eq(last_created_at)) - .add(game::Column::Id.lt(last_id)) + .add(game::Column::Id.lt(last_id)), ); - + query = query.filter(condition); } } @@ -251,7 +267,10 @@ impl GameService { // We have a next page games.truncate(limit as usize); if let Some(last_game) = games.last() { - next_cursor = Some(Self::encode_cursor(last_game.created_at.into(), last_game.id)); + next_cursor = Some(Self::encode_cursor( + last_game.created_at.into(), + last_game.id, + )); } } @@ -268,24 +287,26 @@ impl GameService { } fn decode_cursor(cursor: &str) -> Result<(DateTime, Uuid), String> { - let decoded_bytes = URL_SAFE_NO_PAD.decode(cursor) + let decoded_bytes = URL_SAFE_NO_PAD + .decode(cursor) .map_err(|_| "Invalid base64".to_string())?; - let raw = String::from_utf8(decoded_bytes) - .map_err(|_| "Invalid utf8".to_string())?; - + let raw = String::from_utf8(decoded_bytes).map_err(|_| "Invalid utf8".to_string())?; + // Split once let parts: Vec<&str> = raw.splitn(2, ",").collect(); if parts.len() != 2 { return Err("Invalid cursor format".to_string()); } - let ts_micros: i64 = parts[0].parse() + let ts_micros: i64 = parts[0] + .parse() .map_err(|_| "Invalid timestamp".to_string())?; - let id = Uuid::parse_str(parts[1]) - .map_err(|_| "Invalid UUID".to_string())?; + let id = Uuid::parse_str(parts[1]).map_err(|_| "Invalid UUID".to_string())?; - let timestamp = Utc.timestamp_micros(ts_micros).single() - .ok_or("Invalid timestamp value".to_string())?; + let timestamp = Utc + .timestamp_micros(ts_micros) + .single() + .ok_or("Invalid timestamp value".to_string())?; Ok((timestamp, id)) } @@ -294,23 +315,24 @@ impl GameService { #[cfg(test)] mod tests { use super::*; - use sea_orm::{MockDatabase, DbBackend}; use chrono::FixedOffset; + use sea_orm::{DbBackend, MockDatabase}; #[test] fn test_cursor_encoding_decoding() { let now = Utc::now(); let id = Uuid::new_v4(); - + let cursor = GameService::encode_cursor(now, id); - let (decoded_ts, decoded_id) = GameService::decode_cursor(&cursor).expect("Decoding failed"); - + let (decoded_ts, decoded_id) = + GameService::decode_cursor(&cursor).expect("Decoding failed"); + // Timestamp might lose precision if we are not careful, but we used timestamp_micros // We compare micros assert_eq!(decoded_ts.timestamp(), now.timestamp()); assert_eq!(decoded_id, id); } - + #[tokio::test] async fn test_list_games_query_structure() { // Create Mock Database to verify the generated SQL @@ -334,27 +356,21 @@ mod tests { }], ]) .into_connection(); - + let player_id = Uuid::new_v4(); - - let _result = GameService::list_games( - &db, - None, - 10, - Some(player_id), - None - ).await; - + + let _result = GameService::list_games(&db, None, 10, Some(player_id), None).await; + // Get transaction log to verify SQL let transaction_log = db.into_transaction_log(); - + // We expect one query assert_eq!(transaction_log.len(), 1); - + let log = &transaction_log[0]; let log_str = format!("{:?}", log); println!("Log: {}", log_str); - + // Verify SQL logic via Debug string (escaped quotes due to Debug format) // We expect filtering by player with table alias "game" assert!(log_str.contains(r#"\"game\".\"white_player\" = $1"#)); @@ -364,44 +380,38 @@ mod tests { // Verify Limit assert!(log_str.contains("LIMIT $3")); } - + #[tokio::test] async fn test_list_games_with_cursor() { let last_time = Utc::now(); let last_id = Uuid::new_v4(); let cursor = GameService::encode_cursor(last_time, last_id); - + let db = MockDatabase::new(DbBackend::Postgres) .append_query_results(vec![vec![game::Model { - id: Uuid::new_v4(), - white_player: Uuid::new_v4(), - black_player: Uuid::new_v4(), - fen: "fen".to_string(), - pgn: serde_json::json!({}), - result: None, - variant: db_entity::game::GameVariant::Standard, - started_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()), - duration_sec: 600, - created_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()), - updated_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()), - is_imported: false, - original_pgn: None, + id: Uuid::new_v4(), + white_player: Uuid::new_v4(), + black_player: Uuid::new_v4(), + fen: "fen".to_string(), + pgn: serde_json::json!({}), + result: None, + variant: db_entity::game::GameVariant::Standard, + started_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()), + duration_sec: 600, + created_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()), + updated_at: Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()), + is_imported: false, + original_pgn: None, }]]) .into_connection(); - - let _result = GameService::list_games( - &db, - Some(cursor), - 10, - None, - None - ).await; - + + let _result = GameService::list_games(&db, Some(cursor), 10, None, None).await; + let transaction_log = db.into_transaction_log(); let log = &transaction_log[0]; let log_str = format!("{:?}", log); println!("Log with cursor: {}", log_str); - + // Verify cursor condition: (created_at < ?) OR (created_at = ? AND id < ?) assert!(log_str.contains(r#"\"game\".\"created_at\" < $1"#)); assert!(log_str.contains(r#"\"game\".\"created_at\" = $2"#)); diff --git a/backend/modules/service/src/helper/mod.rs b/backend/modules/service/src/helper/mod.rs index 5b4e5bc7..c72e4b9f 100644 --- a/backend/modules/service/src/helper/mod.rs +++ b/backend/modules/service/src/helper/mod.rs @@ -1 +1 @@ -pub mod password; \ No newline at end of file +pub mod password; diff --git a/backend/modules/service/src/helper/password.rs b/backend/modules/service/src/helper/password.rs index a0aff8c6..f94e6169 100644 --- a/backend/modules/service/src/helper/password.rs +++ b/backend/modules/service/src/helper/password.rs @@ -1,4 +1,4 @@ -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use rand::rngs::OsRng; @@ -13,7 +13,7 @@ pub fn verify_password<'a>( password: &'a str, hashed_password: &'a str, ) -> Result<(), argon2::password_hash::Error> { - let password_hash = PasswordHash::new(&hashed_password)?; + let password_hash = PasswordHash::new(hashed_password)?; // Trait objects for algorithms to support let algs: &[&dyn PasswordVerifier] = &[&Argon2::default()]; diff --git a/backend/modules/st_core/src/endpoint.rs b/backend/modules/st_core/src/endpoint.rs index 5f4406aa..d1dd5295 100644 --- a/backend/modules/st_core/src/endpoint.rs +++ b/backend/modules/st_core/src/endpoint.rs @@ -1,6 +1,6 @@ +use crate::{AIMetadata, NFTMintRequest, NFTService}; use actix_web::{web, HttpResponse, Result}; use serde::{Deserialize, Serialize}; -use crate::{NFTService, AIMetadata, NFTMintRequest}; use utoipa::ToSchema; #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -30,9 +30,7 @@ pub struct MintNFTResponse { (status = 500, description = "Internal server error", body = MintNFTResponse) ) )] -pub async fn mint_nft( - request: web::Json, -) -> Result { +pub async fn mint_nft(request: web::Json) -> Result { // Get issuer account from environment or configuration let issuer_account = std::env::var("STELLAR_ISSUER_ACCOUNT") .unwrap_or_else(|_| "GAB35A2WLFSK64P6EWSGVFXZYU6E5K2INGTTLMDEDSIPYOH7NZVV6GIG".to_string()); @@ -72,18 +70,14 @@ pub async fn mint_nft( (status = 400, description = "Invalid metadata", body = serde_json::Value) ) )] -pub async fn format_ai_metadata( - request: web::Json, -) -> Result { +pub async fn format_ai_metadata(request: web::Json) -> Result { match NFTService::format_ai_metadata(request.into_inner()) { - Ok(formatted_metadata) => { - match NFTService::create_ipfs_metadata(&formatted_metadata) { - Ok(ipfs_metadata) => Ok(HttpResponse::Ok().json(ipfs_metadata)), - Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ - "success": false, - "error": e.to_string() - }))), - } + Ok(formatted_metadata) => match NFTService::create_ipfs_metadata(&formatted_metadata) { + Ok(ipfs_metadata) => Ok(HttpResponse::Ok().json(ipfs_metadata)), + Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": e.to_string() + }))), }, Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "success": false, @@ -109,20 +103,50 @@ pub async fn format_ai_metadata( (status = 400, description = "Invalid parameters", body = serde_json::Value) ) )] -pub async fn generate_stellar_toml( - query: web::Query, -) -> Result { +pub async fn generate_stellar_toml(query: web::Query) -> Result { let metadata = AIMetadata { - name: query.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), - description: query.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(), - url: query.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - issuer: query.get("issuer").and_then(|v| v.as_str()).unwrap_or("").to_string(), - code: query.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string(), + name: query + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + description: query + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + url: query + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + issuer: query + .get("issuer") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + code: query + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), attributes: None, - external_url: query.get("external_url").and_then(|v| v.as_str()).map(|s| s.to_string()), - image: query.get("image").and_then(|v| v.as_str()).map(|s| s.to_string()), - animation_url: query.get("animation_url").and_then(|v| v.as_str()).map(|s| s.to_string()), - youtube_url: query.get("youtube_url").and_then(|v| v.as_str()).map(|s| s.to_string()), + external_url: query + .get("external_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + image: query + .get("image") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + animation_url: query + .get("animation_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + youtube_url: query + .get("youtube_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), }; match NFTService::generate_stellar_toml(&metadata) { @@ -140,17 +164,8 @@ pub async fn generate_stellar_toml( pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/nft") - .service( - web::resource("/mint") - .route(web::post().to(mint_nft)) - ) - .service( - web::resource("/metadata/format") - .route(web::post().to(format_ai_metadata)) - ) - .service( - web::resource("/stellar-toml") - .route(web::get().to(generate_stellar_toml)) - ) + .service(web::resource("/mint").route(web::post().to(mint_nft))) + .service(web::resource("/metadata/format").route(web::post().to(format_ai_metadata))) + .service(web::resource("/stellar-toml").route(web::get().to(generate_stellar_toml))), ); } diff --git a/backend/modules/st_core/src/lib.rs b/backend/modules/st_core/src/lib.rs index 703371a3..80e0b08b 100644 --- a/backend/modules/st_core/src/lib.rs +++ b/backend/modules/st_core/src/lib.rs @@ -1,12 +1,12 @@ -pub mod nft; pub mod models; +pub mod nft; pub mod transaction_builder; #[cfg(feature = "api")] pub mod endpoint; -pub use nft::*; pub use models::*; +pub use nft::*; pub use transaction_builder::*; #[cfg(feature = "api")] diff --git a/backend/modules/st_core/src/main.rs b/backend/modules/st_core/src/main.rs index 50a800fa..28228ee6 100644 --- a/backend/modules/st_core/src/main.rs +++ b/backend/modules/st_core/src/main.rs @@ -1,9 +1,9 @@ -use st_core::{NFTService, AIMetadata, NFTMintRequest}; +use st_core::{AIMetadata, NFTMintRequest, NFTService}; #[tokio::main] async fn main() -> Result<(), Box> { println!("Stellar Core NFT Service"); - + // Example usage let ai_metadata = AIMetadata { name: "Chess AI Master".to_string(), @@ -17,14 +17,14 @@ async fn main() -> Result<(), Box> { animation_url: None, youtube_url: None, }; - + let mint_request = NFTMintRequest { ai_metadata, destination_account: "GATTMQEODSDX45WZK2JFIYETXWYCU5GRJ5I3Z7P2UDYD6YFVONDM4CX4".to_string(), issuer_account: "GAB35A2WLFSK64P6EWSGVFXZYU6E5K2INGTTLMDEDSIPYOH7NZVV6GIG".to_string(), network: "testnet".to_string(), }; - + match NFTService::create_nft_mint_transaction(mint_request).await { Ok(response) => { println!("✅ NFT Mint Transaction Created Successfully!"); @@ -37,6 +37,6 @@ async fn main() -> Result<(), Box> { eprintln!("❌ Error creating NFT mint transaction: {}", e); } } - + Ok(()) } diff --git a/backend/modules/st_core/src/models.rs b/backend/modules/st_core/src/models.rs index 238bdaf6..959444b2 100644 --- a/backend/modules/st_core/src/models.rs +++ b/backend/modules/st_core/src/models.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AIMetadata { pub name: String, pub description: String, @@ -43,20 +43,3 @@ pub struct StellarAssetInfo { pub fixed_number: u32, pub display_decimals: u8, } - -impl Default for AIMetadata { - fn default() -> Self { - Self { - name: String::new(), - description: String::new(), - url: String::new(), - issuer: String::new(), - code: String::new(), - attributes: None, - external_url: None, - image: None, - animation_url: None, - youtube_url: None, - } - } -} diff --git a/backend/modules/st_core/src/nft.rs b/backend/modules/st_core/src/nft.rs index 799cd72c..fe126c6e 100644 --- a/backend/modules/st_core/src/nft.rs +++ b/backend/modules/st_core/src/nft.rs @@ -8,55 +8,54 @@ pub struct NFTService; impl NFTService { /// Creates an NFT minting transaction following Stellar SEP-0039 standards - pub async fn create_nft_mint_transaction( - request: NFTMintRequest, - ) -> Result { + pub async fn create_nft_mint_transaction(request: NFTMintRequest) -> Result { // Validate request Self::validate_mint_request(&request)?; - + // Create the transaction let response = StellarTransactionBuilder::create_nft_mint_transaction(&request)?; - + Ok(response) } - + /// Validates the NFT mint request according to SEP-0039 standards fn validate_mint_request(request: &NFTMintRequest) -> Result<()> { // Validate Stellar account format if request.issuer_account.len() != 56 || !request.issuer_account.starts_with('G') { return Err(anyhow!("Invalid issuer account format")); } - - if request.destination_account.len() != 56 || !request.destination_account.starts_with('G') { + + if request.destination_account.len() != 56 || !request.destination_account.starts_with('G') + { return Err(anyhow!("Invalid destination account format")); } - + // Validate asset code (1-12 characters, alphanumeric) if request.ai_metadata.code.is_empty() || request.ai_metadata.code.len() > 12 { return Err(anyhow!("Asset code must be 1-12 characters")); } - + // Validate required fields if request.ai_metadata.name.is_empty() { return Err(anyhow!("AI name is required")); } - + if request.ai_metadata.description.is_empty() { return Err(anyhow!("AI description is required")); } - + if request.ai_metadata.issuer.is_empty() { return Err(anyhow!("AI issuer is required")); } - + // Validate network if request.network != "testnet" && request.network != "public" { return Err(anyhow!("Network must be 'testnet' or 'public'")); } - + Ok(()) } - + /// Generates stellar.toml content for the NFT following SEP-0039 pub fn generate_stellar_toml(metadata: &AIMetadata) -> Result { let toml_content = format!( @@ -73,7 +72,10 @@ url="{}" fixed_number=1 display_decimals=7 "#, - metadata.external_url.as_ref().unwrap_or(&"https://example.com".to_string()), + metadata + .external_url + .as_ref() + .unwrap_or(&"https://example.com".to_string()), metadata.issuer, metadata.code, metadata.name, @@ -81,10 +83,10 @@ display_decimals=7 metadata.image.as_ref().unwrap_or(&"".to_string()), metadata.url ); - + Ok(toml_content) } - + /// Creates IPFS-compatible metadata JSON following EIP-721 style pub fn create_ipfs_metadata(metadata: &AIMetadata) -> Result { let mut ipfs_metadata = json!({ @@ -94,34 +96,37 @@ display_decimals=7 "issuer": metadata.issuer, "code": metadata.code }); - + // Add optional fields if let Some(image) = &metadata.image { ipfs_metadata["image"] = json!(image); } - + if let Some(external_url) = &metadata.external_url { ipfs_metadata["external_url"] = json!(external_url); } - + if let Some(animation_url) = &metadata.animation_url { ipfs_metadata["animation_url"] = json!(animation_url); } - + if let Some(youtube_url) = &metadata.youtube_url { ipfs_metadata["youtube_url"] = json!(youtube_url); } - + if let Some(attributes) = &metadata.attributes { ipfs_metadata["attributes"] = json!(attributes); } - + // Add AI-specific metadata let mut ai_attributes = HashMap::new(); ai_attributes.insert("type".to_string(), json!("AI Agent")); ai_attributes.insert("standard".to_string(), json!("SEP-0039")); - ai_attributes.insert("created_at".to_string(), json!(chrono::Utc::now().to_rfc3339())); - + ai_attributes.insert( + "created_at".to_string(), + json!(chrono::Utc::now().to_rfc3339()), + ); + if let Some(ref mut attrs) = ipfs_metadata["attributes"].as_object_mut() { for (key, value) in ai_attributes { attrs.insert(key, value); @@ -129,10 +134,10 @@ display_decimals=7 } else { ipfs_metadata["attributes"] = json!(ai_attributes); } - + Ok(ipfs_metadata) } - + /// Validates and formats AI metadata for NFT minting pub fn format_ai_metadata(mut metadata: AIMetadata) -> Result { // Clean and validate name @@ -140,28 +145,28 @@ display_decimals=7 if metadata.name.is_empty() { return Err(anyhow!("AI name cannot be empty")); } - + // Clean and validate description metadata.description = metadata.description.trim().to_string(); if metadata.description.is_empty() { return Err(anyhow!("AI description cannot be empty")); } - + // Validate asset code format metadata.code = metadata.code.to_uppercase().trim().to_string(); if metadata.code.is_empty() || metadata.code.len() > 12 { return Err(anyhow!("Asset code must be 1-12 characters")); } - + // Validate URL format if provided - if !metadata.url.is_empty() { - if !metadata.url.starts_with("http://") && - !metadata.url.starts_with("https://") && - !metadata.url.starts_with("ipfs://") { - return Err(anyhow!("URL must start with http://, https://, or ipfs://")); - } + if !metadata.url.is_empty() + && !metadata.url.starts_with("http://") + && !metadata.url.starts_with("https://") + && !metadata.url.starts_with("ipfs://") + { + return Err(anyhow!("URL must start with http://, https://, or ipfs://")); } - + Ok(metadata) } } @@ -169,10 +174,10 @@ display_decimals=7 #[cfg(test)] mod tests { use super::*; - + #[test] fn test_format_ai_metadata() { - let mut metadata = AIMetadata { + let metadata = AIMetadata { name: " Test AI ".to_string(), description: " Test Description ".to_string(), code: "testai".to_string(), @@ -184,16 +189,16 @@ mod tests { animation_url: None, youtube_url: None, }; - + let result = NFTService::format_ai_metadata(metadata); assert!(result.is_ok()); - + let formatted = result.unwrap(); assert_eq!(formatted.name, "Test AI"); assert_eq!(formatted.description, "Test Description"); assert_eq!(formatted.code, "TESTAI"); } - + #[test] fn test_create_ipfs_metadata() { let metadata = AIMetadata { @@ -208,10 +213,10 @@ mod tests { animation_url: None, youtube_url: None, }; - + let result = NFTService::create_ipfs_metadata(&metadata); assert!(result.is_ok()); - + let ipfs_metadata = result.unwrap(); assert_eq!(ipfs_metadata["name"], "Test AI"); assert_eq!(ipfs_metadata["code"], "TESTAI"); diff --git a/backend/modules/st_core/src/transaction_builder.rs b/backend/modules/st_core/src/transaction_builder.rs index f1b94d01..ccee9684 100644 --- a/backend/modules/st_core/src/transaction_builder.rs +++ b/backend/modules/st_core/src/transaction_builder.rs @@ -1,9 +1,10 @@ use crate::models::{AIMetadata, NFTMintRequest, NFTMintResponse}; use anyhow::{anyhow, Result}; +use std::str::FromStr; use stellar_base::{ + account::DataValue, amount::Amount, asset::Asset, - account::DataValue, crypto::PublicKey, memo::Memo, network::Network, @@ -11,7 +12,6 @@ use stellar_base::{ transaction::{Transaction, MIN_BASE_FEE}, xdr::XDRSerialize, }; -use std::str::FromStr; pub struct StellarTransactionBuilder; @@ -19,14 +19,11 @@ impl StellarTransactionBuilder { pub fn create_nft_mint_transaction(request: &NFTMintRequest) -> Result { // Parse accounts let destination_publickey = PublicKey::from_account_id(&request.destination_account)?; - + // Create NFT asset (non-divisible) let issuer_publickey = PublicKey::from_account_id(&request.ai_metadata.issuer)?; - let nft_asset = Asset::new_credit( - &request.ai_metadata.code, - issuer_publickey, - )?; - + let nft_asset = Asset::new_credit(&request.ai_metadata.code, issuer_publickey)?; + // Create mint operation with minimum amount (1 stroop = 0.0000001) let mint_amount = Amount::from_str("0.0000001")?; let mint_operation = Operation::new_payment() @@ -34,59 +31,59 @@ impl StellarTransactionBuilder { .with_amount(mint_amount)? .with_asset(nft_asset) .build()?; - + // Create manage data operation for IPFS hash (if URL is provided) let mut operations = Vec::new(); - + // Add mint operation operations.push(mint_operation); - + if !request.ai_metadata.url.is_empty() { // Extract IPFS hash or use full URL as data entry let data_entry_name = "ipfshash"; let data_entry_value = request.ai_metadata.url.clone(); - + let manage_data_op = Operation::new_manage_data() .with_data_name(data_entry_name.to_string()) .with_data_value(Some(DataValue::from_slice(data_entry_value.as_bytes())?)) .build()?; - + operations.push(manage_data_op); } - + // Get sequence number (in production, this should be fetched from Horizon) let sequence_number = 1u64; // Placeholder - should be fetched from Horizon API - + // Create transaction - let mut transaction = Transaction::builder( - issuer_publickey, - sequence_number as i64, - MIN_BASE_FEE, - ) - .with_memo(Memo::new_text(format!("NFT Mint: {}", request.ai_metadata.name))?) - .add_operation(operations[0].clone()); - + let mut transaction = + Transaction::builder(issuer_publickey, sequence_number as i64, MIN_BASE_FEE) + .with_memo(Memo::new_text(format!( + "NFT Mint: {}", + request.ai_metadata.name + ))?) + .add_operation(operations[0].clone()); + // Add manage data operation if present if operations.len() > 1 { transaction = transaction.add_operation(operations[1].clone()); } - + let transaction = transaction.into_transaction()?; - + // Set network and sign let _network = match request.network.as_str() { "testnet" => Network::new_test(), "public" => Network::new_public(), _ => return Err(anyhow!("Invalid network. Use 'testnet' or 'public'")), }; - + // Generate XDR (unsigned transaction envelope) let xdr_envelope = transaction.into_envelope(); let xdr_base64 = xdr_envelope.xdr_base64()?; - + // Generate transaction hash for reference let transaction_hash = format!("tx_{}", uuid::Uuid::new_v4()); - + Ok(NFTMintResponse { xdr_transaction: xdr_base64, network: request.network.clone(), @@ -94,7 +91,7 @@ impl StellarTransactionBuilder { created_at: chrono::Utc::now().to_rfc3339(), }) } - + pub fn format_ai_metadata_for_stellar(metadata: &AIMetadata) -> Result { let mut stellar_metadata = serde_json::json!({ "name": metadata.name, @@ -103,32 +100,33 @@ impl StellarTransactionBuilder { "issuer": metadata.issuer, "code": metadata.code }); - + // Add optional fields if present if let Some(image) = &metadata.image { stellar_metadata["image"] = serde_json::Value::String(image.clone()); } - + if let Some(external_url) = &metadata.external_url { stellar_metadata["external_url"] = serde_json::Value::String(external_url.clone()); } - + if let Some(animation_url) = &metadata.animation_url { stellar_metadata["animation_url"] = serde_json::Value::String(animation_url.clone()); } - + if let Some(youtube_url) = &metadata.youtube_url { stellar_metadata["youtube_url"] = serde_json::Value::String(youtube_url.clone()); } - + if let Some(attributes) = &metadata.attributes { stellar_metadata["attributes"] = serde_json::Value::Object( - attributes.iter() + attributes + .iter() .map(|(k, v)| (k.clone(), v.clone())) - .collect() + .collect(), ); } - + Ok(stellar_metadata) } } @@ -136,7 +134,7 @@ impl StellarTransactionBuilder { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_create_nft_mint_transaction() { let request = NFTMintRequest { @@ -152,14 +150,15 @@ mod tests { animation_url: None, youtube_url: None, }, - destination_account: "GATTMQEODSDX45WZK2JFIYETXWYCU5GRJ5I3Z7P2UDYD6YFVONDM4CX4".to_string(), + destination_account: "GATTMQEODSDX45WZK2JFIYETXWYCU5GRJ5I3Z7P2UDYD6YFVONDM4CX4" + .to_string(), issuer_account: "GAB35A2WLFSK64P6EWSGVFXZYU6E5K2INGTTLMDEDSIPYOH7NZVV6GIG".to_string(), network: "testnet".to_string(), }; - + let result = StellarTransactionBuilder::create_nft_mint_transaction(&request); assert!(result.is_ok()); - + let response = result.unwrap(); assert!(!response.xdr_transaction.is_empty()); assert_eq!(response.network, "testnet"); diff --git a/backend/modules/st_core/tests/integration_test.rs b/backend/modules/st_core/tests/integration_test.rs index f9be7430..6754570f 100644 --- a/backend/modules/st_core/tests/integration_test.rs +++ b/backend/modules/st_core/tests/integration_test.rs @@ -1,7 +1,6 @@ #[cfg(test)] mod tests { - use super::*; - use st_core::{NFTService, AIMetadata, NFTMintRequest}; + use st_core::{AIMetadata, NFTMintRequest, NFTService}; #[tokio::test] async fn test_nft_mint_transaction_creation() { @@ -20,7 +19,8 @@ mod tests { let mint_request = NFTMintRequest { ai_metadata, - destination_account: "GATTMQEODSDX45WZK2JFIYETXWYCU5GRJ5I3Z7P2UDYD6YFVONDM4CX4".to_string(), + destination_account: "GATTMQEODSDX45WZK2JFIYETXWYCU5GRJ5I3Z7P2UDYD6YFVONDM4CX4" + .to_string(), issuer_account: "GAB35A2WLFSK64P6EWSGVFXZYU6E5K2INGTTLMDEDSIPYOH7NZVV6GIG".to_string(), network: "testnet".to_string(), }; @@ -36,7 +36,7 @@ mod tests { #[tokio::test] async fn test_ai_metadata_formatting() { - let mut metadata = AIMetadata { + let metadata = AIMetadata { name: " Test AI ".to_string(), description: " Test Description ".to_string(), url: "ipfs://QmTest123".to_string(), diff --git a/backend/modules/tournament/src/arena.rs b/backend/modules/tournament/src/arena.rs index 479b2a01..1bbab1f3 100644 --- a/backend/modules/tournament/src/arena.rs +++ b/backend/modules/tournament/src/arena.rs @@ -3,6 +3,12 @@ use std::collections::HashSet; pub struct ArenaPairingStrategy; +impl Default for ArenaPairingStrategy { + fn default() -> Self { + Self::new() + } +} + impl ArenaPairingStrategy { pub fn new() -> Self { Self @@ -38,7 +44,7 @@ impl PairingStrategy for ArenaPairingStrategy { } let player_b = &players[j]; - + // Track the closest available player as fallback (soft constraint) if fallback_match_idx.is_none() { fallback_match_idx = Some(j); @@ -47,8 +53,14 @@ impl PairingStrategy for ArenaPairingStrategy { // Check soft constraint: avoid pairing if played recently // Assuming recent_opponents contains IDs of players played against. // We check if the LAST opponent is player_b. - let played_recently = player_a.recent_opponents.last().map_or(false, |id| *id == player_b.id) - || player_b.recent_opponents.last().map_or(false, |id| *id == player_a.id); + let played_recently = player_a + .recent_opponents + .last() + .is_some_and(|id| *id == player_b.id) + || player_b + .recent_opponents + .last() + .is_some_and(|id| *id == player_a.id); if !played_recently { best_match_idx = Some(j); @@ -85,8 +97,8 @@ impl PairingStrategy for ArenaPairingStrategy { #[cfg(test)] mod tests { use super::*; - use uuid::Uuid; use chrono::Utc; + use uuid::Uuid; fn create_player(elo: u32, recent_opponents: Vec) -> TournamentPlayer { TournamentPlayer { @@ -109,15 +121,18 @@ mod tests { assert_eq!(pairs.len(), 1); assert_eq!(left.len(), 1); - - // Should pair 1200 and 1100 (closest), leaving 1000? + + // Should pair 1200 and 1100 (closest), leaving 1000? // Or 1200(p3), 1100(p2), 1000(p1) -> p3 paired with p2 (diff 100), p1 left. // Wait, p3(1200) vs p2(1100) = 100. // p2(1100) vs p1(1000) = 100. // Greedy: p3 (first) pairs with p2. p1 left. - + // Let's verify IDs. - let paired_ids: Vec = pairs.iter().flat_map(|p| vec![p.player1.id, p.player2.id]).collect(); + let paired_ids: Vec = pairs + .iter() + .flat_map(|p| vec![p.player1.id, p.player2.id]) + .collect(); assert!(paired_ids.contains(&p3.id)); assert!(paired_ids.contains(&p2.id)); assert_eq!(left[0].id, p1.id); @@ -132,16 +147,15 @@ mod tests { // p1 checks p3 -> OK. // Pairs p1-p3. p2 left. - let id_b = Uuid::new_v4(); let p_a = create_player(2000, vec![id_b]); let mut p_b = create_player(1990, vec![]); p_b.id = id_b; let p_c = create_player(1900, vec![]); - + let strat = ArenaPairingStrategy::new(); let (pairs, _left) = strat.pair(vec![p_a.clone(), p_b.clone(), p_c.clone()]); - + assert_eq!(pairs[0].player2.id, p_c.id); // Should skip p_b and pick p_c } @@ -156,7 +170,7 @@ mod tests { let strat = ArenaPairingStrategy::new(); let (pairs, _left) = strat.pair(vec![p1, p2]); - + assert_eq!(pairs.len(), 1); } @@ -181,6 +195,10 @@ mod tests { assert_eq!(pairs.len(), 500); assert_eq!(left.len(), 0); // Expect < 50ms, usually < 10ms for O(N^2) or O(N log N) with 1000 items - assert!(duration.as_millis() < 50, "Pairing took too long: {:?}", duration); + assert!( + duration.as_millis() < 50, + "Pairing took too long: {:?}", + duration + ); } } diff --git a/backend/modules/tournament/src/bracket.rs b/backend/modules/tournament/src/bracket.rs index a6e92b2f..565a18af 100644 --- a/backend/modules/tournament/src/bracket.rs +++ b/backend/modules/tournament/src/bracket.rs @@ -74,7 +74,9 @@ pub enum BracketError { impl std::fmt::Display for BracketError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::NotEnoughPlayers => write!(f, "At least 2 players are required to start a tournament"), + Self::NotEnoughPlayers => { + write!(f, "At least 2 players are required to start a tournament") + } Self::TournamentAlreadyStarted => write!(f, "Tournament has already started"), Self::TournamentNotStarted => write!(f, "Tournament has not started yet"), Self::MatchNotFound => write!(f, "Match not found"), @@ -176,7 +178,11 @@ impl BracketService { }; { - let m = bracket.matches.iter_mut().find(|m| m.id == match_id).unwrap(); + let m = bracket + .matches + .iter_mut() + .find(|m| m.id == match_id) + .unwrap(); m.winner_id = Some(winner_id); m.status = MatchStatus::Completed; m.completed_at = Some(Utc::now()); @@ -186,7 +192,7 @@ impl BracketService { BracketFormat::SingleElimination | BracketFormat::DoubleElimination => { let next_round = match_round + 1; // Odd match_number feeds player1 slot; even feeds player2 slot - let next_match_number = (match_number + 1) / 2; + let next_match_number = match_number.div_ceil(2); if let Some(next) = bracket .matches @@ -206,7 +212,11 @@ impl BracketService { } } BracketFormat::RoundRobin => { - if bracket.matches.iter().all(|m| m.status == MatchStatus::Completed) { + if bracket + .matches + .iter() + .all(|m| m.status == MatchStatus::Completed) + { bracket.winner_id = determine_round_robin_winner(bracket); bracket.status = TournamentStatus::Completed; bracket.completed_at = Some(Utc::now()); @@ -232,7 +242,11 @@ impl BracketService { } fn next_power_of_two(n: u32) -> u32 { - if n.is_power_of_two() { n } else { n.next_power_of_two() } + if n.is_power_of_two() { + n + } else { + n.next_power_of_two() + } } fn generate_single_elimination_matches( @@ -250,7 +264,13 @@ fn generate_single_elimination_matches( let (p1_id, p2_id, status, winner_id, completed_at) = match (p1, p2) { // Bye: top seed advances automatically - (Some(id), None) => (Some(id), None, MatchStatus::Completed, Some(id), Some(Utc::now())), + (Some(id), None) => ( + Some(id), + None, + MatchStatus::Completed, + Some(id), + Some(Utc::now()), + ), (Some(id1), Some(id2)) => (Some(id1), Some(id2), MatchStatus::Pending, None, None), _ => (None, None, MatchStatus::Pending, None, None), }; @@ -400,7 +420,12 @@ mod tests { .unwrap(); BracketService::start_tournament(&mut bracket).unwrap(); - let m = bracket.matches.iter().find(|m| m.round == 1).unwrap().clone(); + let m = bracket + .matches + .iter() + .find(|m| m.round == 1) + .unwrap() + .clone(); let winner = m.player1_id.unwrap(); BracketService::record_result(&mut bracket, m.id, winner).unwrap(); @@ -421,16 +446,42 @@ mod tests { BracketService::start_tournament(&mut bracket).unwrap(); - let r1: Vec<_> = bracket.matches.iter().filter(|m| m.round == 1).map(|m| m.id).collect(); + let r1: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1) + .map(|m| m.id) + .collect(); for id in r1 { - let winner = bracket.matches.iter().find(|m| m.id == id).unwrap().player1_id.unwrap(); + let winner = bracket + .matches + .iter() + .find(|m| m.id == id) + .unwrap() + .player1_id + .unwrap(); BracketService::record_result(&mut bracket, id, winner).unwrap(); } - assert_eq!(bracket.status, TournamentStatus::InProgress, "Should still be in progress after round 1"); + assert_eq!( + bracket.status, + TournamentStatus::InProgress, + "Should still be in progress after round 1" + ); - let r2: Vec<_> = bracket.matches.iter().filter(|m| m.round == 2).map(|m| m.id).collect(); + let r2: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 2) + .map(|m| m.id) + .collect(); for id in r2 { - let winner = bracket.matches.iter().find(|m| m.id == id).unwrap().player1_id.unwrap(); + let winner = bracket + .matches + .iter() + .find(|m| m.id == id) + .unwrap() + .player1_id + .unwrap(); BracketService::record_result(&mut bracket, id, winner).unwrap(); } assert_eq!(bracket.status, TournamentStatus::Completed); @@ -447,8 +498,8 @@ mod tests { .unwrap(); let m = bracket.matches[0].clone(); - let err = BracketService::record_result(&mut bracket, m.id, m.player1_id.unwrap()) - .unwrap_err(); + let err = + BracketService::record_result(&mut bracket, m.id, m.player1_id.unwrap()).unwrap_err(); assert_eq!(err, BracketError::TournamentNotStarted); } @@ -519,7 +570,10 @@ mod tests { .filter(|m| m.round == 1 && m.status == MatchStatus::Completed) .collect(); assert_eq!(byes.len(), 1, "Exactly one bye for 3-player bracket"); - assert!(byes[0].winner_id.is_some(), "Bye match should have a winner set"); + assert!( + byes[0].winner_id.is_some(), + "Bye match should have a winner set" + ); } #[test] @@ -549,7 +603,13 @@ mod tests { let ids: Vec<_> = bracket.matches.iter().map(|m| m.id).collect(); for id in ids { - let winner = bracket.matches.iter().find(|m| m.id == id).unwrap().player1_id.unwrap(); + let winner = bracket + .matches + .iter() + .find(|m| m.id == id) + .unwrap() + .player1_id + .unwrap(); BracketService::record_result(&mut bracket, id, winner).unwrap(); } @@ -572,7 +632,13 @@ mod tests { // Player at index 0 (highest ELO) wins all their matches let ids: Vec<_> = bracket.matches.iter().map(|m| m.id).collect(); for id in ids { - let winner = bracket.matches.iter().find(|m| m.id == id).unwrap().player1_id.unwrap(); + let winner = bracket + .matches + .iter() + .find(|m| m.id == id) + .unwrap() + .player1_id + .unwrap(); BracketService::record_result(&mut bracket, id, winner).unwrap(); } diff --git a/backend/modules/tournament/src/lib.rs b/backend/modules/tournament/src/lib.rs index a29a95a8..5142c71f 100644 --- a/backend/modules/tournament/src/lib.rs +++ b/backend/modules/tournament/src/lib.rs @@ -1,13 +1,13 @@ -pub mod swiss; -pub mod pairing; pub mod arena; pub mod bracket; +pub mod pairing; +pub mod swiss; -pub use swiss::{ - Player, Color, Pairing, TournamentState, PairingResult, SwissConfig, GameResult, - SwissPairer, PairingError -}; pub use bracket::{ - BracketFormat, BracketMatch, BracketService, BracketError, - MatchStatus, TournamentBracket, TournamentParticipant, TournamentStatus, + BracketError, BracketFormat, BracketMatch, BracketService, MatchStatus, TournamentBracket, + TournamentParticipant, TournamentStatus, +}; +pub use swiss::{ + Color, GameResult, Pairing, PairingError, PairingResult, Player, SwissConfig, SwissPairer, + TournamentState, }; diff --git a/backend/modules/tournament/src/pairing.rs b/backend/modules/tournament/src/pairing.rs index e6bfc1d5..8fdd9a26 100644 --- a/backend/modules/tournament/src/pairing.rs +++ b/backend/modules/tournament/src/pairing.rs @@ -1,6 +1,6 @@ -use uuid::Uuid; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TournamentPlayer { diff --git a/backend/modules/tournament/src/swiss/mod.rs b/backend/modules/tournament/src/swiss/mod.rs index 4237a960..57b93aee 100644 --- a/backend/modules/tournament/src/swiss/mod.rs +++ b/backend/modules/tournament/src/swiss/mod.rs @@ -6,7 +6,7 @@ pub mod pairer; #[cfg(test)] mod tests; -pub use pairer::{SwissPairer, PairingError}; +pub use pairer::{PairingError, SwissPairer}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Player { @@ -82,7 +82,7 @@ impl Player { pub fn add_game_result(&mut self, opponent: Uuid, color: Color, result: GameResult) { self.opponents.push(opponent); self.color_history.push(color); - + match result { GameResult::Win => self.score += 1.0, GameResult::Draw => self.score += 0.5, @@ -95,8 +95,16 @@ impl Player { } pub fn get_color_balance(&self) -> i32 { - let white_count = self.color_history.iter().filter(|&&c| c == Color::White).count() as i32; - let black_count = self.color_history.iter().filter(|&&c| c == Color::Black).count() as i32; + let white_count = self + .color_history + .iter() + .filter(|&&c| c == Color::White) + .count() as i32; + let black_count = self + .color_history + .iter() + .filter(|&&c| c == Color::Black) + .count() as i32; white_count - black_count } @@ -118,10 +126,7 @@ pub enum GameResult { impl TournamentState { pub fn new(players: Vec, total_rounds: u32) -> Self { - let player_map: HashMap = players - .into_iter() - .map(|p| (p.id, p)) - .collect(); + let player_map: HashMap = players.into_iter().map(|p| (p.id, p)).collect(); Self { players: player_map, @@ -133,16 +138,14 @@ impl TournamentState { } pub fn get_active_players(&self) -> Vec<&Player> { - self.players - .values() - .filter(|p| p.is_active) - .collect() + self.players.values().filter(|p| p.is_active).collect() } pub fn get_players_sorted_by_score_then_rating(&self) -> Vec<&Player> { let mut players: Vec<&Player> = self.get_active_players(); players.sort_by(|a, b| { - b.score.partial_cmp(&a.score) + b.score + .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) .then(b.rating.cmp(&a.rating)) }); @@ -154,7 +157,8 @@ impl TournamentState { if let Some(player) = self.players.get_mut(&player_id) { // Find the opponent and color from current round pairings if let Some(pairing) = self.pairings.iter().find(|p| { - p.round == self.current_round && (p.white_player == player_id || p.black_player == player_id) + p.round == self.current_round + && (p.white_player == player_id || p.black_player == player_id) }) { let opponent_id = if pairing.white_player == player_id { pairing.black_player @@ -170,7 +174,7 @@ impl TournamentState { } } } - + self.completed_rounds += 1; self.current_round += 1; } diff --git a/backend/modules/tournament/src/swiss/pairer.rs b/backend/modules/tournament/src/swiss/pairer.rs index 9679d60e..6b6d53ae 100644 --- a/backend/modules/tournament/src/swiss/pairer.rs +++ b/backend/modules/tournament/src/swiss/pairer.rs @@ -2,6 +2,7 @@ use super::*; use std::collections::HashMap; pub struct SwissPairer { + #[allow(dead_code)] config: SwissConfig, } @@ -10,16 +11,20 @@ impl SwissPairer { Self { config } } - pub fn pair_round(&self, tournament: &mut TournamentState) -> Result, PairingError> { + pub fn pair_round( + &self, + tournament: &mut TournamentState, + ) -> Result, PairingError> { // Clone players to avoid borrow issues let players: Vec = tournament.players.values().cloned().collect(); let mut player_refs: Vec<&Player> = players.iter().collect(); player_refs.sort_by(|a, b| { - b.score.partial_cmp(&a.score) + b.score + .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) .then(b.rating.cmp(&a.rating)) }); - + // Handle odd number of players - assign bye to lowest ranked let pairings = if player_refs.len() % 2 == 1 { let bye_player_id = self.assign_bye(&mut player_refs, tournament)?; @@ -42,14 +47,19 @@ impl SwissPairer { Ok(pairings) } - fn assign_bye(&self, players: &mut Vec<&Player>, tournament: &mut TournamentState) -> Result { + fn assign_bye( + &self, + players: &mut Vec<&Player>, + tournament: &mut TournamentState, + ) -> Result { // Find the lowest ranked player who hasn't had a bye yet let bye_candidate = players .iter() .enumerate() .filter(|(_, p): &(_, &&Player)| !p.has_had_bye()) .min_by(|(_, a), (_, b)| { - a.score.partial_cmp(&b.score) + a.score + .partial_cmp(&b.score) .unwrap_or(std::cmp::Ordering::Equal) .then(a.rating.cmp(&b.rating)) }); @@ -58,26 +68,30 @@ impl SwissPairer { Some((index, player)) => { let player_id = player.id; players.remove(index); - + // Award 1 point for bye if let Some(p) = tournament.players.get_mut(&player_id) { p.score += 1.0; } - + Ok(player_id) } None => Err(PairingError::NoValidByeCandidate), } } - fn pair_even_players(&self, players: Vec<&Player>, tournament: &mut TournamentState) -> Result, PairingError> { + fn pair_even_players( + &self, + players: Vec<&Player>, + tournament: &mut TournamentState, + ) -> Result, PairingError> { let mut pairings = Vec::new(); let _unpaired_players: Vec = players.iter().map(|p| p.id).collect(); let mut used_players = std::collections::HashSet::new(); // Dutch System: Process score groups let mut score_groups = self.create_score_groups(&players); - + for group in score_groups.iter_mut() { if group.len() < 2 { continue; @@ -109,25 +123,20 @@ impl SwissPairer { fn create_score_groups<'a>(&self, players: &[&'a Player]) -> Vec> { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; - + let mut groups: HashMap> = HashMap::new(); - + for player in players { let mut hasher = DefaultHasher::new(); let score_bits = player.score.to_bits(); score_bits.hash(&mut hasher); let key = hasher.finish(); - - groups - .entry(key) - .or_insert_with(Vec::new) - .push(player); + + groups.entry(key).or_default().push(player); } - let mut sorted_groups: Vec> = groups - .into_values() - .collect(); - + let mut sorted_groups: Vec> = groups.into_values().collect(); + // Sort groups by score (highest first) sorted_groups.sort_by(|a, b| b[0].score.partial_cmp(&a[0].score).unwrap()); sorted_groups @@ -150,15 +159,16 @@ impl SwissPairer { // Find best opponent for player1 for (i, &player2) in group_players.iter().enumerate().skip(1) { if self.can_pair(player1, player2, tournament) { - let pairing = self.create_pairing(player1, player2, tournament.current_round)?; + let pairing = + self.create_pairing(player1, player2, tournament.current_round)?; pairings.push(PairingResult::Paired(pairing)); - + // Update float scores self.update_float_scores(player1, player2, tournament, false); - + used_players.insert(player1.id); used_players.insert(player2.id); - + group_players.remove(i); group_players.remove(0); found_pair = true; @@ -185,7 +195,8 @@ impl SwissPairer { // Sort remaining players by score then rating players.sort_by(|a, b| { - b.score.partial_cmp(&a.score) + b.score + .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) .then(b.rating.cmp(&a.rating)) }); @@ -202,7 +213,7 @@ impl SwissPairer { if self.can_pair(player1, player2, tournament) { let pairing = self.create_pairing(player1, player2, tournament.current_round)?; pairings.push(PairingResult::Paired(pairing)); - + // Update float scores (these are floaters) self.update_float_scores(player1, player2, tournament, true); } else { @@ -220,9 +231,8 @@ impl SwissPairer { } // Color balance preference - let color_preference_ok = self.check_color_preference(player1, player2); - color_preference_ok + self.check_color_preference(player1, player2) } fn check_color_preference(&self, player1: &Player, player2: &Player) -> bool { @@ -241,7 +251,12 @@ impl SwissPairer { true } - fn create_pairing(&self, player1: &Player, player2: &Player, round: u32) -> Result { + fn create_pairing( + &self, + player1: &Player, + player2: &Player, + round: u32, + ) -> Result { let (white_player, black_player) = if player1.should_prefer_white() { (player1.id, player2.id) } else if player2.should_prefer_white() { @@ -272,7 +287,7 @@ impl SwissPairer { if is_floater { // Update float scores for players paired across score groups let _score_diff = (player1.score - player2.score).abs(); - + if player1.score > player2.score { if let Some(p) = tournament.players.get_mut(&player1.id) { p.float_score += 1; // Floating down diff --git a/backend/modules/tournament/src/swiss/tests.rs b/backend/modules/tournament/src/swiss/tests.rs index f193f743..493bb739 100644 --- a/backend/modules/tournament/src/swiss/tests.rs +++ b/backend/modules/tournament/src/swiss/tests.rs @@ -17,12 +17,12 @@ mod tests { fn test_tournament_state_creation() { let players = create_test_players(); let tournament = TournamentState::new(players.clone(), 5); - + assert_eq!(tournament.players.len(), 5); assert_eq!(tournament.current_round, 1); assert_eq!(tournament.completed_rounds, 0); assert_eq!(tournament.total_rounds, 5); - + // Check all players start with 0 score for player in tournament.players.values() { assert_eq!(player.score, 0.0); @@ -65,9 +65,9 @@ mod tests { if let Some(player) = tournament.players.get_mut(&charlie_id) { player.score = 1.5; } - + let sorted_players = tournament.get_players_sorted_by_score_then_rating(); - + // Should be: player 1 (2.0, 2100), player 0 (2.0, 2000), player 2 (1.5, 1800) assert_eq!(sorted_players[0].rating, 2100); assert_eq!(sorted_players[1].rating, 2000); @@ -77,20 +77,20 @@ mod tests { #[test] fn test_color_balance_tracking() { let mut player = Player::new(Uuid::new_v4(), "Test".to_string(), 1500); - + assert_eq!(player.get_color_balance(), 0); assert!(!player.should_prefer_white()); - + // Add white game player.color_history.push(Color::White); assert_eq!(player.get_color_balance(), 1); assert!(!player.should_prefer_white()); // Prefers black now - + // Add black game player.color_history.push(Color::Black); assert_eq!(player.get_color_balance(), 0); assert!(!player.should_prefer_white()); // Balanced - + // Add another black game player.color_history.push(Color::Black); assert_eq!(player.get_color_balance(), -1); @@ -101,7 +101,7 @@ mod tests { fn test_game_result_application() { let mut tournament = TournamentState::new(create_test_players(), 5); let player_ids: Vec = tournament.players.keys().cloned().collect(); - + // Create a pairing for round 1 let pairing = Pairing { white_player: player_ids[0], @@ -109,15 +109,15 @@ mod tests { round: 1, }; tournament.pairings.push(pairing); - + // Apply results let results = vec![ (player_ids[0], GameResult::Win), // White wins (player_ids[1], GameResult::Loss), // Black loses ]; - + tournament.apply_round_results(results); - + // Check scores assert_eq!(tournament.players[&player_ids[0]].score, 1.0); assert_eq!(tournament.players[&player_ids[1]].score, 0.0); @@ -132,15 +132,21 @@ mod tests { let players = create_test_players(); let mut tournament = TournamentState::new(players, 5); let pairer = SwissPairer::new(SwissConfig::default()); - + let pairings = pairer.pair_round(&mut tournament).unwrap(); - + // Should have 2 pairings (4 players) and 1 bye (5th player) assert_eq!(pairings.len(), 3); - - let pairing_count = pairings.iter().filter(|p| matches!(p, PairingResult::Paired(_))).count(); - let bye_count = pairings.iter().filter(|p| matches!(p, PairingResult::Bye(_))).count(); - + + let pairing_count = pairings + .iter() + .filter(|p| matches!(p, PairingResult::Paired(_))) + .count(); + let bye_count = pairings + .iter() + .filter(|p| matches!(p, PairingResult::Bye(_))) + .count(); + assert_eq!(pairing_count, 2); assert_eq!(bye_count, 1); } @@ -149,18 +155,18 @@ mod tests { fn test_swiss_pairing_odd_players() { let mut players = create_test_players(); players.pop(); // Remove one player to make it even (4 players) - + let mut tournament = TournamentState::new(players, 5); let pairer = SwissPairer::new(SwissConfig::default()); - + let pairings = pairer.pair_round(&mut tournament).unwrap(); - + // Should have exactly 2 pairings, no byes assert_eq!(pairings.len(), 2); - + for pairing in &pairings { match pairing { - PairingResult::Paired(_) => {}, // Expected + PairingResult::Paired(_) => {} // Expected PairingResult::Bye(_) => panic!("Unexpected bye with even number of players"), } } @@ -171,16 +177,17 @@ mod tests { let players = create_test_players(); let mut tournament = TournamentState::new(players, 5); let pairer = SwissPairer::new(SwissConfig::default()); - + // Find who gets the bye (should be lowest rated) let initial_players = tournament.get_players_sorted_by_score_then_rating(); let expected_bye_candidate = initial_players.last().unwrap(); // Lowest rated let expected_id = expected_bye_candidate.id; - + let pairings = pairer.pair_round(&mut tournament).unwrap(); - + // Find the bye - let bye_player_id = pairings.iter() + let bye_player_id = pairings + .iter() .find_map(|p| { if let PairingResult::Bye(id) = p { Some(id) @@ -189,9 +196,9 @@ mod tests { } }) .unwrap(); - + assert_eq!(*bye_player_id, expected_id); - + // Check that bye player received 1 point assert_eq!(tournament.players[bye_player_id].score, 1.0); } @@ -201,21 +208,22 @@ mod tests { let players = create_test_players(); let mut tournament = TournamentState::new(players, 5); let pairer = SwissPairer::new(SwissConfig::default()); - + // First round let first_round_pairings = pairer.pair_round(&mut tournament).unwrap(); tournament.pairings.clear(); - + // Convert pairing results to actual pairings for pairing_result in &first_round_pairings { if let PairingResult::Paired(pairing) = pairing_result { tournament.pairings.push(pairing.clone()); } } - + // Apply dummy results to advance let player_ids: Vec = tournament.players.keys().cloned().collect(); - let results: Vec<(Uuid, GameResult)> = player_ids.iter() + let results: Vec<(Uuid, GameResult)> = player_ids + .iter() .map(|&id| { if tournament.players[&id].score > 0.5 { (id, GameResult::Win) @@ -224,18 +232,21 @@ mod tests { } }) .collect(); - + tournament.apply_round_results(results); - + // Second round let second_round_pairings = pairer.pair_round(&mut tournament).unwrap(); - + // Verify no repeat pairings for pairing_result in &second_round_pairings { if let PairingResult::Paired(pairing) = pairing_result { let has_played_before = tournament.players[&pairing.white_player] .has_played_against(&pairing.black_player); - assert!(!has_played_before, "Players should not be paired against each other again"); + assert!( + !has_played_before, + "Players should not be paired against each other again" + ); } } } @@ -244,9 +255,9 @@ mod tests { fn test_tournament_completion() { let players = create_test_players(); let mut tournament = TournamentState::new(players, 3); // 3 rounds - + assert!(!tournament.is_complete()); - + tournament.completed_rounds = 3; assert!(tournament.is_complete()); } @@ -264,21 +275,21 @@ mod tests { Player::new(Uuid::new_v4(), "GM Leinier".to_string(), 2676), Player::new(Uuid::new_v4(), "GM Anish".to_string(), 2673), ]; - + let mut tournament = TournamentState::new(players, 5); let pairer = SwissPairer::new(SwissConfig::default()); - + // Simulate first round let round1_pairings = pairer.pair_round(&mut tournament).unwrap(); assert_eq!(round1_pairings.len(), 4); // 4 pairings, no byes (8 players) - + // Apply realistic first round results (higher rated players tend to win) let mut results = Vec::new(); for pairing_result in &round1_pairings { if let PairingResult::Paired(pairing) = pairing_result { let white_rating = tournament.players[&pairing.white_player].rating; let black_rating = tournament.players[&pairing.black_player].rating; - + // Higher rated player wins (simplified) if white_rating > black_rating { results.push((pairing.white_player, GameResult::Win)); @@ -289,13 +300,13 @@ mod tests { } } } - + tournament.apply_round_results(results); - + // Verify tournament state after round 1 assert_eq!(tournament.completed_rounds, 1); assert_eq!(tournament.current_round, 2); - + // Check that players have different scores let scores: Vec = tournament.players.values().map(|p| p.score).collect(); let mut unique_scores = std::collections::HashSet::new(); @@ -303,8 +314,11 @@ mod tests { let score_bits = score.to_bits(); unique_scores.insert(score_bits); } - assert!(unique_scores.len() > 1, "Players should have different scores after round 1"); - + assert!( + unique_scores.len() > 1, + "Players should have different scores after round 1" + ); + // Second round should pair players with same scores when possible let round2_pairings = pairer.pair_round(&mut tournament).unwrap(); assert_eq!(round2_pairings.len(), 4); diff --git a/backend/src/chess960/api.rs b/backend/src/chess960/api.rs index bc553857..dd56d5bd 100644 --- a/backend/src/chess960/api.rs +++ b/backend/src/chess960/api.rs @@ -1,161 +1,163 @@ -use actix_web::{web, HttpResponse, Result}; -use serde::{Deserialize, Serialize}; - use std::sync::Arc; -use lazy_static::lazy_static; -use rand::Rng; - use super::models::{Chess960Library, Chess960Position}; - use super::generator::Chess960Generator; - -lazy_static::lazy_static! { - static ref CHESS960_LIBRARY: Arc = { - Arc::new(Chess960Generator::generate_all_positions()) - }; -} - -#[derive(Serialize)] -pub struct ApiResponse { - pub success: bool, - pub data: Option, - pub message: String, -} - -#[derive(Deserialize)] -pub struct PositionQuery { - pub number: Option, - pub random: Option, -} - -#[derive(Deserialize)] -pub struct FenVerifyRequest { - pub fen: String, -} - -#[derive(Serialize)] -pub struct StatsResponse { - pub total_positions: u16, - pub king_distribution: std::collections::HashMap, - pub version: String, -} - -pub async fn get_position(query: web::Query) -> Result { - let library = &*CHESS960_LIBRARY; - - let position = if query.random.unwrap_or(false) { - // Get random position - let mut rng = rand::thread_rng(); - let random_id = rng.gen_range(1..=960); - library.positions.get(&random_id).cloned() - } else if let Some(number) = query.number { - library.positions.get(&number).cloned() - } else { - return Ok(HttpResponse::BadRequest().json(ApiResponse { - success: false, - data: None::, - message: "Specify 'number' parameter or set 'random=true'".to_string(), - })); - }; - - match position { - Some(pos) => Ok(HttpResponse::Ok().json(ApiResponse { - success: true, - data: Some(pos), - message: "Position retrieved successfully".to_string(), - })), - None => Ok(HttpResponse::NotFound().json(ApiResponse { - success: false, - data: None::, - message: "Position not found".to_string(), - })), - } -} - -pub async fn get_fen(path: web::Path) -> Result { - let number = path.into_inner(); - let library = &*CHESS960_LIBRARY; - - match library.positions.get(&number) { - Some(position) => Ok(HttpResponse::Ok().json(ApiResponse { - success: true, - data: Some(&position.fen), - message: format!("FEN for position {} retrieved", number), - })), - None => Ok(HttpResponse::NotFound().json(ApiResponse { - success: false, - data: None::, - message: format!("Position {} not found", number), - })), - } -} -lazy_static! { - static ref CHESS960_FENS: std::collections::HashSet = { - CHESS960_LIBRARY.positions.values() - .map(|pos| pos.fen.clone()) - .collect() - }; -} - - pub async fn verify_fen(req: web::Json) -> Result { - let is_valid = CHESS960_FENS.contains(&req.fen); - - Ok(HttpResponse::Ok().json(ApiResponse { - success: true, - data: Some(is_valid), - message: if is_valid { - "Valid Chess960 FEN".to_string() - } else { - "Not a valid Chess960 FEN".to_string() - }, - })) -} -lazy_static! { - static ref KING_DISTRIBUTION: std::collections::HashMap = { - let mut distribution = std::collections::HashMap::new(); - for position in CHESS960_LIBRARY.positions.values() { - let count = distribution.entry(position.white_king_pos).or_insert(0); - *count += 1; - } - distribution - }; - } - -pub async fn get_stats() -> Result { - let library = &*CHESS960_LIBRARY; - - let stats = StatsResponse { - total_positions: library.total_positions, - king_distribution: KING_DISTRIBUTION.clone(), - version: library.metadata.version.clone(), - }; - - Ok(HttpResponse::Ok().json(ApiResponse { - success: true, - data: Some(stats), - message: "Statistics retrieved successfully".to_string(), - })) - } - -pub async fn export_json() -> Result { - let library = &*CHESS960_LIBRARY; - - match serde_json::to_string_pretty(&**library) { - Ok(json) => Ok(HttpResponse::Ok() - .content_type("application/json") - .body(json)), - Err(e) => Ok(HttpResponse::InternalServerError().json(ApiResponse { - success: false, - data: None::, - message: format!("Failed to serialize library: {}", e), - })), - } -} - -pub fn configure_routes(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("/api/chess960") - .route("/position", web::get().to(get_position)) - .route("/fen/{number}", web::get().to(get_fen)) - .route("/verify", web::post().to(verify_fen)) - .route("/stats", web::get().to(get_stats)) - .route("/export", web::get().to(export_json)) - ); -} \ No newline at end of file +use super::generator::Chess960Generator; +use super::models::{Chess960Library, Chess960Position}; +use actix_web::{web, HttpResponse, Result}; +use lazy_static::lazy_static; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +lazy_static::lazy_static! { + static ref CHESS960_LIBRARY: Arc = { + Arc::new(Chess960Generator::generate_all_positions()) + }; +} + +#[derive(Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: String, +} + +#[derive(Deserialize)] +pub struct PositionQuery { + pub number: Option, + pub random: Option, +} + +#[derive(Deserialize)] +pub struct FenVerifyRequest { + pub fen: String, +} + +#[derive(Serialize)] +pub struct StatsResponse { + pub total_positions: u16, + pub king_distribution: std::collections::HashMap, + pub version: String, +} + +pub async fn get_position(query: web::Query) -> Result { + let library = &*CHESS960_LIBRARY; + + let position = if query.random.unwrap_or(false) { + // Get random position + let mut rng = rand::thread_rng(); + let random_id = rng.gen_range(1..=960); + library.positions.get(&random_id).cloned() + } else if let Some(number) = query.number { + library.positions.get(&number).cloned() + } else { + return Ok(HttpResponse::BadRequest().json(ApiResponse { + success: false, + data: None::, + message: "Specify 'number' parameter or set 'random=true'".to_string(), + })); + }; + + match position { + Some(pos) => Ok(HttpResponse::Ok().json(ApiResponse { + success: true, + data: Some(pos), + message: "Position retrieved successfully".to_string(), + })), + None => Ok(HttpResponse::NotFound().json(ApiResponse { + success: false, + data: None::, + message: "Position not found".to_string(), + })), + } +} + +pub async fn get_fen(path: web::Path) -> Result { + let number = path.into_inner(); + let library = &*CHESS960_LIBRARY; + + match library.positions.get(&number) { + Some(position) => Ok(HttpResponse::Ok().json(ApiResponse { + success: true, + data: Some(&position.fen), + message: format!("FEN for position {} retrieved", number), + })), + None => Ok(HttpResponse::NotFound().json(ApiResponse { + success: false, + data: None::, + message: format!("Position {} not found", number), + })), + } +} +lazy_static! { + static ref CHESS960_FENS: std::collections::HashSet = { + CHESS960_LIBRARY + .positions + .values() + .map(|pos| pos.fen.clone()) + .collect() + }; +} + +pub async fn verify_fen(req: web::Json) -> Result { + let is_valid = CHESS960_FENS.contains(&req.fen); + + Ok(HttpResponse::Ok().json(ApiResponse { + success: true, + data: Some(is_valid), + message: if is_valid { + "Valid Chess960 FEN".to_string() + } else { + "Not a valid Chess960 FEN".to_string() + }, + })) +} +lazy_static! { + static ref KING_DISTRIBUTION: std::collections::HashMap = { + let mut distribution = std::collections::HashMap::new(); + for position in CHESS960_LIBRARY.positions.values() { + let count = distribution.entry(position.white_king_pos).or_insert(0); + *count += 1; + } + distribution + }; +} + +pub async fn get_stats() -> Result { + let library = &*CHESS960_LIBRARY; + + let stats = StatsResponse { + total_positions: library.total_positions, + king_distribution: KING_DISTRIBUTION.clone(), + version: library.metadata.version.clone(), + }; + + Ok(HttpResponse::Ok().json(ApiResponse { + success: true, + data: Some(stats), + message: "Statistics retrieved successfully".to_string(), + })) +} + +pub async fn export_json() -> Result { + let library = &*CHESS960_LIBRARY; + + match serde_json::to_string_pretty(&**library) { + Ok(json) => Ok(HttpResponse::Ok() + .content_type("application/json") + .body(json)), + Err(e) => Ok(HttpResponse::InternalServerError().json(ApiResponse { + success: false, + data: None::, + message: format!("Failed to serialize library: {}", e), + })), + } +} + +pub fn configure_routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/chess960") + .route("/position", web::get().to(get_position)) + .route("/fen/{number}", web::get().to(get_fen)) + .route("/verify", web::post().to(verify_fen)) + .route("/stats", web::get().to(get_stats)) + .route("/export", web::get().to(export_json)), + ); +} diff --git a/backend/src/chess960/generator.rs b/backend/src/chess960/generator.rs index 7296b379..3a5b7181 100644 --- a/backend/src/chess960/generator.rs +++ b/backend/src/chess960/generator.rs @@ -1,178 +1,188 @@ -use super::models::{Chess960Position, Chess960Library, LibraryMetadata}; -use chrono::Utc; -use std::collections::HashMap; - -pub struct Chess960Generator; - -impl Chess960Generator { - pub fn generate_all_positions() -> Chess960Library { - let mut positions = HashMap::new(); - let mut position_id = 1u16; - - // Generate all valid Chess960 positions using systematic approach - for arrangement in Self::generate_valid_arrangements() { - let position = Self::create_position(position_id, arrangement); - positions.insert(position_id, position); - position_id += 1; - } - - Chess960Library { - positions, - total_positions: 960, - metadata: LibraryMetadata { - version: "1.0.0".to_string(), - generated_at: Utc::now().to_rfc3339(), - description: "Complete Chess960 starting positions library".to_string(), - }, - } - } - - fn generate_valid_arrangements() -> Vec<[usize; 8]> { - let mut valid_arrangements = Vec::new(); - - // Systematic generation ensuring Chess960 rules - for king in 1..=6 { // King must be between rooks (positions 1-6) - for queen in 0..=7 { - if queen == king { continue; } - - for rook1 in 0..king { - if rook1 == queen { continue; } - - for rook2 in (king + 1)..=7 { - if rook2 == queen { continue; } - - for knight1 in 0..=7 { - if [king, queen, rook1, rook2].contains(&knight1) { continue; } - - for knight2 in (knight1 + 1)..=7 { - if [king, queen, rook1, rook2, knight1].contains(&knight2) { continue; } - - // Find remaining positions for bishops - let pieces = [king, queen, rook1, rook2, knight1, knight2]; -let occupied: std::collections::HashSet<_> = pieces.iter().copied().collect(); - - - let free: Vec = (0..=7) - .filter(|pos| !occupied.contains(pos)) - .collect(); - - if free.len() == 2 { - let bishop1 = free[0]; - let bishop2 = free[1]; - - // Verify bishops are on opposite colors - if (bishop1 + bishop2) % 2 == 1 { - valid_arrangements.push([ - king, queen, rook1, rook2, - bishop1, bishop2, knight1, knight2 - ]); - } - } - } - } - } - } - } - } - - valid_arrangements - } - - fn create_position(id: u16, arrangement: [usize; 8]) -> Chess960Position { - let [king, queen, rook1, rook2, bishop1, bishop2, knight1, knight2] = arrangement; - - let mut back_rank = ['?'; 8]; - back_rank[king] = 'K'; - back_rank[queen] = 'Q'; - back_rank[rook1] = 'R'; - back_rank[rook2] = 'R'; - back_rank[bishop1] = 'B'; - back_rank[bishop2] = 'B'; - back_rank[knight1] = 'N'; - back_rank[knight2] = 'N'; - - let back_rank_str: String = back_rank.iter().collect(); - let fen = format!( - "{}/pppppppp/8/8/8/8/PPPPPPPP/{} w KQkq - 0 1", - back_rank_str.to_lowercase(), - back_rank_str - ); - - Chess960Position { - position_number: id, - fen, - back_rank: back_rank_str, - white_king_pos: king, - white_rook_positions: [rook1.min(rook2), rook1.max(rook2)], - white_bishop_positions: [bishop1.min(bishop2), bishop1.max(bishop2)], - white_knight_positions: [knight1.min(knight2), knight1.max(knight2)], - white_queen_pos: queen, - } - } - - pub fn verify_position(position: &Chess960Position) -> bool { - let king_pos = position.white_king_pos; - let [rook1, rook2] = position.white_rook_positions; - let [bishop1, bishop2] = position.white_bishop_positions; - - // Verify king between rooks - if !(rook1 < king_pos && king_pos < rook2) { - return false; - } - - // Verify bishops on opposite colors - if (bishop1 + bishop2) % 2 == 0 { - return false; - } - - // Verify all positions are unique - let positions = [ - king_pos, - position.white_queen_pos, - rook1, - rook2, - bishop1, - bishop2, - position.white_knight_positions[0], - position.white_knight_positions[1], - ]; - let unique_positions: std::collections::HashSet<_> = positions.iter().copied().collect(); - unique_positions.len() == 8 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_960_positions() { - let library = Chess960Generator::generate_all_positions(); - assert_eq!(library.positions.len(), 960); - assert_eq!(library.total_positions, 960); - } - - #[test] - fn test_all_positions_valid() { - let library = Chess960Generator::generate_all_positions(); - - for position in library.positions.values() { - assert!(Chess960Generator::verify_position(position)); - } - } - - #[test] - fn test_fen_format() { - let library = Chess960Generator::generate_all_positions(); - - for position in library.positions.values().take(10) { - let fen_parts: Vec<&str> = position.fen.split(' ').collect(); - assert_eq!(fen_parts.len(), 6); - assert_eq!(fen_parts[1], "w"); // White to move - assert_eq!(fen_parts[2], "KQkq"); // Castling rights - assert_eq!(fen_parts[3], "-"); // No en passant - assert_eq!(fen_parts[4], "0"); // Halfmove clock - assert_eq!(fen_parts[5], "1"); // Fullmove number - } - } -} \ No newline at end of file +use super::models::{Chess960Library, Chess960Position, LibraryMetadata}; +use chrono::Utc; +use std::collections::HashMap; + +pub struct Chess960Generator; + +impl Chess960Generator { + pub fn generate_all_positions() -> Chess960Library { + let mut positions = HashMap::new(); + let mut position_id = 1u16; + + // Generate all valid Chess960 positions using systematic approach + for arrangement in Self::generate_valid_arrangements() { + let position = Self::create_position(position_id, arrangement); + positions.insert(position_id, position); + position_id += 1; + } + + Chess960Library { + positions, + total_positions: 960, + metadata: LibraryMetadata { + version: "1.0.0".to_string(), + generated_at: Utc::now().to_rfc3339(), + description: "Complete Chess960 starting positions library".to_string(), + }, + } + } + + fn generate_valid_arrangements() -> Vec<[usize; 8]> { + let mut valid_arrangements = Vec::new(); + + // Systematic generation ensuring Chess960 rules + for king in 1..=6 { + // King must be between rooks (positions 1-6) + for queen in 0..=7 { + if queen == king { + continue; + } + + for rook1 in 0..king { + if rook1 == queen { + continue; + } + + for rook2 in (king + 1)..=7 { + if rook2 == queen { + continue; + } + + for knight1 in 0..=7 { + if [king, queen, rook1, rook2].contains(&knight1) { + continue; + } + + for knight2 in (knight1 + 1)..=7 { + if [king, queen, rook1, rook2, knight1].contains(&knight2) { + continue; + } + + // Find remaining positions for bishops + let pieces = [king, queen, rook1, rook2, knight1, knight2]; + let occupied: std::collections::HashSet<_> = + pieces.iter().copied().collect(); + + let free: Vec = + (0..=7).filter(|pos| !occupied.contains(pos)).collect(); + + if free.len() == 2 { + let bishop1 = free[0]; + let bishop2 = free[1]; + + // Verify bishops are on opposite colors + if (bishop1 + bishop2) % 2 == 1 { + valid_arrangements.push([ + king, queen, rook1, rook2, bishop1, bishop2, knight1, + knight2, + ]); + } + } + } + } + } + } + } + } + + valid_arrangements + } + + fn create_position(id: u16, arrangement: [usize; 8]) -> Chess960Position { + let [king, queen, rook1, rook2, bishop1, bishop2, knight1, knight2] = arrangement; + + let mut back_rank = ['?'; 8]; + back_rank[king] = 'K'; + back_rank[queen] = 'Q'; + back_rank[rook1] = 'R'; + back_rank[rook2] = 'R'; + back_rank[bishop1] = 'B'; + back_rank[bishop2] = 'B'; + back_rank[knight1] = 'N'; + back_rank[knight2] = 'N'; + + let back_rank_str: String = back_rank.iter().collect(); + let fen = format!( + "{}/pppppppp/8/8/8/8/PPPPPPPP/{} w KQkq - 0 1", + back_rank_str.to_lowercase(), + back_rank_str + ); + + Chess960Position { + position_number: id, + fen, + back_rank: back_rank_str, + white_king_pos: king, + white_rook_positions: [rook1.min(rook2), rook1.max(rook2)], + white_bishop_positions: [bishop1.min(bishop2), bishop1.max(bishop2)], + white_knight_positions: [knight1.min(knight2), knight1.max(knight2)], + white_queen_pos: queen, + } + } + + pub fn verify_position(position: &Chess960Position) -> bool { + let king_pos = position.white_king_pos; + let [rook1, rook2] = position.white_rook_positions; + let [bishop1, bishop2] = position.white_bishop_positions; + + // Verify king between rooks + if !(rook1 < king_pos && king_pos < rook2) { + return false; + } + + // Verify bishops on opposite colors + if (bishop1 + bishop2) % 2 == 0 { + return false; + } + + // Verify all positions are unique + let positions = [ + king_pos, + position.white_queen_pos, + rook1, + rook2, + bishop1, + bishop2, + position.white_knight_positions[0], + position.white_knight_positions[1], + ]; + let unique_positions: std::collections::HashSet<_> = positions.iter().copied().collect(); + unique_positions.len() == 8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_960_positions() { + let library = Chess960Generator::generate_all_positions(); + assert_eq!(library.positions.len(), 960); + assert_eq!(library.total_positions, 960); + } + + #[test] + fn test_all_positions_valid() { + let library = Chess960Generator::generate_all_positions(); + + for position in library.positions.values() { + assert!(Chess960Generator::verify_position(position)); + } + } + + #[test] + fn test_fen_format() { + let library = Chess960Generator::generate_all_positions(); + + for position in library.positions.values().take(10) { + let fen_parts: Vec<&str> = position.fen.split(' ').collect(); + assert_eq!(fen_parts.len(), 6); + assert_eq!(fen_parts[1], "w"); // White to move + assert_eq!(fen_parts[2], "KQkq"); // Castling rights + assert_eq!(fen_parts[3], "-"); // No en passant + assert_eq!(fen_parts[4], "0"); // Halfmove clock + assert_eq!(fen_parts[5], "1"); // Fullmove number + } + } +} diff --git a/backend/src/chess960/mod.rs b/backend/src/chess960/mod.rs index c42bfe0d..fe1790bd 100644 --- a/backend/src/chess960/mod.rs +++ b/backend/src/chess960/mod.rs @@ -1,7 +1,7 @@ -pub mod models; -pub mod generator; -pub mod api; - -pub use models::*; -pub use generator::Chess960Generator; -pub use api::configure_routes; \ No newline at end of file +pub mod api; +pub mod generator; +pub mod models; + +pub use api::configure_routes; +pub use generator::Chess960Generator; +pub use models::*; diff --git a/backend/src/chess960/models.rs b/backend/src/chess960/models.rs index 782637d4..9fb366c3 100644 --- a/backend/src/chess960/models.rs +++ b/backend/src/chess960/models.rs @@ -1,28 +1,28 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Chess960Position { - pub position_number: u16, - pub fen: String, - pub back_rank: String, - pub white_king_pos: usize, - pub white_rook_positions: [usize; 2], - pub white_bishop_positions: [usize; 2], - pub white_knight_positions: [usize; 2], - pub white_queen_pos: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Chess960Library { - pub positions: HashMap, - pub total_positions: u16, - pub metadata: LibraryMetadata, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LibraryMetadata { - pub version: String, - pub generated_at: String, - pub description: String, -} \ No newline at end of file +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Chess960Position { + pub position_number: u16, + pub fen: String, + pub back_rank: String, + pub white_king_pos: usize, + pub white_rook_positions: [usize; 2], + pub white_bishop_positions: [usize; 2], + pub white_knight_positions: [usize; 2], + pub white_queen_pos: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Chess960Library { + pub positions: HashMap, + pub total_positions: u16, + pub metadata: LibraryMetadata, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LibraryMetadata { + pub version: String, + pub generated_at: String, + pub description: String, +} diff --git a/backend/src/engine/lc0_orchestrator.rs b/backend/src/engine/lc0_orchestrator.rs index d8bf6fc4..973e2e8b 100644 --- a/backend/src/engine/lc0_orchestrator.rs +++ b/backend/src/engine/lc0_orchestrator.rs @@ -1,5 +1,11 @@ pub struct Lc0Orchestrator; +impl Default for Lc0Orchestrator { + fn default() -> Self { + Self::new() + } +} + impl Lc0Orchestrator { pub fn new() -> Self { Lc0Orchestrator diff --git a/backend/src/main.rs b/backend/src/main.rs index 850625ea..94ee2437 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,4 +8,4 @@ use api::server; #[actix_web::main] async fn main() -> std::io::Result<()> { server::main().await -} \ No newline at end of file +} diff --git a/contracts/game_contract/src/lib.rs b/contracts/game_contract/src/lib.rs index c73cfc75..929d889c 100644 --- a/contracts/game_contract/src/lib.rs +++ b/contracts/game_contract/src/lib.rs @@ -501,7 +501,12 @@ impl GameContract { Ok(()) } - pub fn claim_draw(env: Env, game_id: u64, player: Address, signature: BytesN<64>) -> Result<(), ContractError> { + pub fn claim_draw( + env: Env, + game_id: u64, + player: Address, + signature: BytesN<64>, + ) -> Result<(), ContractError> { let mut games: Map = env .storage() .instance() @@ -1233,10 +1238,8 @@ impl GameContract { env.storage().instance().set(&BALANCES, &balances); env.storage().instance().set(&TREASURY, &treasury); - env.events().publish( - (symbol_short!("pzlbatch"),), - (proofs.len(), total_claimed), - ); + env.events() + .publish((symbol_short!("pzlbatch"),), (proofs.len(), total_claimed)); Ok(()) } @@ -1751,10 +1754,8 @@ impl GameContract { verified.set(account.clone(), true); env.storage().instance().set(&SEP10_VERIFIED, &verified); - env.events().publish( - (symbol_short!("sep10"), symbol_short!("verified")), - account, - ); + env.events() + .publish((symbol_short!("sep10"), symbol_short!("verified")), account); Ok(()) } @@ -1808,7 +1809,9 @@ impl GameContract { } env.storage().instance().set(&MULTISIG_SIGNERS, &signers); - env.storage().instance().set(&MULTISIG_THRESHOLD, &threshold); + env.storage() + .instance() + .set(&MULTISIG_THRESHOLD, &threshold); Ok(()) } @@ -1952,11 +1955,7 @@ impl GameContract { return Err(ContractError::NotASigner); } - if !env - .storage() - .instance() - .has(&PENDING_FEE_PROPOSAL) - { + if !env.storage().instance().has(&PENDING_FEE_PROPOSAL) { return Err(ContractError::NoProposal); } @@ -1965,10 +1964,8 @@ impl GameContract { env.storage().instance().remove(&PENDING_FEE_PROPOSAL); env.storage().instance().remove(&FEE_PROPOSAL_APPROVALS); - env.events().publish( - (symbol_short!("multisig"), symbol_short!("cancel")), - signer, - ); + env.events() + .publish((symbol_short!("multisig"), symbol_short!("cancel")), signer); Ok(()) } @@ -1995,7 +1992,11 @@ impl GameContract { // to get a trusted timestamp for game clock synchronization. /// Configure the SEP-40 oracle contract address for clock sync. - pub fn configure_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), ContractError> { + pub fn configure_oracle( + env: Env, + admin: Address, + oracle: Address, + ) -> Result<(), ContractError> { let current_admin: Address = env .storage() .instance() @@ -2060,7 +2061,9 @@ impl GameContract { if duration == 0 { panic!("Timelock duration must be greater than 0"); } - env.storage().instance().set(&TOURNAMENT_TIMELOCK, &duration); + env.storage() + .instance() + .set(&TOURNAMENT_TIMELOCK, &duration); Ok(()) } @@ -2068,10 +2071,7 @@ impl GameContract { /// /// Locks the total prize pool until `current_ledger + timelock_duration`. /// Returns the escrow ID. - pub fn create_tournament_escrow( - env: Env, - game_id: u64, - ) -> Result { + pub fn create_tournament_escrow(env: Env, game_id: u64) -> Result { let games: Map = env .storage() .instance() @@ -2125,33 +2125,35 @@ impl GameContract { Ok(escrow_id) } - /// Release a time-locked tournament escrow to the specified winners. -/// -/// Can only be called after the lock period has expired, and only by the -/// contract admin. -pub fn release_tournament_escrow( - env: Env, - admin: Address, - escrow_id: u64, - winners: Vec
, - percentages: Vec, -) -> Result<(), ContractError> { - let current_admin: Address = env - .storage() - .instance() - .get(&CONTRACT_ADMIN) - .expect("Not initialized"); - current_admin.require_auth(); - if admin != current_admin { - return Err(ContractError::Unauthorized); - } + /// Release a time-locked tournament escrow to the specified winners. + /// + /// Can only be called after the lock period has expired, and only by the + /// contract admin. + pub fn release_tournament_escrow( + env: Env, + admin: Address, + escrow_id: u64, + winners: Vec
, + percentages: Vec, + ) -> Result<(), ContractError> { + let current_admin: Address = env + .storage() + .instance() + .get(&CONTRACT_ADMIN) + .expect("Not initialized"); + current_admin.require_auth(); + if admin != current_admin { + return Err(ContractError::Unauthorized); + } let mut escrows: Map = env .storage() .instance() .get(&TOURNAMENT_ESCROWS) .ok_or(ContractError::EscrowNotFound)?; - let escrow = escrows.get(escrow_id).ok_or(ContractError::EscrowNotFound)?; + let escrow = escrows + .get(escrow_id) + .ok_or(ContractError::EscrowNotFound)?; if escrow.released { return Err(ContractError::EscrowAlreadyReleased); @@ -2213,7 +2215,10 @@ pub fn release_tournament_escrow( } /// Query a tournament escrow by ID. - pub fn get_tournament_escrow(env: Env, escrow_id: u64) -> Result { + pub fn get_tournament_escrow( + env: Env, + escrow_id: u64, + ) -> Result { let escrows: Map = env .storage() .instance() From 2c8f819dc7796ff87f1608edbbf744c80ae369c5 Mon Sep 17 00:00:00 2001 From: Malik Abdul Date: Wed, 29 Jul 2026 15:29:27 +0000 Subject: [PATCH 3/3] fix: remove unused std::thread and std::time::Duration imports from rate_limit test --- backend/modules/api/src/test/rate_limit.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/modules/api/src/test/rate_limit.rs b/backend/modules/api/src/test/rate_limit.rs index e36e9920..b60fa769 100644 --- a/backend/modules/api/src/test/rate_limit.rs +++ b/backend/modules/api/src/test/rate_limit.rs @@ -1,7 +1,5 @@ use actix_governor::{Governor, GovernorConfigBuilder}; use actix_web::{test, web, App, HttpResponse, Responder}; -use std::thread; -use std::time::Duration; async fn mock_handler() -> impl Responder { HttpResponse::Ok().body("OK")