feat(engine): Phase M — sync transparency + local data backup
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 4m22s

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:
funman300
2026-07-16 18:06:01 -07:00
parent b4845564af
commit 2f373784bf
11 changed files with 617 additions and 7 deletions
@@ -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}")));
}
}
}
}
+15 -2
View File
@@ -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);
}
+143 -1
View File
@@ -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,
);