From 2f373784bf869b3c7de1f9488718564be0db3385 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 16 Jul 2026 18:06:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20Phase=20M=20=E2=80=94=20sync=20?= =?UTF-8?q?transparency=20+=20local=20data=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the 13-phase menu redesign. Three parts: 1. Post-sync merge summary: the pull poller now keeps the `ConflictReport`s the merge produces (previously discarded) in a new `SyncConflictLog` resource and toasts "Synced — N conflicts, kept newer values" when a merge wasn't clean. No wire changes — the server-side `SyncResponse.conflicts` field already existed. 2. Account tab detail: a per-field conflict list under the sync row ("win_streak_current — this device: 3 / server: 5") plus a clean-merge caption, so the last merge is always inspectable. 3. Local data export/import: new `solitaire_data::transfer` module — one versioned JSON bundle (settings, stats, achievements, progress) written atomically next to the other saves, with a version-gated loader that surfaces every failure instead of defaulting. Account tab grows an Export/Import button pair (desktop+Android only); the handler runs in `Last`, off the annotated Update spine. Import rewrites the live resources, persists via the existing save fns, and fires SettingsChangedEvent so appliers react normally. Tests: bundle round-trip/version-gate/missing-file in solitaire_data; ECS-level export→import round trip and failed-import-warns in settings_plugin. Gate: workspace tests green, clippy -D warnings clean. Co-Authored-By: Claude Fable 5 --- solitaire_data/src/lib.rs | 6 + solitaire_data/src/transfer.rs | 137 +++++++++++++++++ solitaire_engine/src/events.rs | 12 ++ solitaire_engine/src/resources.rs | 19 +++ .../src/settings_plugin/data_transfer.rs | 139 +++++++++++++++++ solitaire_engine/src/settings_plugin/input.rs | 17 ++- solitaire_engine/src/settings_plugin/mod.rs | 22 +++ solitaire_engine/src/settings_plugin/tests.rs | 90 +++++++++++ solitaire_engine/src/settings_plugin/ui.rs | 144 +++++++++++++++++- .../src/settings_plugin/updates.rs | 6 + solitaire_engine/src/sync_plugin.rs | 32 +++- 11 files changed, 617 insertions(+), 7 deletions(-) create mode 100644 solitaire_data/src/transfer.rs create mode 100644 solitaire_engine/src/settings_plugin/data_transfer.rs diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 9dcddc6..c96e1dc 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -133,6 +133,12 @@ pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for}; pub mod difficulty_seeds; pub use difficulty_seeds::{DifficultySeeds, seeds_for}; +pub mod transfer; +pub use transfer::{ + BundleError, DATA_BUNDLE_SCHEMA_VERSION, DataBundle, data_bundle_path, load_bundle_from, + save_bundle_to, +}; + pub mod settings; pub use settings::{ AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend, diff --git a/solitaire_data/src/transfer.rs b/solitaire_data/src/transfer.rs new file mode 100644 index 0000000..55b08be --- /dev/null +++ b/solitaire_data/src/transfer.rs @@ -0,0 +1,137 @@ +//! Local data export/import (Phase M — sync transparency). +//! +//! Bundles every player-owned JSON save (settings, stats, achievements, +//! progress) into one versioned backup file so a player can move their +//! data between devices without an account, or keep a manual backup. +//! +//! The bundle deliberately excludes the in-progress game and the replay +//! history: the former is transient, the latter can be large and is +//! shareable through the replay upload path instead. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::settings::Settings; +use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot}; + +/// Bundle format version. Bump on any breaking shape change; the loader +/// rejects newer versions rather than misreading them. +pub const DATA_BUNDLE_SCHEMA_VERSION: u32 = 1; + +/// File name of the default backup location (next to the other saves). +pub const DATA_BUNDLE_FILE_NAME: &str = "ferrous-solitaire-backup.json"; + +/// One-file backup of every player-owned JSON save. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataBundle { + /// Format version — see [`DATA_BUNDLE_SCHEMA_VERSION`]. + pub schema_version: u32, + /// When the bundle was written. + pub exported_at: DateTime, + pub settings: Settings, + pub stats: StatsSnapshot, + pub achievements: Vec, + pub progress: PlayerProgress, +} + +/// Why a bundle could not be loaded. +#[derive(Debug, thiserror::Error)] +pub enum BundleError { + #[error("could not read backup file: {0}")] + Io(#[from] io::Error), + #[error("backup file is not valid JSON: {0}")] + Parse(#[from] serde_json::Error), + #[error("backup was written by a newer app version (schema {found} > {supported})")] + UnsupportedVersion { found: u32, supported: u32 }, +} + +/// Default backup path: `//ferrous-solitaire-backup.json`, +/// or `None` when no data directory is available on this platform. +pub fn data_bundle_path() -> Option { + crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(DATA_BUNDLE_FILE_NAME)) +} + +/// Write `bundle` to `path` atomically (`.tmp` → rename), creating parent +/// directories as needed. Mirrors the other save-file writers. +pub fn save_bundle_to(path: &Path, bundle: &DataBundle) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(bundle).map_err(io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, json.as_bytes())?; + fs::rename(&tmp, path)?; + Ok(()) +} + +/// Read and validate a bundle from `path`. Unlike the per-file loaders this +/// does NOT default-on-error — an import silently replacing player data +/// with defaults would be destructive, so every failure is surfaced. +pub fn load_bundle_from(path: &Path) -> Result { + let data = fs::read(path)?; + let bundle: DataBundle = serde_json::from_slice(&data)?; + if bundle.schema_version > DATA_BUNDLE_SCHEMA_VERSION { + return Err(BundleError::UnsupportedVersion { + found: bundle.schema_version, + supported: DATA_BUNDLE_SCHEMA_VERSION, + }); + } + Ok(bundle) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_bundle() -> DataBundle { + DataBundle { + schema_version: DATA_BUNDLE_SCHEMA_VERSION, + exported_at: Utc::now(), + settings: Settings::default(), + stats: StatsSnapshot { + games_played: 7, + ..Default::default() + }, + achievements: vec![], + progress: PlayerProgress::default(), + } + } + + #[test] + fn round_trips_through_disk() { + let dir = std::env::temp_dir().join(format!("bundle_test_{}", std::process::id())); + let path = dir.join(DATA_BUNDLE_FILE_NAME); + let bundle = sample_bundle(); + save_bundle_to(&path, &bundle).expect("save should succeed"); + let loaded = load_bundle_from(&path).expect("load should succeed"); + assert_eq!(loaded.schema_version, DATA_BUNDLE_SCHEMA_VERSION); + assert_eq!(loaded.stats.games_played, 7); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_newer_schema() { + let dir = std::env::temp_dir().join(format!("bundle_test_v_{}", std::process::id())); + let path = dir.join(DATA_BUNDLE_FILE_NAME); + let mut bundle = sample_bundle(); + bundle.schema_version = DATA_BUNDLE_SCHEMA_VERSION + 1; + save_bundle_to(&path, &bundle).expect("save should succeed"); + assert!(matches!( + load_bundle_from(&path), + Err(BundleError::UnsupportedVersion { .. }) + )); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_file_is_an_io_error() { + assert!(matches!( + load_bundle_from(Path::new("/nonexistent/backup.json")), + Err(BundleError::Io(_)) + )); + } +} diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 1fe61b8..f240194 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -151,6 +151,18 @@ pub struct SyncLogoutRequestEvent; #[derive(Message, Debug, Clone, Copy, Default)] pub struct DeleteAccountRequestEvent; +/// Request to export every JSON save into one local backup bundle +/// (Phase M — sync transparency). Fired by the Account tab's "Export +/// data" button; handled in the `Last` schedule by `settings_plugin`. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct DataExportRequestEvent; + +/// Request to restore every JSON save from the local backup bundle +/// (Phase M — sync transparency). Fired by the Account tab's "Import +/// data" button; handled in the `Last` schedule by `settings_plugin`. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct DataImportRequestEvent; + /// Request to toggle the pause overlay. Fired by the HUD "Pause" button so /// the same toggle path runs whether the player presses `Esc` or clicks. /// Consumed by `pause_plugin::toggle_pause`, which honours the same drag / diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs index 21f1da4..2f4ff45 100644 --- a/solitaire_engine/src/resources.rs +++ b/solitaire_engine/src/resources.rs @@ -102,6 +102,25 @@ pub enum SyncStatus { #[derive(Resource, Debug, Clone, Default)] pub struct SyncStatusResource(pub SyncStatus); +/// Outcome of the most recent successful sync merge (Phase M — sync +/// transparency). `None` until the first pull of the session resolves. +/// +/// The Account tab renders the conflict details; the sync poller emits a +/// one-line toast summary when `conflicts` is non-empty. +#[derive(Resource, Debug, Clone, Default)] +pub struct SyncConflictLog(pub Option); + +/// Timestamp + per-field conflict list captured from +/// [`solitaire_sync::merge`] after a pull resolves. +#[derive(Debug, Clone)] +pub struct SyncMergeSummary { + /// When the merge resolved. + pub at: DateTime, + /// Fields that could not be merged deterministically (best-effort + /// resolution already applied — see `solitaire_sync::merge`). + pub conflicts: Vec, +} + /// Tracks which hint the player is currently cycling through. /// /// Incremented on each H press so repeated presses reveal different moves. diff --git a/solitaire_engine/src/settings_plugin/data_transfer.rs b/solitaire_engine/src/settings_plugin/data_transfer.rs new file mode 100644 index 0000000..e776794 --- /dev/null +++ b/solitaire_engine/src/settings_plugin/data_transfer.rs @@ -0,0 +1,139 @@ +//! Phase M (sync transparency): local backup export/import. +//! +//! Runs in the `Last` schedule — the export snapshot is taken after the +//! whole frame settles, and the import's resource rewrites become visible +//! to every Update system on the next frame, keeping this rare bulk +//! operation off the annotated Update spine entirely. + +use bevy::prelude::*; +use chrono::Utc; +use solitaire_data::{DataBundle, data_bundle_path, load_bundle_from, save_bundle_to}; + +use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; +use crate::events::{ + DataExportRequestEvent, DataImportRequestEvent, InfoToastEvent, WarningToastEvent, +}; +use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; +use crate::stats_plugin::{StatsResource, StatsStoragePath}; + +use super::{SettingsChangedEvent, SettingsResource, SettingsStoragePath}; + +/// Drains the export/import request streams and performs the file IO. +/// +/// The bundle is small (a few KB of JSON), so the write happens inline +/// rather than on the async pool — one synchronous write per explicit +/// button press, never per frame. +#[allow(clippy::too_many_arguments)] +pub(super) fn handle_data_transfer( + mut exports: MessageReader, + mut imports: MessageReader, + mut settings: ResMut, + settings_path: Res, + stats: Option>, + stats_path: Option>, + achievements: Option>, + achievements_path: Option>, + progress: Option>, + progress_path: Option>, + mut settings_changed: MessageWriter, + mut info_toast: MessageWriter, + mut warning_toast: MessageWriter, +) { + let export_requested = exports.read().next().is_some(); + let import_requested = imports.read().next().is_some(); + if !export_requested && !import_requested { + return; + } + // The bundle lives next to settings.json (same app data dir in + // production); deriving it from the injected settings path keeps + // tests off the real platform directory. + let path = settings_path + .0 + .as_ref() + .map(|p| p.with_file_name(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME)) + .or_else(data_bundle_path); + let Some(path) = path else { + warning_toast.write(WarningToastEvent( + "No data directory available on this platform".to_string(), + )); + return; + }; + + // An import in the same frame as an export would race on the file; + // real UI can't produce both, but be deterministic anyway: export + // first, then import reads what was just written (a no-op restore). + if export_requested { + let bundle = DataBundle { + schema_version: solitaire_data::DATA_BUNDLE_SCHEMA_VERSION, + exported_at: Utc::now(), + settings: settings.0.clone(), + stats: stats.as_ref().map(|s| s.0.clone()).unwrap_or_default(), + achievements: achievements + .as_ref() + .map(|a| a.0.clone()) + .unwrap_or_default(), + progress: progress.as_ref().map(|p| p.0.clone()).unwrap_or_default(), + }; + match save_bundle_to(&path, &bundle) { + Ok(()) => { + info_toast.write(InfoToastEvent(format!( + "Data exported to {}", + path.display() + ))); + } + Err(e) => { + warn!("data export failed: {e}"); + warning_toast.write(WarningToastEvent(format!("Export failed: {e}"))); + } + } + } + + if import_requested { + match load_bundle_from(&path) { + Ok(bundle) => { + settings.0 = bundle.settings; + if let Some(target) = &settings_path.0 + && let Err(e) = solitaire_data::save_settings_to(target, &settings.0) + { + warn!("import: failed to persist settings: {e}"); + } + // Settings appliers (theme, audio, layout) react to the + // change event exactly as if edited in the panel. + settings_changed.write(SettingsChangedEvent(settings.0.clone())); + + if let Some(mut s) = stats { + s.0 = bundle.stats; + if let Some(target) = stats_path.as_ref().and_then(|p| p.0.as_ref()) + && let Err(e) = solitaire_data::save_stats_to(target, &s.0) + { + warn!("import: failed to persist stats: {e}"); + } + } + if let Some(mut a) = achievements { + a.0 = bundle.achievements; + if let Some(target) = achievements_path.as_ref().and_then(|p| p.0.as_ref()) + && let Err(e) = solitaire_data::save_achievements_to(target, &a.0) + { + warn!("import: failed to persist achievements: {e}"); + } + } + if let Some(mut p) = progress { + p.0 = bundle.progress; + if let Some(target) = progress_path.as_ref().and_then(|pp| pp.0.as_ref()) + && let Err(e) = solitaire_data::save_progress_to(target, &p.0) + { + warn!("import: failed to persist progress: {e}"); + } + } + info_toast.write(InfoToastEvent(format!( + "Backup from {} restored", + bundle.exported_at.format("%Y-%m-%d %H:%M UTC") + ))); + } + Err(e) => { + warn!("data import failed: {e}"); + warning_toast.write(WarningToastEvent(format!("Import failed: {e}"))); + } + } + } +} diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs index cdef105..0bd47bf 100644 --- a/solitaire_engine/src/settings_plugin/input.rs +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -433,6 +433,9 @@ pub(super) fn handle_settings_buttons( SettingsButton::OpenThemeStore => { // Handled by `handle_sync_buttons`. } + SettingsButton::ExportData | SettingsButton::ImportData => { + // Handled by `handle_sync_buttons`. + } SettingsButton::Done => { screen.0 = false; } @@ -460,14 +463,18 @@ pub(super) fn handle_tab_buttons( } /// Handles sync-related settings buttons: Sync Now, Connect, Disconnect, -/// and Delete Account. Split from `handle_settings_buttons` to stay within -/// Bevy's 16-parameter system limit. +/// Delete Account, and the Phase M Export/Import data pair. Split from +/// `handle_settings_buttons` to stay within Bevy's 16-parameter system +/// limit. +#[allow(clippy::too_many_arguments)] pub(super) fn handle_sync_buttons( interaction_query: Query<(&Interaction, &SettingsButton), Changed>, mut manual_sync: MessageWriter, mut configure_sync: MessageWriter, mut logout_sync: MessageWriter, mut delete_account: MessageWriter, + mut export_data: MessageWriter, + mut import_data: MessageWriter, #[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter< crate::events::ThemeStoreOpenRequestEvent, >, @@ -500,6 +507,12 @@ pub(super) fn handle_sync_buttons( SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); } + SettingsButton::ExportData => { + export_data.write(crate::events::DataExportRequestEvent); + } + SettingsButton::ImportData => { + import_data.write(crate::events::DataImportRequestEvent); + } _ => {} } } diff --git a/solitaire_engine/src/settings_plugin/mod.rs b/solitaire_engine/src/settings_plugin/mod.rs index bc69ad6..6efdfde 100644 --- a/solitaire_engine/src/settings_plugin/mod.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -25,6 +25,7 @@ use crate::events::{ use crate::resources::SettingsScrollPos; use crate::theme::ThemeThumbnailPair; +mod data_transfer; mod input; mod ui; mod updates; @@ -124,6 +125,12 @@ struct ThemeText; #[derive(Component, Debug)] struct SyncStatusText; +/// Marks the headline `Text` node of the last-merge summary in the +/// Account tab (Phase M — sync transparency). Tests query it to assert +/// the summary rendered. +#[derive(Component, Debug)] +pub(super) struct SyncMergeSummaryText; + /// Marks the `Text` node showing the active card-back index. #[derive(Component, Debug)] struct CardBackText; @@ -334,6 +341,10 @@ enum SettingsButton { DisconnectSync, /// Open the account-deletion confirmation modal. DeleteAccount, + /// Write every JSON save into one backup bundle (Phase M). + ExportData, + /// Restore every JSON save from the backup bundle (Phase M). + ImportData, Done, /// Select a specific card-back by index from the picker row. SelectCardBack(usize), @@ -399,6 +410,8 @@ impl SettingsButton { SettingsButton::ConnectSync => 91, SettingsButton::DisconnectSync => 92, SettingsButton::DeleteAccount => 93, + SettingsButton::ExportData => 94, + SettingsButton::ImportData => 95, // Done is tagged by `attach_focusable_to_modal_buttons` and // never reaches `attach_focusable_to_settings_buttons`; the // value here is only a fallback for completeness. @@ -452,6 +465,15 @@ impl Plugin for SettingsPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() + .add_message::() + // Idempotent — the sync plugin also registers it; needed here + // so the transfer handler's failure toasts work headless. + .add_message::() + // Bulk backup/restore lives in Last: the export snapshot is + // taken post-frame and an import's rewrites surface next + // frame — keeps the rare path off the annotated Update spine. + .add_systems(Last, data_transfer::handle_data_transfer) .add_message::() .add_message::() .add_message::() diff --git a/solitaire_engine/src/settings_plugin/tests.rs b/solitaire_engine/src/settings_plugin/tests.rs index 6c57043..f2fc893 100644 --- a/solitaire_engine/src/settings_plugin/tests.rs +++ b/solitaire_engine/src/settings_plugin/tests.rs @@ -793,3 +793,93 @@ fn ui_scale_out_of_range_sanitizes_on_load() { .sanitized(); assert_eq!(tiny.ui_scale, UI_SCALE_MIN); } + +// --------------------------------------------------------------------------- +// Phase M: local data export / import +// --------------------------------------------------------------------------- + +/// App with a real (temp-dir) settings path so the data-transfer handler +/// derives its bundle path inside the temp dir instead of the platform +/// data directory. +fn transfer_app(dir: &std::path::Path) -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(SettingsPlugin { + storage_path: Some(dir.join("settings.json")), + ui_enabled: false, + }); + app.init_resource::>(); + app.update(); + app +} + +#[test] +fn export_then_import_round_trips_settings() { + let dir = std::env::temp_dir().join(format!("settings_transfer_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let mut app = transfer_app(&dir); + + app.world_mut() + .resource_mut::() + .0 + .sfx_volume = 0.25; + app.world_mut() + .write_message(crate::events::DataExportRequestEvent); + app.update(); + assert!( + dir.join(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME) + .exists(), + "export should write the bundle next to settings.json" + ); + + // Clobber the live value, then restore from the bundle. + app.world_mut() + .resource_mut::() + .0 + .sfx_volume = 1.0; + app.world_mut() + .write_message(crate::events::DataImportRequestEvent); + app.update(); + // Import handling runs in Last of the same frame; the rewrite is + // visible immediately after update() returns. + let restored = app.world().resource::().0.sfx_volume; + assert!( + (restored - 0.25).abs() < f32::EPSILON, + "import should restore the exported value, got {restored}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn import_without_backup_warns_instead_of_defaulting() { + let dir = + std::env::temp_dir().join(format!("settings_transfer_missing_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let mut app = transfer_app(&dir); + + app.world_mut() + .resource_mut::() + .0 + .sfx_volume = 0.25; + app.world_mut() + .write_message(crate::events::DataImportRequestEvent); + app.update(); + + // Settings untouched (no silent default-restore)… + let v = app.world().resource::().0.sfx_volume; + assert!( + (v - 0.25).abs() < f32::EPSILON, + "failed import must not touch settings" + ); + // …and the failure is surfaced as a warning toast. + let msgs = app + .world() + .resource::>(); + let mut cursor = msgs.get_cursor(); + assert!( + cursor.read(msgs).any(|w| w.0.contains("Import failed")), + "missing backup should emit a warning toast" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/solitaire_engine/src/settings_plugin/ui.rs b/solitaire_engine/src/settings_plugin/ui.rs index 9c29e4a..8b65572 100644 --- a/solitaire_engine/src/settings_plugin/ui.rs +++ b/solitaire_engine/src/settings_plugin/ui.rs @@ -46,6 +46,7 @@ pub(super) fn spawn_settings_panel( scroll_offset: f32, font_res: Option<&FontResource>, theme_overrides_back: bool, + conflict_log: Option<&crate::resources::SyncConflictLog>, active_tab: SettingsTab, ) { spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| { @@ -100,7 +101,9 @@ pub(super) fn spawn_settings_panel( font_res, ), SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res), - SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res), + SettingsTab::Account => { + spawn_account_tab(body, settings, sync_status, conflict_log, font_res) + } }); // Done is the only action — primary so the player always knows @@ -353,6 +356,7 @@ fn spawn_account_tab( body: &mut ChildSpawnerCommands, settings: &Settings, sync_status: &str, + conflict_log: Option<&crate::resources::SyncConflictLog>, font_res: Option<&FontResource>, ) { // --- Privacy (only shown when a Matomo URL is configured) --- @@ -372,6 +376,144 @@ fn spawn_account_tab( // --- Sync --- section_label(body, "Sync", font_res); sync_row(body, sync_status, &settings.sync_backend, font_res); + spawn_sync_merge_summary(body, conflict_log, font_res); + + // --- Local data (Phase M) --- backup/restore of the JSON saves; + // filesystem-backed, so not offered on the wasm build. + #[cfg(not(target_arch = "wasm32"))] + { + section_label(body, "Local data", font_res); + local_data_row(body, font_res); + } +} + +/// `Export data` / `Import data` pill buttons plus a caption naming the +/// backup file (Phase M). Mirrors the sync row's pill-button styling. +#[cfg(not(target_arch = "wasm32"))] +fn local_data_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { + let button_font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + let caption_font = button_font.clone(); + + let small_button = |row: &mut ChildSpawnerCommands, + marker: SettingsButton, + label: &str, + tooltip: String, + font: TextFont| { + row.spawn(( + marker, + Button, + Tooltip::new(tooltip), + Node { + padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BackgroundColor(BG_ELEVATED_HI), + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|b| { + b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY))); + }); + }; + + parent + .spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|col| { + col.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + flex_wrap: FlexWrap::Wrap, + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|row| { + small_button( + row, + SettingsButton::ExportData, + "Export data", + "Write settings, stats, achievements and progress to one backup file" + .to_string(), + button_font.clone(), + ); + small_button( + row, + SettingsButton::ImportData, + "Import data", + "Restore all saves from the backup file (replaces current data)".to_string(), + button_font.clone(), + ); + }); + col.spawn(( + Text::new(format!( + "Backup file: {}", + solitaire_data::data_bundle_path() + .map_or_else(|| "unavailable".to_string(), |p| p.display().to_string()) + )), + caption_font, + TextColor(TEXT_SECONDARY), + )); + }); +} + +/// Phase M (sync transparency): renders what the most recent sync merge +/// did — a clean-merge caption, or one line per conflicting field with +/// both values, so the player can see exactly which numbers were +/// reconciled and how. +fn spawn_sync_merge_summary( + body: &mut ChildSpawnerCommands, + conflict_log: Option<&crate::resources::SyncConflictLog>, + font_res: Option<&FontResource>, +) { + let Some(summary) = conflict_log.and_then(|log| log.0.as_ref()) else { + return; + }; + let caption_font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + let when = summary.at.format("%H:%M UTC"); + if summary.conflicts.is_empty() { + body.spawn(( + SyncMergeSummaryText, + Text::new(format!("Last merge at {when}: no conflicts")), + caption_font, + TextColor(TEXT_SECONDARY), + )); + return; + } + let n = summary.conflicts.len(); + let plural = if n == 1 { "conflict" } else { "conflicts" }; + body.spawn(( + SyncMergeSummaryText, + Text::new(format!( + "Last merge at {when}: {n} {plural} — kept newer values" + )), + caption_font.clone(), + TextColor(TEXT_SECONDARY), + )); + for c in &summary.conflicts { + body.spawn(( + Text::new(format!( + " {} — this device: {} / server: {}", + c.field, c.local_value, c.remote_value + )), + caption_font.clone(), + TextColor(TEXT_SECONDARY), + )); + } } /// Section divider — small lavender label inside the scrollable body. diff --git a/solitaire_engine/src/settings_plugin/updates.rs b/solitaire_engine/src/settings_plugin/updates.rs index 63a2b6e..43ba6bd 100644 --- a/solitaire_engine/src/settings_plugin/updates.rs +++ b/solitaire_engine/src/settings_plugin/updates.rs @@ -32,6 +32,7 @@ pub(super) fn sync_settings_panel_visibility( theme_registry: Option>, theme_thumbs: Option>, card_images: Option>, + conflict_log: Option>, ) { if !screen.is_changed() { return; @@ -47,6 +48,7 @@ pub(super) fn sync_settings_panel_visibility( theme_registry.as_deref(), theme_thumbs.as_deref(), card_images.as_deref(), + conflict_log.as_deref(), scroll_pos.0, active_tab.0, ); @@ -76,6 +78,7 @@ fn build_panel( theme_registry: Option<&crate::theme::ThemeRegistry>, theme_thumbs: Option<&ThemeThumbnailCache>, card_images: Option<&crate::card_plugin::CardImageSet>, + conflict_log: Option<&crate::resources::SyncConflictLog>, scroll_offset: f32, active_tab: SettingsTab, ) { @@ -125,6 +128,7 @@ fn build_panel( scroll_offset, font_res, theme_overrides_back, + conflict_log, active_tab, ); } @@ -145,6 +149,7 @@ pub(super) fn rebuild_panel_on_tab_change( theme_registry: Option>, theme_thumbs: Option>, card_images: Option>, + conflict_log: Option>, ) { if !active_tab.is_changed() || active_tab.is_added() { return; @@ -167,6 +172,7 @@ pub(super) fn rebuild_panel_on_tab_change( theme_registry.as_deref(), theme_thumbs.as_deref(), card_images.as_deref(), + conflict_log.as_deref(), 0.0, active_tab.0, ); diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index cf9e5cf..4ec3565 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -26,11 +26,15 @@ use solitaire_sync::{SyncPayload, merge}; use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; use crate::events::{ - GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent, + GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, + WarningToastEvent, }; use crate::game_plugin::RecordingReplay; use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; -use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource}; +use crate::resources::{ + GameStateResource, SyncConflictLog, SyncMergeSummary, SyncStatus, SyncStatusResource, + TokioRuntimeResource, +}; use crate::stats_plugin::{ LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath, }; @@ -106,9 +110,13 @@ impl Plugin for SyncPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() - .add_message::(); + .add_message::() + // Idempotent — GamePlugin also registers it; re-register so the + // conflict-summary toast works under MinimalPlugins tests. + .add_message::(); // Build the shared Tokio runtime; disable all network sync if the OS // refuses to create threads (resource-limited environments, sandboxed @@ -198,6 +206,8 @@ fn poll_pull_result( progress_path: Res, mut configure_sync: MessageWriter, mut warning_toast: MessageWriter, + mut conflict_log: ResMut, + mut info_toast: MessageWriter, ) { let Some(task) = task_res.0.as_mut() else { return; @@ -210,7 +220,21 @@ fn poll_pull_result( match result { Ok(remote) => { let local = build_payload(&stats.0, &achievements.0, &progress.0); - let (merged, _conflicts) = merge(&local, &remote); + let (merged, conflicts) = merge(&local, &remote); + + // Phase M (sync transparency): keep the conflict details for + // the Account tab and summarise non-clean merges in a toast. + if !conflicts.is_empty() { + let n = conflicts.len(); + let plural = if n == 1 { "conflict" } else { "conflicts" }; + info_toast.write(InfoToastEvent(format!( + "Synced — {n} {plural}, kept newer values" + ))); + } + conflict_log.0 = Some(SyncMergeSummary { + at: Utc::now(), + conflicts, + }); // Persist merged state atomically. if let Some(p) = &stats_path.0