6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
252 lines
8.4 KiB
Rust
252 lines
8.4 KiB
Rust
//! Daily challenge endpoint.
|
|
//!
|
|
//! `GET /api/daily-challenge` — returns the challenge for today's date.
|
|
//!
|
|
//! The seed is deterministic (same for all players worldwide) and is
|
|
//! generated on first request for that date, then stored in the database
|
|
//! so subsequent calls return the same value.
|
|
|
|
use axum::{Json, extract::State};
|
|
use chrono::Utc;
|
|
|
|
use solitaire_sync::ChallengeGoal;
|
|
|
|
use crate::{AppState, error::AppError};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seed generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Compute a deterministic seed from a date string such as `"2026-04-26"`.
|
|
///
|
|
/// Uses a simple polynomial rolling hash over the UTF-8 bytes of the string.
|
|
/// The computation is identical across all server instances and all clients
|
|
/// that implement the same algorithm.
|
|
pub fn hash_date_to_u64(date: &str) -> u64 {
|
|
date.bytes()
|
|
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
|
|
}
|
|
|
|
/// Generate a [`ChallengeGoal`] from a seed and date.
|
|
///
|
|
/// The goal type and parameters are derived deterministically from the seed
|
|
/// so all players face exactly the same challenge on the same day.
|
|
fn generate_goal(date: &str, seed: u64) -> ChallengeGoal {
|
|
// Pick a goal variant based on seed modulo number-of-variants.
|
|
// Six variants give a fortnight of variety before any repeat.
|
|
match seed % 6 {
|
|
0 => ChallengeGoal {
|
|
date: date.to_string(),
|
|
seed,
|
|
description: "Win in under 5 minutes".to_string(),
|
|
target_score: None,
|
|
max_time_secs: Some(300),
|
|
},
|
|
1 => ChallengeGoal {
|
|
date: date.to_string(),
|
|
seed,
|
|
description: "Reach a score of 4 000 or more".to_string(),
|
|
target_score: Some(4_000),
|
|
max_time_secs: None,
|
|
},
|
|
2 => ChallengeGoal {
|
|
date: date.to_string(),
|
|
seed,
|
|
description: "Win in under 3 minutes".to_string(),
|
|
target_score: None,
|
|
max_time_secs: Some(180),
|
|
},
|
|
3 => ChallengeGoal {
|
|
date: date.to_string(),
|
|
seed,
|
|
description: "Reach a score of 5 000 or more".to_string(),
|
|
target_score: Some(5_000),
|
|
max_time_secs: None,
|
|
},
|
|
4 => ChallengeGoal {
|
|
date: date.to_string(),
|
|
seed,
|
|
description: "Win in under 8 minutes".to_string(),
|
|
target_score: None,
|
|
max_time_secs: Some(480),
|
|
},
|
|
_ => ChallengeGoal {
|
|
date: date.to_string(),
|
|
seed,
|
|
description: "Win today's deal".to_string(),
|
|
target_score: None,
|
|
max_time_secs: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Database row helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct ChallengeRow {
|
|
goal_json: Option<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `GET /api/daily-challenge` — no auth required.
|
|
///
|
|
/// Looks up today's challenge in the database. If none exists yet, generates
|
|
/// one deterministically and stores it before returning.
|
|
///
|
|
/// The `INSERT OR IGNORE` followed by a re-SELECT ensures that concurrent
|
|
/// requests racing to create today's row all return the same persisted value
|
|
/// rather than each returning their own locally-generated copy.
|
|
pub async fn daily_challenge(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<ChallengeGoal>, AppError> {
|
|
let today = Utc::now().format("%Y-%m-%d").to_string();
|
|
|
|
// Try to load an existing row first (fast path — no generation needed).
|
|
let row = sqlx::query_as!(
|
|
ChallengeRow,
|
|
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
|
today
|
|
)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
if let Some(r) = row {
|
|
let json = r
|
|
.goal_json
|
|
.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
|
|
let goal: ChallengeGoal = serde_json::from_str(&json)?;
|
|
return Ok(Json(goal));
|
|
}
|
|
|
|
// No row yet — generate the goal locally and attempt to store it.
|
|
// `INSERT OR IGNORE` means a concurrent request that wins the race will
|
|
// silently ignore our insert. We then re-SELECT to ensure both requests
|
|
// return the same persisted row regardless of which one won.
|
|
let seed = hash_date_to_u64(&today);
|
|
let goal = generate_goal(&today, seed);
|
|
let goal_json = serde_json::to_string(&goal)?;
|
|
let seed_i64 = seed as i64;
|
|
|
|
sqlx::query!(
|
|
"INSERT OR IGNORE INTO daily_challenges (date, seed, goal_json) VALUES (?, ?, ?)",
|
|
today,
|
|
seed_i64,
|
|
goal_json
|
|
)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// Re-SELECT to return exactly what is stored — handles the race where
|
|
// another request inserted a row between our initial SELECT and INSERT.
|
|
let stored = sqlx::query_as!(
|
|
ChallengeRow,
|
|
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
|
today
|
|
)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
let stored_json = stored
|
|
.goal_json
|
|
.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
|
|
let stored_goal: ChallengeGoal = serde_json::from_str(&stored_json)?;
|
|
Ok(Json(stored_goal))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn hash_date_is_deterministic() {
|
|
let date = "2026-04-26";
|
|
assert_eq!(hash_date_to_u64(date), hash_date_to_u64(date));
|
|
}
|
|
|
|
#[test]
|
|
fn hash_date_differs_across_adjacent_days() {
|
|
assert_ne!(
|
|
hash_date_to_u64("2026-04-26"),
|
|
hash_date_to_u64("2026-04-27")
|
|
);
|
|
assert_ne!(
|
|
hash_date_to_u64("2026-04-26"),
|
|
hash_date_to_u64("2026-04-25")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hash_date_differs_across_years() {
|
|
assert_ne!(
|
|
hash_date_to_u64("2026-01-01"),
|
|
hash_date_to_u64("2027-01-01")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hash_date_is_nonzero_for_real_dates() {
|
|
// Zero would be pathological — every date must produce a non-zero seed
|
|
// so the RNG initialises properly.
|
|
assert_ne!(hash_date_to_u64("2026-04-26"), 0);
|
|
assert_ne!(hash_date_to_u64("2026-01-01"), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn generate_goal_covers_all_six_variants() {
|
|
// The six variants are selected by seed % 6. Verify each branch
|
|
// produces a non-empty description and a non-empty date string.
|
|
for variant_idx in 0u64..6 {
|
|
let goal = generate_goal("2026-04-26", variant_idx);
|
|
assert_eq!(goal.date, "2026-04-26");
|
|
assert!(!goal.description.is_empty());
|
|
// seed field must match the passed-in seed.
|
|
assert_eq!(goal.seed, variant_idx);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generate_goal_time_and_score_variants_are_set_correctly() {
|
|
// Variant 0: max_time_secs = 300, no score.
|
|
let g = generate_goal("2026-04-26", 0);
|
|
assert_eq!(g.max_time_secs, Some(300));
|
|
assert!(g.target_score.is_none());
|
|
|
|
// Variant 1: target_score = 4000, no time.
|
|
let g = generate_goal("2026-04-26", 1);
|
|
assert_eq!(g.target_score, Some(4_000));
|
|
assert!(g.max_time_secs.is_none());
|
|
|
|
// Variant 5: fallback — no time, no score (just win).
|
|
let g = generate_goal("2026-04-26", 5);
|
|
assert!(g.target_score.is_none());
|
|
assert!(g.max_time_secs.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn generate_goal_all_variants_have_sane_ranges() {
|
|
for variant_idx in 0u64..6 {
|
|
let g = generate_goal("2026-04-26", variant_idx);
|
|
assert!(
|
|
!g.description.is_empty(),
|
|
"variant {variant_idx}: description must not be empty"
|
|
);
|
|
if let Some(t) = g.max_time_secs {
|
|
assert!(
|
|
(60..=3600).contains(&t),
|
|
"variant {variant_idx}: max_time_secs {t} outside [60, 3600]"
|
|
);
|
|
}
|
|
if let Some(s) = g.target_score {
|
|
assert!(
|
|
(1_000..=10_000).contains(&s),
|
|
"variant {variant_idx}: target_score {s} outside [1000, 10000]"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|