feat(engine): "Copy share link" Stats button — clipboards the replay URL
Quat: replay sharing as the next punch-list item. End-to-end: 1. Player wins a game on a server-backed sync backend. 2. `sync_plugin::push_replay_on_win` spawns the upload task on `AsyncComputeTaskPool` and stores the handle in the new `PendingReplayUpload` resource. The previous in-flight task (if any) is dropped — the most recent win is the one whose share link the player will care about. 3. `poll_replay_upload_result` harvests the task on the main thread each frame; on success writes `<server>/replays/<id>` to `LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider) is silently absorbed; real network/auth errors warn-log. 4. The Stats overlay's action bar gains a "Copy share link" button. Click writes `LastSharedReplayUrl` to the OS clipboard via `arboard` and surfaces a "Copied: <url>" toast. Trait change: `SyncProvider::push_replay` now returns `Result<String, SyncError>` (the share URL) instead of `Result<(), SyncError>`. The default (`UnsupportedPlatform`) is unchanged for non-server backends; `SolitaireServerClient` parses the response body's `id` field and composes `<base_url>/replays/<id>`. Both call paths (initial + 401 retry) go through the new `share_url_from_response` helper so the parse logic isn't duplicated. New deps: - `arboard` (~10 KB, cross-platform clipboard) added to workspace + `solitaire_engine`. `default-features = false` keeps the X11/Wayland binary-feature deps off the dependency graph; arboard handles the fallback. Approved per the ASK BEFORE rule. Persistence: the URL is in-memory only — the player must share within the session of the win. A future revision can persist it alongside the replay history file if cross-session sharing is needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,26 @@ 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<String>);
|
||||
|
||||
/// 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.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CopyShareLinkButton;
|
||||
|
||||
/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
|
||||
///
|
||||
/// `0` is the most recent win and is the default on every modal open.
|
||||
@@ -175,6 +195,7 @@ impl Plugin for StatsPlugin {
|
||||
.insert_resource(ReplayHistoryResource(initial_history))
|
||||
.init_resource::<SelectedReplayIndex>()
|
||||
.insert_resource(LatestReplayPath(replay_path))
|
||||
.init_resource::<LastSharedReplayUrl>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
@@ -210,6 +231,7 @@ impl Plugin for StatsPlugin {
|
||||
refresh_replay_history_on_win.after(GameMutation),
|
||||
)
|
||||
.add_systems(Update, handle_watch_replay_button)
|
||||
.add_systems(Update, handle_copy_share_link_button)
|
||||
.add_systems(
|
||||
Update,
|
||||
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
|
||||
@@ -277,6 +299,48 @@ 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.
|
||||
fn handle_copy_share_link_button(
|
||||
buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
|
||||
last_url: Res<LastSharedReplayUrl>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
let Some(url) = last_url.0.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(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut cb) => match cb.set_text(url.clone()) {
|
||||
Ok(()) => {
|
||||
toast.write(InfoToastEvent(format!("Copied: {url}")));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("clipboard write failed: {e}");
|
||||
toast.write(InfoToastEvent(
|
||||
"Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(),
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("clipboard init failed: {e}");
|
||||
toast.write(InfoToastEvent(
|
||||
"Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_watch_replay_button(
|
||||
mut commands: Commands,
|
||||
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
|
||||
@@ -811,6 +875,19 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
// Copy share link only renders when a sharable URL is in
|
||||
// hand. The button is intentionally absent (rather than
|
||||
// disabled) when no upload has happened yet — keeps the
|
||||
// action bar free of dead controls in the local-only and
|
||||
// first-launch cases.
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
CopyShareLinkButton,
|
||||
"Copy share link",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
StatsCloseButton,
|
||||
|
||||
@@ -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::{StatsResource, StatsStoragePath};
|
||||
use crate::stats_plugin::{LastSharedReplayUrl, StatsResource, StatsStoragePath};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public resources
|
||||
@@ -57,6 +57,13 @@ pub struct PullTaskResult(pub Option<Result<SyncPayload, SyncError>>);
|
||||
#[derive(Resource, Default)]
|
||||
struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>);
|
||||
|
||||
/// Holds the in-flight winning-replay upload task so the polling
|
||||
/// system can harvest the resulting share URL on the main thread
|
||||
/// without blocking. `None` outside an active upload; `Some(task)`
|
||||
/// from `GameWonEvent` until the response lands.
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin struct
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,12 +101,18 @@ impl Plugin for SyncPlugin {
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.init_resource::<PullTaskResult>()
|
||||
.init_resource::<PullTask>()
|
||||
.init_resource::<PendingReplayUpload>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(
|
||||
Update,
|
||||
(poll_pull_result, handle_manual_sync_request, push_replay_on_win),
|
||||
(
|
||||
poll_pull_result,
|
||||
handle_manual_sync_request,
|
||||
push_replay_on_win,
|
||||
poll_replay_upload_result,
|
||||
),
|
||||
)
|
||||
.add_systems(Last, push_on_exit);
|
||||
}
|
||||
@@ -282,6 +295,7 @@ fn push_replay_on_win(
|
||||
provider: Res<SyncProviderResource>,
|
||||
game: Res<GameStateResource>,
|
||||
recording: Res<RecordingReplay>,
|
||||
mut pending: ResMut<PendingReplayUpload>,
|
||||
) {
|
||||
for ev in wins.read() {
|
||||
// Empty-recording guard mirrors `record_replay_on_win` —
|
||||
@@ -300,15 +314,39 @@ fn push_replay_on_win(
|
||||
recording.moves.clone(),
|
||||
);
|
||||
let provider = provider.0.clone();
|
||||
AsyncComputeTaskPool::get()
|
||||
.spawn(async move {
|
||||
match provider.push_replay(&replay).await {
|
||||
Ok(()) => {}
|
||||
Err(SyncError::UnsupportedPlatform) => {}
|
||||
Err(e) => warn!("replay upload failed: {e}"),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { provider.push_replay(&replay).await });
|
||||
// If a previous upload is still in flight, drop it — the most
|
||||
// recent win is the one whose share link the player will care
|
||||
// about. Bevy's `Task` Drop cancels cooperatively.
|
||||
pending.0 = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn poll_replay_upload_result(
|
||||
mut pending: ResMut<PendingReplayUpload>,
|
||||
mut last_url: ResMut<LastSharedReplayUrl>,
|
||||
) {
|
||||
let Some(task) = pending.0.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return;
|
||||
};
|
||||
pending.0 = None;
|
||||
match result {
|
||||
Ok(url) => last_url.0 = Some(url),
|
||||
Err(SyncError::UnsupportedPlatform) => last_url.0 = None,
|
||||
Err(e) => {
|
||||
warn!("replay upload failed: {e}");
|
||||
last_url.0 = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user