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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_sync::{LeaderboardEntry, SyncPayload, SyncResponse};
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// All errors that can arise during sync operations.
|
/// All errors that can arise during sync operations.
|
||||||
@@ -36,6 +36,11 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
Ok(vec![])
|
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<Option<ChallengeGoal>, SyncError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||||
@@ -60,6 +65,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
(**self).fetch_leaderboard().await
|
(**self).fetch_leaderboard().await
|
||||||
}
|
}
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
|
||||||
|
(**self).fetch_daily_challenge().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_sync::{LeaderboardEntry, SyncPayload, SyncResponse};
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
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()
|
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<Option<ChallengeGoal>, 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<Vec<LeaderboardEntry>, SyncError> {
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
let token = self.access_token()?;
|
let token = self.access_token()?;
|
||||||
let url = format!("{}/api/leaderboard", self.base_url);
|
let url = format!("{}/api/leaderboard", self.base_url);
|
||||||
|
|||||||
@@ -13,13 +13,16 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use chrono::{Local, NaiveDate};
|
use chrono::{Local, NaiveDate};
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
|
||||||
/// Bonus XP awarded for completing today's daily challenge.
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
pub const DAILY_BONUS_XP: u64 = 100;
|
pub const DAILY_BONUS_XP: u64 = 100;
|
||||||
@@ -48,14 +51,22 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
pub streak: u32,
|
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<Task<Option<ChallengeGoal>>>);
|
||||||
|
|
||||||
pub struct DailyChallengePlugin;
|
pub struct DailyChallengePlugin;
|
||||||
|
|
||||||
impl Plugin for DailyChallengePlugin {
|
impl Plugin for DailyChallengePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(DailyChallengeResource::for_today())
|
app.insert_resource(DailyChallengeResource::for_today())
|
||||||
|
.init_resource::<DailyChallengeTask>()
|
||||||
.add_event::<DailyChallengeCompletedEvent>()
|
.add_event::<DailyChallengeCompletedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_systems(Startup, fetch_server_challenge)
|
||||||
|
.add_systems(Update, poll_server_challenge)
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
.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<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<DailyChallengeTask>,
|
||||||
|
) {
|
||||||
|
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<DailyChallengeTask>,
|
||||||
|
mut daily: ResMut<DailyChallengeResource>,
|
||||||
|
) {
|
||||||
|
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(
|
fn handle_daily_completion(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
daily: Res<DailyChallengeResource>,
|
daily: Res<DailyChallengeResource>,
|
||||||
|
|||||||
Reference in New Issue
Block a user