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 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 00:03:24 +00:00
parent f7f14efe07
commit 20db4b312a
5 changed files with 77 additions and 11 deletions
+7
View File
@@ -61,3 +61,10 @@ pub struct CardFlippedEvent(pub u32);
/// persistence/UI systems that need unlock metadata. /// persistence/UI systems that need unlock metadata.
#[derive(Event, Debug, Clone)] #[derive(Event, Debug, Clone)]
pub struct AchievementUnlockedEvent(pub AchievementRecord); 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;
+1 -2
View File
@@ -4,8 +4,7 @@
//! - `U` → `UndoRequestEvent` //! - `U` → `UndoRequestEvent`
//! - `N` → `NewGameRequestEvent { seed: None }` //! - `N` → `NewGameRequestEvent { seed: None }`
//! - `D` → `DrawRequestEvent` //! - `D` → `DrawRequestEvent`
//! - `Esc` → logged as a pause placeholder (no event yet; wired up when the //! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//! pause screen lands in a later phase)
//! //!
//! Mouse: //! Mouse:
//! - Left-click on the stock pile (face-down top) → `DrawRequestEvent` //! - Left-click on the stock pile (face-down top) → `DrawRequestEvent`
+2 -2
View File
@@ -35,8 +35,8 @@ pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, ManualSyncRequestEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
}; };
pub use game_plugin::{GameMutation, GamePlugin}; pub use game_plugin::{GameMutation, GamePlugin};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
+36 -1
View File
@@ -15,6 +15,7 @@ use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings}; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings};
use crate::events::ManualSyncRequestEvent;
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
/// Volume adjustment step applied by the `[` / `]` hotkeys. /// Volume adjustment step applied by the `[` / `]` hotkeys.
@@ -69,6 +70,7 @@ enum SettingsButton {
MusicUp, MusicUp,
ToggleDrawMode, ToggleDrawMode,
ToggleTheme, ToggleTheme,
SyncNow,
Done, Done,
} }
@@ -110,6 +112,7 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone())) .insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>() .init_resource::<SettingsScreen>()
.add_event::<SettingsChangedEvent>() .add_event::<SettingsChangedEvent>()
.add_event::<ManualSyncRequestEvent>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen)); .add_systems(Update, (handle_volume_keys, toggle_settings_screen));
if self.ui_enabled { if self.ui_enabled {
@@ -245,6 +248,7 @@ fn handle_settings_buttons(
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>, path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>, mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>, mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>, mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>, mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>,
@@ -322,6 +326,9 @@ fn handle_settings_buttons(
**t = theme_label(&settings.0.theme); **t = theme_label(&settings.0.theme);
} }
} }
SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent);
}
SettingsButton::Done => { SettingsButton::Done => {
screen.0 = false; screen.0 = false;
} }
@@ -453,12 +460,40 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_statu
// --- Sync section --- // --- Sync section ---
section_label(card, "Sync"); section_label(card, "Sync");
card.spawn(( card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(10.0),
..default()
})
.with_children(|row| {
row.spawn((
SyncStatusText, SyncStatusText,
Text::new(sync_status.to_string()), Text::new(sync_status.to_string()),
TextFont { font_size: 16.0, ..default() }, TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.65, 0.65, 0.70)), 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 // Done button
card.spawn(( card.spawn((
+26 -1
View File
@@ -25,6 +25,7 @@ use solitaire_data::{
use solitaire_sync::{merge, SyncPayload}; use solitaire_sync::{merge, SyncPayload};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::ManualSyncRequestEvent;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
use crate::stats_plugin::{StatsResource, StatsStoragePath}; use crate::stats_plugin::{StatsResource, StatsStoragePath};
@@ -92,8 +93,9 @@ impl Plugin for SyncPlugin {
.init_resource::<SyncStatusResource>() .init_resource::<SyncStatusResource>()
.init_resource::<PullTaskResult>() .init_resource::<PullTaskResult>()
.init_resource::<PullTask>() .init_resource::<PullTask>()
.add_event::<ManualSyncRequestEvent>()
.add_systems(Startup, start_pull) .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); .add_systems(Last, push_on_exit);
} }
} }
@@ -116,6 +118,29 @@ fn start_pull(
status.0 = SyncStatus::Syncing; 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<ManualSyncRequestEvent>,
provider: Res<SyncProviderResource>,
mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>,
) {
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. /// Update system: polls the pull task without blocking.
/// ///
/// When the task resolves successfully: /// When the task resolves successfully: