feat(engine): "Watch replay" affordance in Stats overlay

The Stats screen now shows the most recent winning replay's caption
("M:SS win on YYYY-MM-DD") and a Watch Replay button. Until the web
viewer is fully wired the click fires a toast pointing the player at
the upcoming `<server>/replays/<id>` URL; once the upload + page
ship the toast is replaced with an actual link.

- New resources `LatestReplayResource(Option<Replay>)` and
  `LatestReplayPath(Option<PathBuf>)` populated at plugin build time
  from the platform-default `latest_replay.json`. Headless mode
  disables I/O the same way `StatsResource` does.
- `refresh_latest_replay_on_win` re-loads from disk after every
  `GameWonEvent` so opening the modal a second time reflects the
  most recent victory rather than a stale snapshot.
- `format_replay_caption` is a pure helper exposed for both the
  Stats button label and (later) toast messaging.
- `WatchReplayButton` marker added to `solitaire_engine`'s public
  re-exports so the future web-side click integrations can match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 18:41:55 +00:00
parent 57d1c58fdf
commit d9f36bf34a
2 changed files with 133 additions and 4 deletions
+4 -1
View File
@@ -122,7 +122,10 @@ pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
}; };
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use stats_plugin::{
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource,
StatsScreen, StatsUpdate, WatchReplayButton,
};
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{ pub use ui_modal::{
+129 -3
View File
@@ -11,8 +11,8 @@ use std::path::PathBuf;
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsExt, StatsSnapshot, latest_replay_path, load_latest_replay_from, load_stats_from, save_stats_to, stats_file_path,
WEEKLY_GOALS, PlayerProgress, Replay, StatsExt, StatsSnapshot, WEEKLY_GOALS,
}; };
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -58,6 +58,30 @@ pub struct StatsScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StatsCell; pub struct StatsCell;
/// Resource holding the most recently loaded winning [`Replay`], if any.
///
/// Populated from `<data_dir>/solitaire_quest/latest_replay.json` at
/// startup and refreshed in-place whenever the engine writes a new
/// winning replay (the path the Stats UI calls into is unchanged so a
/// re-open of the modal sees the latest record).
///
/// The Stats overlay reads this to decide whether to render the
/// "Watch replay" call-to-action or the "No replay recorded yet"
/// caption.
#[derive(Resource, Debug, Default, Clone)]
pub struct LatestReplayResource(pub Option<Replay>);
/// Persistence path for the latest winning replay file. `None` disables
/// I/O — used by tests and by `StatsPlugin::headless`.
#[derive(Resource, Debug, Clone)]
pub struct LatestReplayPath(pub Option<PathBuf>);
/// Marker on the "Watch replay" button inside the Stats modal. Clicking
/// it currently fires an [`InfoToastEvent`] indicating playback ships
/// in a future build — see [`handle_watch_replay_button`].
#[derive(Component, Debug)]
pub struct WatchReplayButton;
/// Registers stats resources, update systems, and the UI toggle. /// Registers stats resources, update systems, and the UI toggle.
pub struct StatsPlugin { pub struct StatsPlugin {
/// Where to persist stats. `None` disables all file I/O (for tests). /// Where to persist stats. `None` disables all file I/O (for tests).
@@ -87,8 +111,18 @@ impl Plugin for StatsPlugin {
Some(path) => load_stats_from(path), Some(path) => load_stats_from(path),
None => StatsSnapshot::default(), None => StatsSnapshot::default(),
}; };
// Replay file lives next to stats.json — when the StatsPlugin
// is in headless mode (storage_path = None), we mirror that
// policy and disable replay I/O too. Otherwise resolve the
// platform-default path via `latest_replay_path()`.
let replay_path = self.storage_path.as_ref().and(latest_replay_path());
let initial_replay = replay_path
.as_deref()
.and_then(load_latest_replay_from);
app.insert_resource(StatsResource(loaded)) app.insert_resource(StatsResource(loaded))
.insert_resource(StatsStoragePath(self.storage_path.clone())) .insert_resource(StatsStoragePath(self.storage_path.clone()))
.insert_resource(LatestReplayResource(initial_replay))
.insert_resource(LatestReplayPath(replay_path))
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
@@ -114,10 +148,72 @@ impl Plugin for StatsPlugin {
handle_forfeit.before(GameMutation), handle_forfeit.before(GameMutation),
) )
.add_systems(Update, toggle_stats_screen.after(GameMutation)) .add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button); .add_systems(Update, handle_stats_close_button)
.add_systems(
Update,
refresh_latest_replay_on_win.after(GameMutation),
)
.add_systems(Update, handle_watch_replay_button);
} }
} }
/// After a win, the engine has just persisted a fresh winning replay.
/// Re-load it so the next time the player opens the Stats overlay, the
/// "Watch replay" call-to-action reflects the most recent victory
/// rather than an older session.
fn refresh_latest_replay_on_win(
mut wins: MessageReader<GameWonEvent>,
mut latest: ResMut<LatestReplayResource>,
path: Res<LatestReplayPath>,
) {
// Only re-load when at least one win actually fired.
if wins.read().next().is_none() {
return;
}
let Some(p) = path.0.as_deref() else {
return;
};
latest.0 = load_latest_replay_from(p);
}
/// Click handler for the "Watch replay" button.
///
/// Replay playback lives on the sync server's web UI rather than in
/// the desktop client. This handler currently surfaces a clear toast
/// pointing the player there once the upload + URL is wired; until
/// then it acknowledges the click and signals that the feature is on
/// the way.
fn handle_watch_replay_button(
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
latest: Res<LatestReplayResource>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
let message = match &latest.0 {
Some(replay) => format!(
"Replay ready ({}) \u{2014} web playback coming in a future build",
format_replay_caption(replay),
),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
toast.write(InfoToastEvent(message));
}
/// Pure helper: render a one-line caption for a [`Replay`] suitable
/// for the Stats overlay button label and the "Replay loaded" toast.
///
/// Format: `"M:SS win on YYYY-MM-DD"`. For a 134-second win recorded
/// on 2026-05-02, returns `"2:14 win on 2026-05-02"`.
pub fn format_replay_caption(replay: &Replay) -> String {
format!(
"{} win on {}",
format_duration(replay.time_seconds),
replay.recorded_at,
)
}
fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) { fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
let Some(target) = &path.0 else { let Some(target) = &path.0 else {
return; return;
@@ -247,6 +343,7 @@ fn toggle_stats_screen(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
latest_replay: Res<LatestReplayResource>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -262,6 +359,7 @@ fn toggle_stats_screen(
progress.as_deref().map(|p| &p.0), progress.as_deref().map(|p| &p.0),
time_attack.as_deref(), time_attack.as_deref(),
font_res.as_deref(), font_res.as_deref(),
latest_replay.0.as_ref(),
); );
} }
} }
@@ -287,6 +385,7 @@ fn spawn_stats_screen(
progress: Option<&PlayerProgress>, progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>, time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
latest_replay: Option<&Replay>,
) { ) {
// --- primary stat cells --- // --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render // First-launch zero-state: when no games have been played yet, render
@@ -435,7 +534,34 @@ fn spawn_stats_screen(
)); ));
} }
// --- Latest replay caption ---
// Surfaces the most recent winning game so the player can spot
// whether their last victory has been recorded. The Watch
// Replay action below is what the player clicks to revisit it.
let replay_caption = match latest_replay {
Some(r) => format!("Latest win: {}", format_replay_caption(r)),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
card.spawn((
Text::new(replay_caption),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
// The Watch Replay button is always rendered so the
// affordance is discoverable from a fresh install. When no
// replay exists, the click handler surfaces a clear
// "No replay recorded yet" toast rather than silently
// doing nothing.
spawn_modal_button(
actions,
WatchReplayButton,
"Watch replay",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button( spawn_modal_button(
actions, actions,
StatsCloseButton, StatsCloseButton,