diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index cc758b5..37faa7a 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -30,7 +30,9 @@ mod pile_map_serde { /// Whether cards are drawn one at a time or three at a time from the stock. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DrawMode { + /// Draw one card from stock per turn. DrawOne, + /// Draw three cards from stock per turn; only the top is playable. DrawThree, } @@ -46,9 +48,13 @@ pub enum DrawMode { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum GameMode { #[default] + /// Standard Klondike rules with score and timer. Classic, + /// No timer, no score display, ambient audio only. Zen, + /// Fixed hard seeds, no undo, must win to advance. Challenge, + /// Play as many games as possible within 10 minutes. TimeAttack, } @@ -985,6 +991,24 @@ mod tests { assert_eq!(g.compute_time_bonus(), 7000); } + // --- EmptySource error path --- + + #[test] + fn move_from_empty_pile_returns_empty_source() { + // Build a game state, clear a tableau pile entirely, then attempt to + // move from it. The source pile exists in `piles` (key is present) but + // contains no cards — exactly the code path that returns EmptySource. + let mut g = new_game(); + // Tableau(0) starts with exactly 1 card; clear it to make the pile empty. + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); + let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1); + assert_eq!( + result, + Err(MoveError::EmptySource), + "moving from an empty pile must return EmptySource" + ); + } + // --- next_auto_complete_move --- #[test] diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 4f4b806..db39bf9 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -64,9 +64,10 @@ pub use card_plugin::{ }; pub use cursor_plugin::CursorPlugin; pub use events::{ - AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent, - HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, - NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent, + AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, + ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, + MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, + StateChangedEvent, UndoRequestEvent, XpAwardedEvent, }; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use help_plugin::{HelpPlugin, HelpScreen}; diff --git a/solitaire_gpgs/src/stub.rs b/solitaire_gpgs/src/stub.rs index f5097c5..95ce325 100644 --- a/solitaire_gpgs/src/stub.rs +++ b/solitaire_gpgs/src/stub.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use solitaire_data::{SyncError, SyncProvider}; -use solitaire_sync::{SyncPayload, SyncResponse}; +use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; /// Google Play Games Services sync client — desktop/iOS stub. /// @@ -38,4 +38,34 @@ impl SyncProvider for GpgsClient { fn is_authenticated(&self) -> bool { false } + + /// No-op stub — returns UnsupportedPlatform on non-Android targets. + async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> { + Err(SyncError::UnsupportedPlatform) + } + + /// No-op stub — returns UnsupportedPlatform on non-Android targets. + async fn fetch_leaderboard(&self) -> Result, SyncError> { + Err(SyncError::UnsupportedPlatform) + } + + /// No-op stub — returns UnsupportedPlatform on non-Android targets. + async fn fetch_daily_challenge(&self) -> Result, SyncError> { + Err(SyncError::UnsupportedPlatform) + } + + /// No-op stub — returns UnsupportedPlatform on non-Android targets. + async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> { + Err(SyncError::UnsupportedPlatform) + } + + /// No-op stub — returns UnsupportedPlatform on non-Android targets. + async fn opt_out_leaderboard(&self) -> Result<(), SyncError> { + Err(SyncError::UnsupportedPlatform) + } + + /// No-op stub — returns UnsupportedPlatform on non-Android targets. + async fn delete_account(&self) -> Result<(), SyncError> { + Err(SyncError::UnsupportedPlatform) + } } diff --git a/solitaire_server/src/auth.rs b/solitaire_server/src/auth.rs index 224c43d..7b7941c 100644 --- a/solitaire_server/src/auth.rs +++ b/solitaire_server/src/auth.rs @@ -5,12 +5,12 @@ use bcrypt::{hash, verify}; use chrono::Utc; use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; -use sqlx::SqlitePool; use uuid::Uuid; use crate::{ error::AppError, middleware::{validate_refresh_token, AuthenticatedUser, Claims}, + AppState, }; // --------------------------------------------------------------------------- @@ -59,7 +59,7 @@ struct UserRow { // bcrypt cost used for password hashing // --------------------------------------------------------------------------- -/// bcrypt cost factor. Per ARCHITECTURE.md §19 this must be 12. +/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency. const BCRYPT_COST: u32 = 12; // --------------------------------------------------------------------------- @@ -107,7 +107,7 @@ fn username_chars_ok(s: &str) -> bool { } pub async fn register( - State(pool): State, + State(state): State, Json(body): Json, ) -> Result, AppError> { // Validate username: 3–32 characters, alphanumeric + underscores only. @@ -137,11 +137,12 @@ pub async fn register( "SELECT id FROM users WHERE username = ?", username ) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await? .flatten(); if existing.is_some() { + tracing::warn!(username = %username, "register: username already taken"); return Err(AppError::UsernameTaken); } @@ -156,21 +157,18 @@ pub async fn register( password_hash, now ) - .execute(&pool) + .execute(&state.pool) .await?; - let secret = std::env::var("JWT_SECRET") - .map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?; - Ok(Json(AuthResponse { - access_token: make_access_token(&user_id, &secret)?, - refresh_token: make_refresh_token(&user_id, &secret)?, + access_token: make_access_token(&user_id, &state.jwt_secret)?, + refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?, })) } /// `POST /api/auth/login` — verify credentials and return tokens. pub async fn login( - State(pool): State, + State(state): State, Json(body): Json, ) -> Result, AppError> { let username = body.username.trim().to_string(); @@ -179,7 +177,7 @@ pub async fn login( "SELECT id, password_hash FROM users WHERE username = ?", username ) - .fetch_optional(&pool) + .fetch_optional(&state.pool) .await?; let row = row.ok_or(AppError::InvalidCredentials)?; @@ -188,29 +186,25 @@ pub async fn login( let valid = verify(&body.password, &row_hash)?; if !valid { + tracing::warn!(username = %username, "login: invalid password"); return Err(AppError::InvalidCredentials); } - let secret = std::env::var("JWT_SECRET") - .map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?; - Ok(Json(AuthResponse { - access_token: make_access_token(&row_id, &secret)?, - refresh_token: make_refresh_token(&row_id, &secret)?, + access_token: make_access_token(&row_id, &state.jwt_secret)?, + refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?, })) } /// `POST /api/auth/refresh` — exchange a refresh token for a new access token. pub async fn refresh( + State(state): State, Json(body): Json, ) -> Result, AppError> { - let secret = std::env::var("JWT_SECRET") - .map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?; - - let claims = validate_refresh_token(&body.refresh_token, &secret)?; + let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?; Ok(Json(RefreshResponse { - access_token: make_access_token(&claims.sub, &secret)?, + access_token: make_access_token(&claims.sub, &state.jwt_secret)?, })) } @@ -218,11 +212,11 @@ pub async fn refresh( /// /// All related rows are removed via `ON DELETE CASCADE` in the schema. pub async fn delete_account( - State(pool): State, + State(state): State, user: AuthenticatedUser, ) -> Result, AppError> { sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id) - .execute(&pool) + .execute(&state.pool) .await?; Ok(Json(serde_json::json!({ "ok": true }))) diff --git a/solitaire_server/src/leaderboard.rs b/solitaire_server/src/leaderboard.rs index 562cee6..52daf86 100644 --- a/solitaire_server/src/leaderboard.rs +++ b/solitaire_server/src/leaderboard.rs @@ -6,11 +6,10 @@ use axum::{extract::State, Json}; use chrono::Utc; use serde::Deserialize; -use sqlx::SqlitePool; use solitaire_sync::LeaderboardEntry; -use crate::{error::AppError, middleware::AuthenticatedUser}; +use crate::{error::AppError, middleware::AuthenticatedUser, AppState}; // --------------------------------------------------------------------------- // Request shapes @@ -42,7 +41,7 @@ struct LeaderboardRow { /// /// Returns entries sorted by `best_score` descending (nulls last). pub async fn get_leaderboard( - State(pool): State, + State(state): State, _user: AuthenticatedUser, ) -> Result>, AppError> { let rows = sqlx::query_as!( @@ -57,7 +56,7 @@ pub async fn get_leaderboard( CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC, l.best_time_secs ASC"# ) - .fetch_all(&pool) + .fetch_all(&state.pool) .await?; let entries: Result, AppError> = rows @@ -90,28 +89,29 @@ pub async fn get_leaderboard( /// appears in `GET /api/leaderboard`. The leaderboard row itself is kept /// so scores are preserved if the player opts back in later. pub async fn opt_out( - State(pool): State, + State(state): State, user: AuthenticatedUser, ) -> Result, AppError> { sqlx::query!( "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?", user.user_id ) - .execute(&pool) + .execute(&state.pool) .await?; Ok(Json(serde_json::json!({ "ok": true }))) } +/// Maximum allowed character count for a leaderboard display name (32 chars). +/// Keeps names readable in the leaderboard UI while allowing reasonable creativity. +const DISPLAY_NAME_MAX: usize = 32; + /// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry. /// /// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the /// leaderboard entry with the supplied display name. -/// Maximum allowed length for a leaderboard display name. -const DISPLAY_NAME_MAX: usize = 32; - pub async fn opt_in( - State(pool): State, + State(state): State, user: AuthenticatedUser, Json(body): Json, ) -> Result, AppError> { @@ -131,7 +131,7 @@ pub async fn opt_in( "UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?", user.user_id ) - .execute(&pool) + .execute(&state.pool) .await?; let now = Utc::now().to_rfc3339(); @@ -147,7 +147,7 @@ pub async fn opt_in( display_name, now ) - .execute(&pool) + .execute(&state.pool) .await?; Ok(Json(serde_json::json!({ "ok": true })))