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:
@@ -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