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:
@@ -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]
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 3–32 characters, alphanumeric + underscores only.
|
// Validate username: 3–32 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 })))
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
|||||||
Reference in New Issue
Block a user