feat(engine): Phase M — sync transparency + local data backup
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user