diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index 51f1eb8..25c4f22 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -138,6 +138,15 @@ pub struct Replay { /// Ordered move list. Each entry is what the player did, replayable /// against a fresh `GameState` constructed from the seed. pub moves: Vec, + /// Public share URL for this replay on the active sync backend, set + /// by `sync_plugin::poll_replay_upload_result` when the upload + /// task resolves. `None` when the player won on a local-only + /// backend, the upload failed, or the replay pre-dates v0.19.0 + /// share-link persistence. `#[serde(default)]` keeps older + /// `replays.json` files loadable without bumping + /// [`REPLAY_SCHEMA_VERSION`]. + #[serde(default)] + pub share_url: Option, } impl Replay { @@ -162,6 +171,7 @@ impl Replay { final_score, recorded_at, moves, + share_url: None, } } } @@ -481,6 +491,34 @@ mod tests { let _ = fs::remove_file(&path); } + /// Backwards-compat: a `Replay` record persisted before v0.19.0 + /// share-link persistence carries no `share_url` field on disk. + /// `#[serde(default)]` must let it deserialise cleanly with + /// `share_url == None`, so existing players don't see their + /// rolling history wiped on the v0.19.0 update. + #[test] + fn replay_loads_when_share_url_field_is_absent() { + let pre_v019_json = format!( + r#"{{ + "schema_version": {schema}, + "seed": 1, + "draw_mode": "DrawOne", + "mode": "Classic", + "time_seconds": 60, + "final_score": 100, + "recorded_at": "2025-01-01", + "moves": [] + }}"#, + schema = REPLAY_SCHEMA_VERSION, + ); + let parsed: Replay = serde_json::from_str(&pre_v019_json) + .expect("pre-v0.19.0 replay JSON must still deserialise"); + assert!( + parsed.share_url.is_none(), + "missing share_url field must default to None", + ); + } + /// Atomic-write contract — `.tmp` must not be left behind after /// `save_latest_replay_to` returns. Mirrors the same check that /// guards `save_game_state_to` in `storage.rs`. diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 790c1cf..c1b1383 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -74,23 +74,13 @@ pub struct StatsCell; #[derive(Resource, Debug, Default, Clone)] pub struct ReplayHistoryResource(pub ReplayHistory); -/// Most recent shareable replay URL written by `sync_plugin` after the -/// `SyncProvider::push_replay` task completes successfully. `None` -/// until the player wins a game on a server-backed sync backend; -/// repopulated on each subsequent win. -/// -/// The Stats overlay's "Copy share link" button reads from here and -/// writes the URL to the OS clipboard via `arboard`. Not persisted to -/// disk — the URL is recoverable by re-uploading the same replay -/// (still in `replays.json`), so the session-bound lifetime is fine -/// for a v1 share affordance. -#[derive(Resource, Debug, Default, Clone)] -pub struct LastSharedReplayUrl(pub Option); - /// Marker on the "Copy share link" button inside the Stats modal. -/// Click writes [`LastSharedReplayUrl`] to the OS clipboard via -/// `arboard` and surfaces a confirmation toast. Hidden / disabled -/// when no shareable URL is available. +/// Click reads the share URL from the currently-selected replay +/// (`history.0.replays[selected.0].share_url`) and writes it to the +/// OS clipboard via `arboard`, surfacing a confirmation toast. The +/// share URL is populated by `sync_plugin::poll_replay_upload_result` +/// when the corresponding win's upload completes and is persisted to +/// `replays.json` so it survives a restart. #[derive(Component, Debug)] pub struct CopyShareLinkButton; @@ -195,7 +185,6 @@ impl Plugin for StatsPlugin { .insert_resource(ReplayHistoryResource(initial_history)) .init_resource::() .insert_resource(LatestReplayPath(replay_path)) - .init_resource::() .add_message::() .add_message::() .add_message::() @@ -299,24 +288,32 @@ fn refresh_replay_history_on_win( /// resets the live game to the recorded deal and ticks through the /// move list via [`crate::replay_playback`]; the /// [`crate::replay_overlay`] banner surfaces while playback runs. -/// Copies [`LastSharedReplayUrl`] to the OS clipboard via `arboard` -/// and surfaces a confirmation toast. When no URL is in hand (no win -/// yet on a server-backed sync backend, local-only mode, or upload -/// failed) the button still acknowledges the click but explains why -/// the clipboard wasn't written. `arboard::Clipboard::new()` failures -/// are logged + surfaced as a generic "couldn't reach the clipboard" -/// toast rather than swallowed — they're rare but worth diagnosing. +/// Copies the currently-selected replay's `share_url` to the OS +/// clipboard via `arboard` and surfaces a confirmation toast. When no +/// URL is in hand on the selected entry (replay never uploaded — the +/// player won on a local-only backend, the upload failed, or the +/// replay pre-dates v0.19.0 share-link persistence) the button still +/// acknowledges the click but explains why the clipboard wasn't +/// written. `arboard::Clipboard::new()` failures are logged + surfaced +/// as a generic "couldn't reach the clipboard" toast rather than +/// swallowed — they're rare but worth diagnosing. fn handle_copy_share_link_button( buttons: Query<&Interaction, (With, Changed)>, - last_url: Res, + history: Res, + selected: Res, mut toast: MessageWriter, ) { if !buttons.iter().any(|i| *i == Interaction::Pressed) { return; } - let Some(url) = last_url.0.as_ref() else { + let Some(url) = history + .0 + .replays + .get(selected.0) + .and_then(|r| r.share_url.as_ref()) + else { toast.write(InfoToastEvent( - "No share link yet \u{2014} win a game on a server-backed sync to upload one.".to_string(), + "No share link for this replay \u{2014} win a game on a server-backed sync to upload one.".to_string(), )); return; }; diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 19e68c2..63f3d4e 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -19,8 +19,8 @@ use chrono::Utc; use uuid::Uuid; use solitaire_data::{ - save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress, - Replay, StatsSnapshot, SyncError, SyncProvider, + save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to, + AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider, }; use solitaire_sync::{merge, SyncPayload, SyncResponse}; @@ -29,7 +29,7 @@ use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent}; use crate::game_plugin::RecordingReplay; use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource}; -use crate::stats_plugin::{LastSharedReplayUrl, StatsResource, StatsStoragePath}; +use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath}; // --------------------------------------------------------------------------- // Public resources @@ -324,14 +324,18 @@ fn push_replay_on_win( } /// Update-schedule system: harvests the upload task's result on the -/// main thread once it resolves. On success writes the share URL to -/// [`LastSharedReplayUrl`] so the Stats overlay's Copy button has -/// something to send to the clipboard. On `UnsupportedPlatform` (the -/// `LocalOnlyProvider` no-op path) clears the URL silently. Real -/// network / auth errors log a warn and clear the URL. +/// main thread once it resolves. On success writes the share URL into +/// the most-recent entry of [`ReplayHistoryResource`] (`replays[0]`, +/// guaranteed by `record_replay_on_win` to be the win this upload +/// covers, since `cancel-on-replace` in `push_replay_on_win` drops any +/// older in-flight task) and persists the updated history to disk so +/// the URL survives a restart. `UnsupportedPlatform` (the +/// `LocalOnlyProvider` no-op path) is silently absorbed; real network +/// / auth errors log a warn but never clobber an existing URL. fn poll_replay_upload_result( mut pending: ResMut, - mut last_url: ResMut, + mut history: ResMut, + replay_path: Res, ) { let Some(task) = pending.0.as_mut() else { return; @@ -340,13 +344,25 @@ fn poll_replay_upload_result( return; }; pending.0 = None; - match result { - Ok(url) => last_url.0 = Some(url), - Err(SyncError::UnsupportedPlatform) => last_url.0 = None, + let url = match result { + Ok(url) => url, + Err(SyncError::UnsupportedPlatform) => return, Err(e) => { warn!("replay upload failed: {e}"); - last_url.0 = None; + return; } + }; + let Some(entry) = history.0.replays.first_mut() else { + // Defensive: `push_replay_on_win` only fires after a win, so a + // missing replays[0] means another system cleared the history + // mid-upload. Drop the URL silently rather than panicking. + return; + }; + entry.share_url = Some(url); + if let Some(path) = replay_path.0.as_deref() + && let Err(e) = save_replay_history_to(path, &history.0) + { + warn!("failed to persist share URL into replay history: {e}"); } } @@ -514,4 +530,87 @@ mod tests { let payload = build_payload(&stats, &[], &PlayerProgress::default()); assert_eq!(payload.stats.games_played, 42); } + + /// `poll_replay_upload_result` must write the resolved share URL + /// into `replays[0].share_url` AND persist the updated history to + /// disk so the URL survives a restart. Pins v0.19.0's persistent + /// share-link contract — the v0.18.0 ephemeral + /// `LastSharedReplayUrl` resource is gone, so a regression here + /// would silently drop the link. + #[test] + fn upload_result_writes_share_url_into_replay_and_persists() { + use solitaire_core::game_state::{DrawMode, GameMode}; + use solitaire_data::{ + load_replay_history_from, save_replay_history_to, Replay, ReplayHistory, + }; + + let mut app = headless_app_with(NoOpProvider); + let path = std::env::temp_dir() + .join("solitaire_test_replay_share_url_persist.json"); + let _ = std::fs::remove_file(&path); + + // Seed the in-memory history with a single replay carrying no + // share_url — the upload-poll path must populate it. + let initial = Replay::new( + 42, + DrawMode::DrawOne, + GameMode::Classic, + 60, + 500, + chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"), + vec![], + ); + let history = ReplayHistory { + schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION, + replays: vec![initial], + }; + save_replay_history_to(&path, &history).expect("seed history on disk"); + app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history)); + app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone()))); + + // Pre-resolved task carrying the URL the production path would + // get back from the server. + let url = "https://example.test/replays/abc123".to_string(); + let task = AsyncComputeTaskPool::get().spawn({ + let url = url.clone(); + async move { Ok::(url) } + }); + app.world_mut() + .resource_mut::() + .0 = Some(task); + + // Pump frames until the polling system observes the task as + // ready and clears `PendingReplayUpload`. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while app.world().resource::().0.is_some() { + app.update(); + std::thread::yield_now(); + if std::time::Instant::now() >= deadline { + break; + } + } + assert!( + app.world().resource::().0.is_none(), + "upload task should have been consumed within 15 s wall-clock", + ); + + // In-memory contract: replays[0].share_url is now Some(url). + let live = app + .world() + .resource::(); + assert_eq!( + live.0.replays.first().and_then(|r| r.share_url.clone()), + Some(url.clone()), + "share URL must be written into replays[0].share_url", + ); + // Persistence contract: a fresh load picks up the same URL. + let on_disk = load_replay_history_from(&path).expect("history must reload"); + assert_eq!( + on_disk.replays.first().and_then(|r| r.share_url.clone()), + Some(url), + "share URL must survive a save/load round-trip", + ); + + let _ = std::fs::remove_file(&path); + } }