From 9a388738910a8685e2de2acd3f73ab81dc9fff23 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 01:09:24 +0000 Subject: [PATCH] feat(engine): fetch daily-challenge seed from server on startup - Add fetch_daily_challenge() to SyncProvider trait (default: Ok(None)) - SolitaireServerClient calls GET /api/daily-challenge (public endpoint) and returns the ChallengeGoal; non-2xx responses return Ok(None) so callers fall back to the local date-hash seed - DailyChallengePlugin spawns an async task on Startup (only when SyncProviderResource is present) and polls it in Update; on success it overwrites DailyChallengeResource.seed with the server's seed, ensuring all players worldwide get the same deal on a given date Co-Authored-By: Claude Sonnet 4.6 --- solitaire_data/src/lib.rs | 10 +++- solitaire_data/src/sync_client.rs | 27 ++++++++- .../src/daily_challenge_plugin.rs | 59 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index f4650de..8a3b5a0 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use solitaire_sync::{LeaderboardEntry, SyncPayload, SyncResponse}; +use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; use thiserror::Error; /// All errors that can arise during sync operations. @@ -36,6 +36,11 @@ pub trait SyncProvider: Send + Sync { async fn fetch_leaderboard(&self) -> Result, SyncError> { Ok(vec![]) } + /// Fetch today's daily challenge from the server. Returns `None` for + /// backends that don't support it, or on any non-fatal network failure. + async fn fetch_daily_challenge(&self) -> Result, SyncError> { + Ok(None) + } } /// Blanket impl so `Box` (returned by @@ -60,6 +65,9 @@ impl SyncProvider for Box { async fn fetch_leaderboard(&self) -> Result, SyncError> { (**self).fetch_leaderboard().await } + async fn fetch_daily_challenge(&self) -> Result, SyncError> { + (**self).fetch_daily_challenge().await + } } pub mod stats; diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index c764206..76668bf 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -12,7 +12,7 @@ //! without matching on [`SyncBackend`] anywhere else in the codebase. use async_trait::async_trait; -use solitaire_sync::{LeaderboardEntry, SyncPayload, SyncResponse}; +use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; use crate::{ auth_tokens::{load_access_token, load_refresh_token, store_tokens}, @@ -200,6 +200,31 @@ impl SyncProvider for SolitaireServerClient { load_access_token(&self.username).is_ok() } + /// Fetch today's daily challenge from the server. + /// + /// Does not require authentication — the endpoint is public. Returns `None` + /// on any non-success HTTP status so the caller falls back to the local seed. + async fn fetch_daily_challenge(&self) -> Result, SyncError> { + let url = format!("{}/api/daily-challenge", self.base_url); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + + if resp.status().is_success() { + let goal: ChallengeGoal = resp + .json() + .await + .map_err(|e| SyncError::Serialization(e.to_string()))?; + Ok(Some(goal)) + } else { + // Non-fatal — caller will use the locally computed seed instead. + Ok(None) + } + } + async fn fetch_leaderboard(&self) -> Result, SyncError> { let token = self.access_token()?; let url = format!("{}/api/leaderboard", self.base_url); diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 1b0f12d..e777efd 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -13,13 +13,16 @@ use bevy::input::ButtonInput; use bevy::prelude::*; +use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use chrono::{Local, NaiveDate}; use solitaire_data::{daily_seed_for, save_progress_to}; +use solitaire_sync::ChallengeGoal; use crate::events::{GameWonEvent, NewGameRequestEvent}; use crate::game_plugin::GameMutation; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; +use crate::sync_plugin::SyncProviderResource; /// Bonus XP awarded for completing today's daily challenge. pub const DAILY_BONUS_XP: u64 = 100; @@ -48,14 +51,22 @@ pub struct DailyChallengeCompletedEvent { pub streak: u32, } +/// Holds the in-flight server challenge fetch so the result can be polled +/// each frame without blocking the main thread. +#[derive(Resource, Default)] +struct DailyChallengeTask(Option>>); + pub struct DailyChallengePlugin; impl Plugin for DailyChallengePlugin { fn build(&self, app: &mut App) { app.insert_resource(DailyChallengeResource::for_today()) + .init_resource::() .add_event::() .add_event::() .add_event::() + .add_systems(Startup, fetch_server_challenge) + .add_systems(Update, poll_server_challenge) // record/award after the base ProgressUpdate so we don't fight // ProgressPlugin's add_xp on the same frame. .add_systems(Update, handle_daily_completion.after(ProgressUpdate)) @@ -63,6 +74,54 @@ impl Plugin for DailyChallengePlugin { } } +/// Startup system: spawns an async task to fetch the server's daily challenge. +/// +/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is +/// installed). The endpoint is public so authentication is not required. +fn fetch_server_challenge( + provider: Option>, + mut task_res: ResMut, +) { + let Some(provider) = provider else { return }; + let provider = provider.0.clone(); + let task = AsyncComputeTaskPool::get() + .spawn(async move { provider.fetch_daily_challenge().await.ok().flatten() }); + task_res.0 = Some(task); +} + +/// Update system: polls the server-challenge fetch task. +/// +/// On success, replaces the locally-computed seed in `DailyChallengeResource` +/// with the server's authoritative seed — ensuring all players worldwide get +/// the same deal on a given date regardless of their local clock hash. +/// +/// Silently no-ops if the task is still in flight, already consumed, or +/// if the server returned a challenge for a different date. +fn poll_server_challenge( + mut task_res: ResMut, + mut daily: ResMut, +) { + let Some(task) = task_res.0.as_mut() else { + return; + }; + let Some(result) = future::block_on(future::poll_once(task)) else { + return; + }; + task_res.0 = None; + let Some(goal) = result else { return }; + let Ok(date) = NaiveDate::parse_from_str(&goal.date, "%Y-%m-%d") else { + return; + }; + if date == daily.date { + let old_seed = daily.seed; + daily.seed = goal.seed; + info!( + "daily challenge seed updated from server: {old_seed} → {}", + goal.seed + ); + } +} + fn handle_daily_completion( mut wins: EventReader, daily: Res,