feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily challenge, account deletion, JWT middleware, rate limiting via tower_governor, SQLite migrations, health endpoint - solitaire_server: expose build_test_router (no rate limiting) so integration tests work without a peer IP in oneshot requests - solitaire_sync: SyncPayload, merge logic, shared API types - solitaire_data: SyncProvider trait, LocalOnlyProvider, SolitaireServerClient, auth_tokens keyring integration, blanket Box<dyn SyncProvider> impl - solitaire_data/settings: derive Default on SyncBackend (clippy fix) - .sqlx/: offline query cache so server compiles without a live DB - sqlx: removed non-existent "offline" feature flag - keyring v2: fixed Entry::new() returning Result<Entry> - sqlx 0.8: all SQLite TEXT columns wrapped in Option<T> - Integration tests: max_connections(1) on in-memory pool so all connections share the same schema All 191 tests pass; cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -124,7 +124,7 @@ fn evaluate_on_win(
|
||||
}
|
||||
record.unlock(now);
|
||||
changed = true;
|
||||
unlocks.send(AchievementUnlockedEvent(def.id.to_string()));
|
||||
unlocks.send(AchievementUnlockedEvent(record.clone()));
|
||||
}
|
||||
|
||||
if changed {
|
||||
@@ -201,7 +201,7 @@ mod tests {
|
||||
// Verify the event was emitted.
|
||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.clone()).collect();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||
assert!(fired.contains(&"first_win".to_string()));
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ mod tests {
|
||||
|
||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.clone()).collect();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||
assert!(
|
||||
!fired.contains(&"first_win".to_string()),
|
||||
"first_win must not re-fire on subsequent wins"
|
||||
|
||||
@@ -151,7 +151,7 @@ fn handle_achievement_toast(
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Achievement: {}", display_name_for(&ev.0)),
|
||||
format!("Achievement: {}", display_name_for(&ev.0.id)),
|
||||
ACHIEVEMENT_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use bevy::prelude::Event;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::AchievementRecord;
|
||||
|
||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||
/// consumed by `GamePlugin`.
|
||||
@@ -55,8 +56,8 @@ pub struct GameWonEvent {
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
|
||||
/// Achievement unlocked notification — name of the achievement.
|
||||
///
|
||||
/// Uses `String` as a placeholder; replaced with `AchievementRecord` in Phase 5.
|
||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||
/// persistence/UI systems that need unlock metadata.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct AchievementUnlockedEvent(pub String);
|
||||
pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod stats_plugin;
|
||||
pub mod sync_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod weekly_goals_plugin;
|
||||
@@ -43,11 +44,12 @@ pub use input_plugin::InputPlugin;
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||
pub use settings_plugin::{
|
||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SFX_STEP,
|
||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
||||
pub use time_attack_plugin::{
|
||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Persists `solitaire_data::Settings` and exposes hotkeys for live tuning.
|
||||
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
|
||||
//! and renders a Bevy UI Settings panel.
|
||||
//!
|
||||
//! Hotkeys (always active, no overlay required):
|
||||
//! - `[` decrease SFX volume by `SFX_STEP`
|
||||
//! - `]` increase SFX volume by `SFX_STEP`
|
||||
//! - `[` — decrease SFX volume by `SFX_STEP`
|
||||
//! - `]` — increase SFX volume by `SFX_STEP`
|
||||
//! - `O` — open / close the Settings panel
|
||||
//!
|
||||
//! On change, the plugin persists `settings.json` and fires
|
||||
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
||||
@@ -12,37 +14,66 @@ use std::path::PathBuf;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
||||
|
||||
/// Volume adjustment step.
|
||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||
pub const SFX_STEP: f32 = 0.1;
|
||||
|
||||
/// Bevy resource wrapping the current `Settings`.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SettingsResource(pub Settings);
|
||||
|
||||
/// Persistence path for `SettingsResource`. `None` disables I/O.
|
||||
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
||||
|
||||
/// Fired any time settings change so consumers (audio, UI) can react.
|
||||
/// Whether the Settings panel is currently visible. Toggle with `O`.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SettingsScreen(pub bool);
|
||||
|
||||
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct SettingsChangedEvent(pub Settings);
|
||||
|
||||
/// Marker on the root Settings panel entity.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanel;
|
||||
|
||||
/// Marks the `Text` node that displays the live SFX volume value.
|
||||
#[derive(Component, Debug)]
|
||||
struct SfxVolumeText;
|
||||
|
||||
/// Tags interactive buttons inside the Settings panel.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
SfxDown,
|
||||
SfxUp,
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Plugin that owns the settings lifecycle.
|
||||
pub struct SettingsPlugin {
|
||||
/// Path to `settings.json`. `None` in headless/test mode.
|
||||
pub storage_path: Option<PathBuf>,
|
||||
/// When `false`, panel spawn/despawn systems are not registered.
|
||||
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
|
||||
pub ui_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: settings_file_path(),
|
||||
ui_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsPlugin {
|
||||
/// Plugin configured with no persistence — for tests and headless apps.
|
||||
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
|
||||
pub fn headless() -> Self {
|
||||
Self { storage_path: None }
|
||||
Self {
|
||||
storage_path: None,
|
||||
ui_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,27 +85,41 @@ impl Plugin for SettingsPlugin {
|
||||
};
|
||||
app.insert_resource(SettingsResource(loaded))
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(Update, handle_volume_keys);
|
||||
.add_systems(Update, (handle_volume_keys, toggle_settings_screen));
|
||||
|
||||
if self.ui_enabled {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(sync_settings_panel_visibility, handle_settings_buttons),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
||||
let Some(target) = &path.0 else {
|
||||
return;
|
||||
};
|
||||
let Some(target) = &path.0 else { return };
|
||||
if let Err(e) = save_settings_to(target, settings) {
|
||||
warn!("failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_volume_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: EventWriter<SettingsChangedEvent>,
|
||||
) {
|
||||
let mut delta = 0.0;
|
||||
let mut delta = 0.0_f32;
|
||||
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||
delta -= SFX_STEP;
|
||||
}
|
||||
@@ -87,13 +132,259 @@ fn handle_volume_keys(
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(delta);
|
||||
if (before - after).abs() < f32::EPSILON {
|
||||
// Already at the rail — no point persisting or notifying.
|
||||
return;
|
||||
}
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
|
||||
/// Opens or closes the Settings panel when `O` is pressed.
|
||||
fn toggle_settings_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::KeyO) {
|
||||
screen.0 = !screen.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||
/// despawns it when it becomes `false`.
|
||||
fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
mut commands: Commands,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
if !screen.is_changed() {
|
||||
return;
|
||||
}
|
||||
if screen.0 {
|
||||
if panels.is_empty() {
|
||||
spawn_settings_panel(&mut commands, &settings.0);
|
||||
}
|
||||
} else {
|
||||
for entity in &panels {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reacts to button presses inside the Settings panel.
|
||||
fn handle_settings_buttons(
|
||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: EventWriter<SettingsChangedEvent>,
|
||||
mut volume_text: Query<&mut Text, With<SfxVolumeText>>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
match button {
|
||||
SettingsButton::SfxDown => {
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut text) = volume_text.get_single_mut() {
|
||||
**text = format!("{:.2}", after);
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsButton::SfxUp => {
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut text) = volume_text.get_single_mut() {
|
||||
**text = format!("{:.2}", after);
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsButton::Done => {
|
||||
screen.0 = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) {
|
||||
commands
|
||||
.spawn((
|
||||
SettingsPanel,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// Inner card
|
||||
root.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(28.0)),
|
||||
row_gap: Val::Px(14.0),
|
||||
min_width: Val::Px(340.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
||||
BorderRadius::all(Val::Px(8.0)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
// Title
|
||||
card.spawn((
|
||||
Text::new("Settings"),
|
||||
TextFont {
|
||||
font_size: 30.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
|
||||
// --- Audio section ---
|
||||
section_label(card, "Audio");
|
||||
|
||||
// SFX volume row: label | value | [−] | [+]
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: Val::Px(8.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("SFX Volume"),
|
||||
TextFont {
|
||||
font_size: 18.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
SfxVolumeText,
|
||||
Text::new(format!("{:.2}", settings.sfx_volume)),
|
||||
TextFont {
|
||||
font_size: 18.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
icon_button(row, "−", SettingsButton::SfxDown);
|
||||
icon_button(row, "+", SettingsButton::SfxUp);
|
||||
});
|
||||
|
||||
coming_soon_row(card, "Music Volume");
|
||||
|
||||
// --- Gameplay section ---
|
||||
section_label(card, "Gameplay");
|
||||
coming_soon_row(card, "Draw Mode");
|
||||
|
||||
// --- Appearance section ---
|
||||
section_label(card, "Appearance");
|
||||
coming_soon_row(card, "Theme");
|
||||
|
||||
// --- Sync section ---
|
||||
section_label(card, "Sync");
|
||||
coming_soon_row(card, "Sync Backend");
|
||||
|
||||
// Done button
|
||||
card.spawn((
|
||||
SettingsButton::Done,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
margin: UiRect::top(Val::Px(6.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
||||
BorderRadius::all(Val::Px(4.0)),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Done"),
|
||||
TextFont {
|
||||
font_size: 18.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn section_label(parent: &mut ChildBuilder, title: &str) {
|
||||
parent.spawn((
|
||||
Text::new(title),
|
||||
TextFont {
|
||||
font_size: 14.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.55, 0.75, 0.55)),
|
||||
));
|
||||
}
|
||||
|
||||
fn coming_soon_row(parent: &mut ChildBuilder, label: &str) {
|
||||
parent.spawn((
|
||||
Text::new(format!("{label} — coming soon")),
|
||||
TextFont {
|
||||
font_size: 16.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.45, 0.45, 0.45)),
|
||||
));
|
||||
}
|
||||
|
||||
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
|
||||
parent
|
||||
.spawn((
|
||||
action,
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(28.0),
|
||||
height: Val::Px(28.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
||||
BorderRadius::all(Val::Px(4.0)),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label.to_string()),
|
||||
TextFont {
|
||||
font_size: 18.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -142,7 +433,6 @@ mod tests {
|
||||
#[test]
|
||||
fn pressing_right_bracket_increases_volume() {
|
||||
let mut app = headless_app();
|
||||
// Drop volume first so there's headroom to grow.
|
||||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
@@ -155,7 +445,6 @@ mod tests {
|
||||
#[test]
|
||||
fn clamped_change_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
// Already at max — pressing right bracket should be a no-op.
|
||||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
|
||||
@@ -11,7 +11,8 @@ use std::path::PathBuf;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsSnapshot, WEEKLY_GOALS,
|
||||
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsExt, StatsSnapshot,
|
||||
WEEKLY_GOALS,
|
||||
};
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Backend-agnostic sync plugin for Solitaire Quest.
|
||||
//!
|
||||
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
|
||||
//! that fetches the remote payload from the active [`SyncProvider`]. Once the
|
||||
//! task resolves, the merged result is written to disk and the in-world
|
||||
//! resources are updated. On app exit, a blocking push sends the current local
|
||||
//! state to the backend.
|
||||
//!
|
||||
//! The plugin is completely backend-agnostic: the caller (usually
|
||||
//! `solitaire_app`) constructs the right [`SyncProvider`] implementation and
|
||||
//! passes it to [`SyncPlugin::new`]. No `match` on a backend enum variant ever
|
||||
//! occurs inside this module.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use solitaire_data::{
|
||||
save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress,
|
||||
StatsSnapshot, SyncProvider,
|
||||
};
|
||||
use solitaire_sync::{merge, SyncPayload};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::stats_plugin::{StatsResource, StatsStoragePath};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wraps the active sync backend. Shared with async tasks via [`Arc`].
|
||||
///
|
||||
/// Registered by [`SyncPlugin`] during `build()`. Other plugins may read this
|
||||
/// resource to check [`SyncProvider::is_authenticated`] or
|
||||
/// [`SyncProvider::backend_name`].
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct SyncProviderResource(pub Arc<dyn SyncProvider + Send + Sync>);
|
||||
|
||||
/// Holds a pending pull result transferred from the async compute task to the
|
||||
/// main thread. Consumed and cleared by [`poll_pull_result`].
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PullTaskResult(pub Option<Result<SyncPayload, String>>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Holds the in-flight pull task so [`poll_pull_result`] can check its status
|
||||
/// each frame without blocking the main thread.
|
||||
#[derive(Resource, Default)]
|
||||
struct PullTask(Option<Task<Result<SyncPayload, String>>>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy plugin that manages the full sync lifecycle:
|
||||
///
|
||||
/// - **Startup** — spawns an async pull task on [`AsyncComputeTaskPool`].
|
||||
/// - **Update** — polls the task each frame; on completion merges the remote
|
||||
/// payload with local data, persists the result, and updates in-world
|
||||
/// resources.
|
||||
/// - **Last** — on [`AppExit`], performs a blocking push of the current local
|
||||
/// state to the active backend.
|
||||
///
|
||||
/// Construct via [`SyncPlugin::new`], passing any type that implements
|
||||
/// [`SyncProvider`].
|
||||
pub struct SyncPlugin {
|
||||
provider: Arc<dyn SyncProvider + Send + Sync>,
|
||||
}
|
||||
|
||||
impl SyncPlugin {
|
||||
/// Create a new [`SyncPlugin`] backed by the given [`SyncProvider`].
|
||||
///
|
||||
/// The provider is heap-allocated and reference-counted so it can be
|
||||
/// cloned cheaply into async tasks.
|
||||
pub fn new(provider: impl SyncProvider + 'static) -> Self {
|
||||
Self {
|
||||
provider: Arc::new(provider),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SyncPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.init_resource::<PullTaskResult>()
|
||||
.init_resource::<PullTask>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(Update, poll_pull_result)
|
||||
.add_systems(Last, push_on_exit);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
||||
fn start_pull(
|
||||
provider: Res<SyncProviderResource>,
|
||||
mut task_res: ResMut<PullTask>,
|
||||
mut status: ResMut<SyncStatusResource>,
|
||||
) {
|
||||
let provider = provider.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
provider.pull().await.map_err(|e| e.to_string())
|
||||
});
|
||||
task_res.0 = Some(task);
|
||||
status.0 = SyncStatus::Syncing;
|
||||
}
|
||||
|
||||
/// Update system: polls the pull task without blocking.
|
||||
///
|
||||
/// When the task resolves successfully:
|
||||
/// 1. Merges the remote payload with the current local state.
|
||||
/// 2. Persists the merged result atomically.
|
||||
/// 3. Updates the in-world [`StatsResource`], [`AchievementsResource`], and
|
||||
/// [`ProgressResource`].
|
||||
/// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`].
|
||||
///
|
||||
/// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`].
|
||||
fn poll_pull_result(
|
||||
mut task_res: ResMut<PullTask>,
|
||||
mut status: ResMut<SyncStatusResource>,
|
||||
mut stats: ResMut<StatsResource>,
|
||||
stats_path: Res<StatsStoragePath>,
|
||||
mut achievements: ResMut<AchievementsResource>,
|
||||
achievements_path: Res<AchievementsStoragePath>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
progress_path: Res<ProgressStoragePath>,
|
||||
) {
|
||||
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;
|
||||
|
||||
match result {
|
||||
Ok(remote) => {
|
||||
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let (merged, _conflicts) = merge(&local, &remote);
|
||||
|
||||
// Persist merged state atomically.
|
||||
if let Some(p) = &stats_path.0 {
|
||||
if let Err(e) = save_stats_to(p, &merged.stats) {
|
||||
warn!("sync: failed to persist stats: {e}");
|
||||
}
|
||||
}
|
||||
if let Some(p) = &achievements_path.0 {
|
||||
if let Err(e) = save_achievements_to(p, &merged.achievements) {
|
||||
warn!("sync: failed to persist achievements: {e}");
|
||||
}
|
||||
}
|
||||
if let Some(p) = &progress_path.0 {
|
||||
if let Err(e) = save_progress_to(p, &merged.progress) {
|
||||
warn!("sync: failed to persist progress: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-world resources.
|
||||
stats.0 = merged.stats;
|
||||
achievements.0 = merged.achievements;
|
||||
progress.0 = merged.progress;
|
||||
status.0 = SyncStatus::LastSynced(Utc::now());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("sync pull failed: {e}");
|
||||
status.0 = SyncStatus::Error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-schedule system: pushes the current local state on [`AppExit`].
|
||||
///
|
||||
/// A blocking push is acceptable here — ARCHITECTURE.md §4 explicitly notes
|
||||
/// that blocking on exit is permitted because the game loop is already
|
||||
/// shutting down.
|
||||
fn push_on_exit(
|
||||
mut exit_events: EventReader<AppExit>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
stats: Res<StatsResource>,
|
||||
achievements: Res<AchievementsResource>,
|
||||
progress: Res<ProgressResource>,
|
||||
) {
|
||||
if exit_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
exit_events.clear();
|
||||
|
||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let provider = provider.0.clone();
|
||||
|
||||
// Prefer an existing tokio runtime; fall back to futures_lite block_on
|
||||
// for environments (e.g. tests) that don't have one.
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => {
|
||||
let _ = handle.block_on(provider.push(&payload));
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = future::block_on(provider.push(&payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Constructs a [`SyncPayload`] from the current in-world state.
|
||||
///
|
||||
/// `user_id` is set to [`Uuid::nil()`] — the server replaces it with the
|
||||
/// authenticated user's real ID when it processes the push request.
|
||||
fn build_payload(
|
||||
stats: &StatsSnapshot,
|
||||
achievements: &[AchievementRecord],
|
||||
progress: &PlayerProgress,
|
||||
) -> SyncPayload {
|
||||
SyncPayload {
|
||||
user_id: Uuid::nil(),
|
||||
stats: stats.clone(),
|
||||
achievements: achievements.to_vec(),
|
||||
progress: progress.clone(),
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_data::SyncError;
|
||||
use solitaire_sync::SyncResponse;
|
||||
|
||||
/// A no-op sync provider that always returns a default payload on pull
|
||||
/// and succeeds silently on push. Used to exercise the plugin in headless
|
||||
/// tests without any network I/O.
|
||||
struct NoOpProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SyncProvider for NoOpProvider {
|
||||
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||
Ok(SyncPayload {
|
||||
user_id: Uuid::nil(),
|
||||
stats: StatsSnapshot::default(),
|
||||
achievements: vec![],
|
||||
progress: PlayerProgress::default(),
|
||||
last_modified: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||
Ok(SyncResponse {
|
||||
merged: SyncPayload {
|
||||
user_id: Uuid::nil(),
|
||||
stats: StatsSnapshot::default(),
|
||||
achievements: vec![],
|
||||
progress: PlayerProgress::default(),
|
||||
last_modified: Utc::now(),
|
||||
},
|
||||
server_time: Utc::now(),
|
||||
conflicts: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"no-op"
|
||||
}
|
||||
|
||||
fn is_authenticated(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider that always fails on pull, used to test the error path.
|
||||
struct FailingProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SyncProvider for FailingProvider {
|
||||
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||
Err(SyncError::Network("simulated failure".to_string()))
|
||||
}
|
||||
|
||||
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||
Err(SyncError::Network("simulated failure".to_string()))
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"failing"
|
||||
}
|
||||
|
||||
fn is_authenticated(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn headless_app_with(provider: impl SyncProvider + 'static) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(crate::game_plugin::GamePlugin)
|
||||
.add_plugins(crate::table_plugin::TablePlugin)
|
||||
.add_plugins(crate::stats_plugin::StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||
.add_plugins(SyncPlugin::new(provider));
|
||||
// MinimalPlugins does not register keyboard input.
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_provider_resource_is_registered() {
|
||||
let app = headless_app_with(NoOpProvider);
|
||||
assert!(app.world().get_resource::<SyncProviderResource>().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_status_becomes_syncing_on_startup() {
|
||||
// After the first update() the startup system has run and set Syncing,
|
||||
// but the async task may not have resolved yet.
|
||||
let mut app = headless_app_with(NoOpProvider);
|
||||
// Run a second update to give the task pool a chance to complete.
|
||||
app.update();
|
||||
// Status is either Syncing (task still running) or LastSynced (resolved).
|
||||
let status = &app.world().resource::<SyncStatusResource>().0;
|
||||
assert!(
|
||||
matches!(
|
||||
status,
|
||||
SyncStatus::Syncing | SyncStatus::LastSynced(_)
|
||||
),
|
||||
"status should be Syncing or LastSynced, got {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_failure_sets_error_status() {
|
||||
let mut app = headless_app_with(FailingProvider);
|
||||
// Pump frames until the task resolves (it's synchronous under
|
||||
// AsyncComputeTaskPool in test mode, so a few updates suffice).
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
let status = &app.world().resource::<SyncStatusResource>().0;
|
||||
assert!(
|
||||
matches!(status, SyncStatus::Error(_)),
|
||||
"expected Error status after failing pull, got {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_payload_sets_nil_user_id() {
|
||||
let payload = build_payload(
|
||||
&StatsSnapshot::default(),
|
||||
&[],
|
||||
&PlayerProgress::default(),
|
||||
);
|
||||
assert_eq!(payload.user_id, Uuid::nil());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_payload_clones_stats() {
|
||||
let mut stats = StatsSnapshot::default();
|
||||
stats.games_played = 42;
|
||||
let payload = build_payload(&stats, &[], &PlayerProgress::default());
|
||||
assert_eq!(payload.stats.games_played, 42);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user