From 20db4b312a5707fc1a018005c40ec852b89e7436 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 00:03:24 +0000 Subject: [PATCH] feat(engine): ManualSyncRequestEvent + Sync Now button in settings - Added ManualSyncRequestEvent to events.rs (exported from lib.rs). - SyncPlugin now handles ManualSyncRequestEvent: if no pull is in flight, spawns a new AsyncComputeTaskPool task and sets status to Syncing. Ignores duplicate requests while a pull is active. - Settings panel "Sync" section now shows the status text alongside a "Sync Now" button that fires ManualSyncRequestEvent. - Cleaned up stale doc comment in input_plugin.rs (Esc pause note). Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/events.rs | 7 ++++ solitaire_engine/src/input_plugin.rs | 3 +- solitaire_engine/src/lib.rs | 4 +-- solitaire_engine/src/settings_plugin.rs | 47 +++++++++++++++++++++---- solitaire_engine/src/sync_plugin.rs | 27 +++++++++++++- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 888474d..d40da20 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -61,3 +61,10 @@ pub struct CardFlippedEvent(pub u32); /// persistence/UI systems that need unlock metadata. #[derive(Event, Debug, Clone)] pub struct AchievementUnlockedEvent(pub AchievementRecord); + +/// Request to manually trigger a sync pull from the active backend. +/// +/// Fired by the Settings panel "Sync Now" button. `SyncPlugin` responds by +/// starting a new pull task if one is not already in flight. +#[derive(Event, Debug, Clone, Copy, Default)] +pub struct ManualSyncRequestEvent; diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 4808b7d..cbf529b 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -4,8 +4,7 @@ //! - `U` → `UndoRequestEvent` //! - `N` → `NewGameRequestEvent { seed: None }` //! - `D` → `DrawRequestEvent` -//! - `Esc` → logged as a pause placeholder (no event yet; wired up when the -//! pause screen lands in a later phase) +//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag) //! //! Mouse: //! - Left-click on the stock pile (face-down top) → `DrawRequestEvent` diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 72971c9..72ace50 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -35,8 +35,8 @@ pub use animation_plugin::{AnimationPlugin, CardAnim}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use events::{ - AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, - MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, + AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, ManualSyncRequestEvent, + MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, }; pub use game_plugin::{GameMutation, GamePlugin}; pub use help_plugin::{HelpPlugin, HelpScreen}; diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index dbbe75f..45ae38d 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -15,6 +15,7 @@ use bevy::prelude::*; use solitaire_core::game_state::DrawMode; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings}; +use crate::events::ManualSyncRequestEvent; use crate::resources::{SyncStatus, SyncStatusResource}; /// Volume adjustment step applied by the `[` / `]` hotkeys. @@ -69,6 +70,7 @@ enum SettingsButton { MusicUp, ToggleDrawMode, ToggleTheme, + SyncNow, Done, } @@ -110,6 +112,7 @@ impl Plugin for SettingsPlugin { .insert_resource(SettingsStoragePath(self.storage_path.clone())) .init_resource::() .add_event::() + .add_event::() .add_systems(Update, (handle_volume_keys, toggle_settings_screen)); if self.ui_enabled { @@ -245,6 +248,7 @@ fn handle_settings_buttons( mut screen: ResMut, path: Res, mut changed: EventWriter, + mut manual_sync: EventWriter, mut sfx_text: Query<&mut Text, (With, Without, Without, Without)>, mut music_text: Query<&mut Text, (With, Without, Without, Without)>, mut draw_text: Query<&mut Text, (With, Without, Without, Without)>, @@ -322,6 +326,9 @@ fn handle_settings_buttons( **t = theme_label(&settings.0.theme); } } + SettingsButton::SyncNow => { + manual_sync.send(ManualSyncRequestEvent); + } SettingsButton::Done => { screen.0 = false; } @@ -453,12 +460,40 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_statu // --- Sync section --- section_label(card, "Sync"); - card.spawn(( - SyncStatusText, - Text::new(sync_status.to_string()), - TextFont { font_size: 16.0, ..default() }, - TextColor(Color::srgb(0.65, 0.65, 0.70)), - )); + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(10.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + SyncStatusText, + Text::new(sync_status.to_string()), + TextFont { font_size: 16.0, ..default() }, + TextColor(Color::srgb(0.65, 0.65, 0.70)), + )); + // "Sync Now" button — hidden when SyncPlugin is not installed; + // visible because ManualSyncRequestEvent is always registered. + row.spawn(( + SettingsButton::SyncNow, + Button, + Node { + padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)), + justify_content: JustifyContent::Center, + ..default() + }, + BackgroundColor(Color::srgb(0.20, 0.30, 0.45)), + BorderRadius::all(Val::Px(4.0)), + )) + .with_children(|b| { + b.spawn(( + Text::new("Sync Now"), + TextFont { font_size: 14.0, ..default() }, + TextColor(Color::WHITE), + )); + }); + }); // Done button card.spawn(( diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 4e73009..81c241c 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -25,6 +25,7 @@ use solitaire_data::{ use solitaire_sync::{merge, SyncPayload}; use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; +use crate::events::ManualSyncRequestEvent; use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::resources::{SyncStatus, SyncStatusResource}; use crate::stats_plugin::{StatsResource, StatsStoragePath}; @@ -92,8 +93,9 @@ impl Plugin for SyncPlugin { .init_resource::() .init_resource::() .init_resource::() + .add_event::() .add_systems(Startup, start_pull) - .add_systems(Update, poll_pull_result) + .add_systems(Update, (poll_pull_result, handle_manual_sync_request)) .add_systems(Last, push_on_exit); } } @@ -116,6 +118,29 @@ fn start_pull( status.0 = SyncStatus::Syncing; } +/// Update system: starts a new pull task when `ManualSyncRequestEvent` is +/// received, but only if no pull is already in flight. +fn handle_manual_sync_request( + mut events: EventReader, + provider: Res, + mut task_res: ResMut, + mut status: ResMut, +) { + if events.is_empty() { + return; + } + events.clear(); + if task_res.0.is_some() { + return; // Already pulling — ignore. + } + 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: