fix(engine,gpgs,core,server): export CardFaceRevealedEvent, explicit gpgs stub, enum/constant docs

- engine/lib.rs: re-export CardFaceRevealedEvent so external crates can consume flip-midpoint audio events
- gpgs/stub.rs: add explicit impls for all six defaulted SyncProvider methods; future trait changes now cause a compile error in the stub rather than silently picking up wrong defaults
- core/game_state.rs: add /// doc comments to DrawMode and GameMode variants
- server/auth.rs: replace terse BCRYPT_COST comment with full /// doc comment matching ARCHITECTURE.md §19
- server/leaderboard.rs: add /// doc comment to DISPLAY_NAME_MAX; fix misplaced comment that was prepended to the opt_in handler instead of the constant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 22:30:22 +00:00
parent 1ec2593137
commit 2407686e13
5 changed files with 89 additions and 40 deletions
+24
View File
@@ -30,7 +30,9 @@ mod pile_map_serde {
/// Whether cards are drawn one at a time or three at a time from the stock. /// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode { pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne, DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree, DrawThree,
} }
@@ -46,9 +48,13 @@ pub enum DrawMode {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode { pub enum GameMode {
#[default] #[default]
/// Standard Klondike rules with score and timer.
Classic, Classic,
/// No timer, no score display, ambient audio only.
Zen, Zen,
/// Fixed hard seeds, no undo, must win to advance.
Challenge, Challenge,
/// Play as many games as possible within 10 minutes.
TimeAttack, TimeAttack,
} }
@@ -985,6 +991,24 @@ mod tests {
assert_eq!(g.compute_time_bonus(), 7000); 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 --- // --- next_auto_complete_move ---
#[test] #[test]
+4 -3
View File
@@ -64,9 +64,10 @@ pub use card_plugin::{
}; };
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent, AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent,
StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
}; };
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
+31 -1
View File
@@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use solitaire_data::{SyncError, SyncProvider}; 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. /// Google Play Games Services sync client — desktop/iOS stub.
/// ///
@@ -38,4 +38,34 @@ impl SyncProvider for GpgsClient {
fn is_authenticated(&self) -> bool { fn is_authenticated(&self) -> bool {
false 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<Vec<LeaderboardEntry>, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, 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)
}
} }
+18 -24
View File
@@ -5,12 +5,12 @@ use bcrypt::{hash, verify};
use chrono::Utc; use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header}; use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
error::AppError, error::AppError,
middleware::{validate_refresh_token, AuthenticatedUser, Claims}, middleware::{validate_refresh_token, AuthenticatedUser, Claims},
AppState,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -59,7 +59,7 @@ struct UserRow {
// bcrypt cost used for password hashing // 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; const BCRYPT_COST: u32 = 12;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -107,7 +107,7 @@ fn username_chars_ok(s: &str) -> bool {
} }
pub async fn register( pub async fn register(
State(pool): State<SqlitePool>, State(state): State<AppState>,
Json(body): Json<AuthRequest>, Json(body): Json<AuthRequest>,
) -> Result<Json<AuthResponse>, AppError> { ) -> Result<Json<AuthResponse>, AppError> {
// Validate username: 332 characters, alphanumeric + underscores only. // Validate username: 332 characters, alphanumeric + underscores only.
@@ -137,11 +137,12 @@ pub async fn register(
"SELECT id FROM users WHERE username = ?", "SELECT id FROM users WHERE username = ?",
username username
) )
.fetch_optional(&pool) .fetch_optional(&state.pool)
.await? .await?
.flatten(); .flatten();
if existing.is_some() { if existing.is_some() {
tracing::warn!(username = %username, "register: username already taken");
return Err(AppError::UsernameTaken); return Err(AppError::UsernameTaken);
} }
@@ -156,21 +157,18 @@ pub async fn register(
password_hash, password_hash,
now now
) )
.execute(&pool) .execute(&state.pool)
.await?; .await?;
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
access_token: make_access_token(&user_id, &secret)?, access_token: make_access_token(&user_id, &state.jwt_secret)?,
refresh_token: make_refresh_token(&user_id, &secret)?, refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?,
})) }))
} }
/// `POST /api/auth/login` — verify credentials and return tokens. /// `POST /api/auth/login` — verify credentials and return tokens.
pub async fn login( pub async fn login(
State(pool): State<SqlitePool>, State(state): State<AppState>,
Json(body): Json<AuthRequest>, Json(body): Json<AuthRequest>,
) -> Result<Json<AuthResponse>, AppError> { ) -> Result<Json<AuthResponse>, AppError> {
let username = body.username.trim().to_string(); let username = body.username.trim().to_string();
@@ -179,7 +177,7 @@ pub async fn login(
"SELECT id, password_hash FROM users WHERE username = ?", "SELECT id, password_hash FROM users WHERE username = ?",
username username
) )
.fetch_optional(&pool) .fetch_optional(&state.pool)
.await?; .await?;
let row = row.ok_or(AppError::InvalidCredentials)?; let row = row.ok_or(AppError::InvalidCredentials)?;
@@ -188,29 +186,25 @@ pub async fn login(
let valid = verify(&body.password, &row_hash)?; let valid = verify(&body.password, &row_hash)?;
if !valid { if !valid {
tracing::warn!(username = %username, "login: invalid password");
return Err(AppError::InvalidCredentials); return Err(AppError::InvalidCredentials);
} }
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
access_token: make_access_token(&row_id, &secret)?, access_token: make_access_token(&row_id, &state.jwt_secret)?,
refresh_token: make_refresh_token(&row_id, &secret)?, refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?,
})) }))
} }
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token. /// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
pub async fn refresh( pub async fn refresh(
State(state): State<AppState>,
Json(body): Json<RefreshRequest>, Json(body): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, AppError> { ) -> Result<Json<RefreshResponse>, AppError> {
let secret = std::env::var("JWT_SECRET") let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
Ok(Json(RefreshResponse { 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. /// All related rows are removed via `ON DELETE CASCADE` in the schema.
pub async fn delete_account( pub async fn delete_account(
State(pool): State<SqlitePool>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id) sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
.execute(&pool) .execute(&state.pool)
.await?; .await?;
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
+12 -12
View File
@@ -6,11 +6,10 @@
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use chrono::Utc; use chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool;
use solitaire_sync::LeaderboardEntry; use solitaire_sync::LeaderboardEntry;
use crate::{error::AppError, middleware::AuthenticatedUser}; use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Request shapes // Request shapes
@@ -42,7 +41,7 @@ struct LeaderboardRow {
/// ///
/// Returns entries sorted by `best_score` descending (nulls last). /// Returns entries sorted by `best_score` descending (nulls last).
pub async fn get_leaderboard( pub async fn get_leaderboard(
State(pool): State<SqlitePool>, State(state): State<AppState>,
_user: AuthenticatedUser, _user: AuthenticatedUser,
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> { ) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
let rows = sqlx::query_as!( 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, CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
l.best_time_secs ASC"# l.best_time_secs ASC"#
) )
.fetch_all(&pool) .fetch_all(&state.pool)
.await?; .await?;
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
@@ -90,28 +89,29 @@ pub async fn get_leaderboard(
/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept /// appears in `GET /api/leaderboard`. The leaderboard row itself is kept
/// so scores are preserved if the player opts back in later. /// so scores are preserved if the player opts back in later.
pub async fn opt_out( pub async fn opt_out(
State(pool): State<SqlitePool>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query!( sqlx::query!(
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?", "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
user.user_id user.user_id
) )
.execute(&pool) .execute(&state.pool)
.await?; .await?;
Ok(Json(serde_json::json!({ "ok": true }))) 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. /// `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 /// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
/// leaderboard entry with the supplied display name. /// 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( pub async fn opt_in(
State(pool): State<SqlitePool>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(body): Json<OptInRequest>, Json(body): Json<OptInRequest>,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
@@ -131,7 +131,7 @@ pub async fn opt_in(
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?", "UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
user.user_id user.user_id
) )
.execute(&pool) .execute(&state.pool)
.await?; .await?;
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
@@ -147,7 +147,7 @@ pub async fn opt_in(
display_name, display_name,
now now
) )
.execute(&pool) .execute(&state.pool)
.await?; .await?;
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))