feat(engine): Phase M — sync transparency + local data backup #189
@@ -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,
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub settings: Settings,
|
||||
pub stats: StatsSnapshot,
|
||||
pub achievements: Vec<AchievementRecord>,
|
||||
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: `<data dir>/<app dir>/ferrous-solitaire-backup.json`,
|
||||
/// or `None` when no data directory is available on this platform.
|
||||
pub fn data_bundle_path() -> Option<PathBuf> {
|
||||
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<DataBundle, BundleError> {
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 /
|
||||
|
||||
@@ -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<SyncMergeSummary>);
|
||||
|
||||
/// 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<Utc>,
|
||||
/// Fields that could not be merged deterministically (best-effort
|
||||
/// resolution already applied — see `solitaire_sync::merge`).
|
||||
pub conflicts: Vec<solitaire_sync::ConflictReport>,
|
||||
}
|
||||
|
||||
/// Tracks which hint the player is currently cycling through.
|
||||
///
|
||||
/// Incremented on each H press so repeated presses reveal different moves.
|
||||
|
||||
@@ -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<DataExportRequestEvent>,
|
||||
mut imports: MessageReader<DataImportRequestEvent>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
settings_path: Res<SettingsStoragePath>,
|
||||
stats: Option<ResMut<StatsResource>>,
|
||||
stats_path: Option<Res<StatsStoragePath>>,
|
||||
achievements: Option<ResMut<AchievementsResource>>,
|
||||
achievements_path: Option<Res<AchievementsStoragePath>>,
|
||||
progress: Option<ResMut<ProgressResource>>,
|
||||
progress_path: Option<Res<ProgressStoragePath>>,
|
||||
mut settings_changed: MessageWriter<SettingsChangedEvent>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
) {
|
||||
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}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Interaction>>,
|
||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
mut export_data: MessageWriter<crate::events::DataExportRequestEvent>,
|
||||
mut import_data: MessageWriter<crate::events::DataImportRequestEvent>,
|
||||
#[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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<crate::events::DataExportRequestEvent>()
|
||||
.add_message::<crate::events::DataImportRequestEvent>()
|
||||
// Idempotent — the sync plugin also registers it; needed here
|
||||
// so the transfer handler's failure toasts work headless.
|
||||
.add_message::<crate::events::WarningToastEvent>()
|
||||
// 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::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
|
||||
@@ -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::<ButtonInput<KeyCode>>();
|
||||
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::<SettingsResource>()
|
||||
.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::<SettingsResource>()
|
||||
.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::<SettingsResource>().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::<SettingsResource>()
|
||||
.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::<SettingsResource>().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::<Messages<crate::events::WarningToastEvent>>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) fn sync_settings_panel_visibility(
|
||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
|
||||
) {
|
||||
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<Res<crate::theme::ThemeRegistry>>,
|
||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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::<PullTaskResult>()
|
||||
.init_resource::<PullTask>()
|
||||
.init_resource::<PendingReplayUpload>()
|
||||
.init_resource::<SyncConflictLog>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<WarningToastEvent>();
|
||||
.add_message::<WarningToastEvent>()
|
||||
// Idempotent — GamePlugin also registers it; re-register so the
|
||||
// conflict-summary toast works under MinimalPlugins tests.
|
||||
.add_message::<InfoToastEvent>();
|
||||
|
||||
// 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<ProgressStoragePath>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
mut conflict_log: ResMut<SyncConflictLog>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user