From 25f1fd27d9722952e8041a98829a5cde5bc8d5e2 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 18:05:15 -0700 Subject: [PATCH] feat(engine): one-shot What's-new card after updates (Phase I) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObtainX updates install silently, so shipped features went unnoticed. On the first launch where the running release differs from the new Settings::last_seen_whats_new, a dismissible card summarises the latest CHANGELOG.md section (embedded; its top version doubles as the app's release identity — no build-time version plumbing). Internal sections are dropped and bullets reduce to their bold lead sentence. Launch beat: splash -> onboarding (first run) -> what's-new -> Home; spawn_home_on_launch waits on the new WhatsNewPending resource. Fresh installs never see the card — onboarding completion stamps the current version silently. The seen-stamp persists on spawn, not dismissal, so the card can never nag twice. 8 new tests (changelog parsing incl. the real embedded file, upgrade/ seen/fresh-install gating, dismissal). Workspace + clippy green. Co-Authored-By: Claude Fable 5 --- solitaire_data/src/settings.rs | 9 + solitaire_engine/src/core_game_plugin.rs | 3 +- solitaire_engine/src/home_plugin.rs | 6 + solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/onboarding_plugin.rs | 4 + solitaire_engine/src/whats_new_plugin.rs | 527 ++++++++++++++++++++++ 6 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 solitaire_engine/src/whats_new_plugin.rs diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 51116bf..1a31d9e 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -257,6 +257,14 @@ pub struct Settings { /// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`. #[serde(default)] pub last_mode: GameMode, + /// The release version whose "What's new" card the player has already + /// seen (e.g. `"0.46.0"`). Empty on installs that predate the card, + /// which correctly reads as "there is news to show" after an upgrade; + /// fresh installs stamp it silently when onboarding completes. Older + /// `settings.json` files deserialize cleanly to `""` via + /// `#[serde(default)]`. + #[serde(default)] + pub last_seen_whats_new: String, /// Custom public name displayed on the leaderboard. When `None`, the /// player's server `username` is used instead. Trimmed to 32 characters /// before submission. Older `settings.json` files written before this @@ -425,6 +433,7 @@ impl Default for Settings { replay_move_interval_secs: default_replay_move_interval_secs(), last_difficulty: None, last_mode: GameMode::Classic, + last_seen_whats_new: String::new(), leaderboard_display_name: None, leaderboard_opted_in: false, take_from_foundation: true, diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 5879fa1..f15aad1 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -21,7 +21,7 @@ use crate::{ SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, - WinSummaryPlugin, + WhatsNewPlugin, WinSummaryPlugin, }; #[cfg(not(target_arch = "wasm32"))] use crate::{ @@ -115,6 +115,7 @@ impl Plugin for CoreGamePlugin { .add_plugins(PausePlugin) .add_plugins(SettingsPlugin::default()) .add_plugins(OnboardingPlugin) + .add_plugins(WhatsNewPlugin) .add_plugins(WinSummaryPlugin) .add_plugins(UiModalPlugin) .add_plugins(UiFocusPlugin) diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 1ca0323..591eb37 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -480,6 +480,10 @@ fn persist_last_mode( /// owns the launch beat; Home appearing underneath it stacked two /// modals (Phase H fix; spotted in the v0.44.0 emulator smoke). Home /// spawns on the first frame after the player finishes or skips. +/// * The What's-new card (Phase I) must have had its beat — +/// [`crate::whats_new_plugin::WhatsNewPending`] released — so an +/// upgrade's release notes get read before the mode picker lands on +/// top of them. /// * `HomeScreen` must not already exist (defensive — e.g. the player /// pressed `M` between ticks). /// * `LaunchHomeShown` flips to `true` after the first spawn so this @@ -494,6 +498,7 @@ fn spawn_home_on_launch( restore_prompts: Query<(), With>, pending_restore: Option>, onboarding: Query<(), With>, + whats_new: Option>, existing: Query<(), With>, sources: HomeSpawnSources, mut deal_expanded: ResMut, @@ -503,6 +508,7 @@ fn spawn_home_on_launch( || !restore_prompts.is_empty() || pending_restore.as_ref().is_some_and(|p| p.0.is_some()) || !onboarding.is_empty() + || whats_new.as_ref().is_some_and(|w| w.0) || sources .settings .as_ref() diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 2555d30..65504a9 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -64,6 +64,7 @@ pub mod ui_modal; pub mod ui_theme; pub mod ui_tooltip; pub mod weekly_goals_plugin; +pub mod whats_new_plugin; pub mod win_summary_plugin; pub mod you_hub_plugin; @@ -194,6 +195,7 @@ pub use ui_modal::{ }; pub use ui_tooltip::{Tooltip, UiTooltipPlugin}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; +pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen}; pub use win_summary_plugin::{ ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, }; diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index 8c42655..471d0a3 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -313,6 +313,10 @@ fn complete_onboarding( despawn_screen(commands, screens); if let Some(s) = settings { s.0.first_run_complete = true; + // A fresh install has nothing "new" to announce — stamp the + // running release so the What's-new card (Phase I) only ever + // fires after an actual upgrade. + s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version(); persist(path.map(|p| &p.0), &s.0); } } diff --git a/solitaire_engine/src/whats_new_plugin.rs b/solitaire_engine/src/whats_new_plugin.rs new file mode 100644 index 0000000..7bc9c9c --- /dev/null +++ b/solitaire_engine/src/whats_new_plugin.rs @@ -0,0 +1,527 @@ +//! One-shot "What's new" card on the first launch after an update +//! (Phase I of the 2026-07 UI redesign). +//! +//! ObtainX updates install silently, so shipped features go unnoticed — +//! nobody found the theme store on their own. On the first launch where +//! the running release differs from `Settings::last_seen_whats_new`, +//! this plugin shows a single dismissible card summarising the latest +//! changelog section, then records the version so the card never +//! repeats. Fresh installs never see it: onboarding completion stamps +//! the current version silently, so the card only ever describes an +//! *upgrade*. +//! +//! # Launch beat +//! +//! Splash → (first run only: onboarding) → **what's new** → Home → +//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same +//! way it waits for the onboarding modal. +//! +//! # Version source +//! +//! The embedded `CHANGELOG.md`'s topmost release section provides both +//! the card's content and the "current version" for change detection — +//! one source of truth, no build-time version plumbing. (The APK +//! `versionName` comes from the release tag at package time and never +//! reaches Rust; the workspace `Cargo.toml` version is static.) + +use bevy::input::ButtonInput; +use bevy::prelude::*; +use solitaire_data::save_settings_to; + +use crate::font_plugin::FontResource; +use crate::onboarding_plugin::OnboardingScreen; +use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; +use crate::ui_modal::{ + ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, + spawn_modal_header, +}; +use crate::ui_theme::{ + TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL, +}; + +/// The changelog ships inside the binary — small, changes only at +/// release cadence, and the card must work offline (§4.2). +const CHANGELOG: &str = include_str!("../../CHANGELOG.md"); + +/// Most bullets shown on the card; a giant release stays skimmable. +const MAX_CARD_BULLETS: usize = 8; + +/// Marker on the What's-new modal's scrim root. +#[derive(Component, Debug)] +pub struct WhatsNewScreen; + +/// Marker on the card's "Got it" button. +#[derive(Component, Debug)] +struct WhatsNewCloseButton; + +/// `true` until this plugin has made its launch-beat decision — either +/// the card was shown and dismissed, or there was nothing to show. +/// `spawn_home_on_launch` waits on this so the card gets the beat +/// before Home. +#[derive(Resource, Debug)] +pub struct WhatsNewPending(pub bool); + +impl Default for WhatsNewPending { + fn default() -> Self { + Self(true) + } +} + +/// One display line of the card. +#[derive(Debug, PartialEq, Eq)] +enum NoteLine { + /// A `###` section heading ("Added", "Fixed", …). + Heading(String), + /// The lead sentence of one changelog bullet. + Bullet(String), +} + +/// The changelog's topmost release section, reduced to card content. +#[derive(Debug)] +struct ReleaseNotes { + /// Version string without the `v` prefix, e.g. `"0.46.0"`. + version: String, + lines: Vec, +} + +/// The version of the topmost changelog release — the engine's notion +/// of "the running release". Empty only if the changelog is malformed. +pub fn current_release_version() -> String { + parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version) +} + +/// Parses the first `## [x.y.z]` section of `changelog` into card +/// content. `## [Unreleased]` is skipped; `### Internal` subsections +/// are dropped (players don't care about CI); each bullet is reduced +/// to its lead sentence with markdown emphasis stripped. +fn parse_latest_release(changelog: &str) -> Option { + let mut lines_iter = changelog.lines(); + let mut version = None; + for line in lines_iter.by_ref() { + if let Some(rest) = line.strip_prefix("## [") { + let (v, _) = rest.split_once(']')?; + if v.eq_ignore_ascii_case("Unreleased") { + continue; + } + version = Some(v.to_string()); + break; + } + } + let version = version?; + + let mut lines = Vec::new(); + let mut skipping_section = false; + let mut current_bullet: Option = None; + + let flush = |bullet: &mut Option, lines: &mut Vec| { + if let Some(text) = bullet.take() { + lines.push(NoteLine::Bullet(lead_sentence(&text))); + } + }; + + for line in lines_iter { + if line.starts_with("## [") { + break; // next (older) release section + } + if let Some(heading) = line.strip_prefix("### ") { + flush(&mut current_bullet, &mut lines); + skipping_section = heading.trim().eq_ignore_ascii_case("internal"); + if !skipping_section { + lines.push(NoteLine::Heading(heading.trim().to_string())); + } + continue; + } + if skipping_section { + continue; + } + if let Some(rest) = line.strip_prefix("- ") { + flush(&mut current_bullet, &mut lines); + current_bullet = Some(rest.trim().to_string()); + } else if current_bullet.is_some() && !line.trim().is_empty() { + // Wrapped continuation of the current bullet. + if let Some(bullet) = current_bullet.as_mut() { + bullet.push(' '); + bullet.push_str(line.trim()); + } + } + } + flush(&mut current_bullet, &mut lines); + + Some(ReleaseNotes { version, lines }) +} + +/// Strips markdown emphasis / code ticks and truncates to the first +/// sentence — changelog bullets lead with a bold summary sentence, and +/// that is exactly the card-sized version. +fn lead_sentence(bullet: &str) -> String { + let stripped: String = bullet.replace("**", "").replace('`', ""); + // Trailing "(#123)" references never survive the sentence cut, but + // guard against a bullet that is only a reference. + let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1); + stripped[..end].trim().trim_end_matches('.').to_string() +} + +/// Registers the launch-gate spawn system and the dismiss handlers. +pub struct WhatsNewPlugin; + +impl Plugin for WhatsNewPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::>() + .add_systems( + Update, + (maybe_spawn_whats_new, handle_whats_new_close).chain(), + ); + } +} + +/// Shows the card once the launch surface is clear, or stands down when +/// there is nothing to show. See the module docs for the exact beat. +/// +/// The seen-version stamp persists on *spawn*, not dismissal — if the +/// app dies with the card open the player has still seen it once, and +/// the card must never nag. +#[allow(clippy::too_many_arguments)] +fn maybe_spawn_whats_new( + mut commands: Commands, + mut pending: ResMut, + splash: Query<(), With>, + onboarding: Query<(), With>, + restore_prompts: Query<(), With>, + pending_restore: Option>, + other_scrims: Query<(), With>, + mut settings: Option>, + storage_path: Option>, + font_res: Option>, + screens: Query<(), With>, +) { + if !pending.0 { + return; + } + if !screens.is_empty() { + // Card already open — the seen-version stamp landed on spawn, + // so without this guard the next frame would read "already + // seen" and release the beat under the open card. + return; + } + if !splash.is_empty() + || !onboarding.is_empty() + || !restore_prompts.is_empty() + || pending_restore.as_ref().is_some_and(|p| p.0.is_some()) + { + return; + } + let Some(settings) = settings.as_mut() else { + // Headless / no settings wired: nothing to compare against. + pending.0 = false; + return; + }; + if !settings.0.first_run_complete { + // Fresh install mid-onboarding — completion stamps the version + // itself (see `complete_onboarding`), which resolves this gate + // on a later frame without ever showing the card. + return; + } + let Some(notes) = parse_latest_release(CHANGELOG) else { + pending.0 = false; + return; + }; + if settings.0.last_seen_whats_new == notes.version { + pending.0 = false; + return; + } + if !other_scrims.is_empty() { + // Another modal owns the beat right now; try again next frame. + return; + } + + spawn_whats_new_card(&mut commands, ¬es, font_res.as_deref()); + + settings.0.last_seen_whats_new = notes.version.clone(); + if let Some(p) = storage_path + && let Some(path) = p.0.as_deref() + && let Err(e) = save_settings_to(path, &settings.0) + { + warn!("whats-new: failed to persist seen version: {e}"); + } + // `pending` stays true until dismissal so Home keeps waiting. +} + +/// Dismisses the card on "Got it" or Esc and releases the launch beat. +fn handle_whats_new_close( + mut commands: Commands, + keys: Option>>, + buttons: Query<&Interaction, (With, Changed)>, + screens: Query>, + other_scrims: Query<(), (With, Without)>, + mut pending: ResMut, +) { + if screens.is_empty() { + return; + } + let click = buttons.iter().any(|i| *i == Interaction::Pressed); + let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty(); + if !click && !esc { + return; + } + for entity in &screens { + commands.entity(entity).despawn(); + } + pending.0 = false; +} + +/// Spawns the card: header with the version, the parsed changelog +/// lines, and a "Got it" action. +fn spawn_whats_new_card( + commands: &mut Commands, + notes: &ReleaseNotes, + font_res: Option<&FontResource>, +) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_heading = TextFont { + font: font_handle.clone(), + font_size: TYPE_CAPTION, + ..default() + }; + let font_bullet = TextFont { + font: font_handle, + font_size: TYPE_BODY, + ..default() + }; + + // Deliberately NOT ScrimDismissible: scrim-tap despawns without + // running the close handler, which would leave the launch beat + // held and Home never spawning. Esc and "Got it" both release it. + let title = format!("What's new in v{}", notes.version); + spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| { + spawn_modal_header(card, &title, font_res); + + card.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + width: Val::Percent(100.0), + max_height: Val::Vh(60.0), + overflow: Overflow::scroll_y(), + ..default() + }) + .with_children(|body| { + let mut bullets_shown = 0usize; + for line in ¬es.lines { + match line { + NoteLine::Heading(heading) => { + body.spawn(( + Text::new(heading.clone()), + font_heading.clone(), + TextColor(TEXT_SECONDARY), + Node { + margin: UiRect::top(VAL_SPACE_1), + ..default() + }, + )); + } + NoteLine::Bullet(text) => { + if bullets_shown >= MAX_CARD_BULLETS { + continue; + } + bullets_shown += 1; + body.spawn(( + Text::new(format!("\u{2022} {text}")), + font_bullet.clone(), + TextColor(TEXT_PRIMARY), + )); + } + } + } + }); + + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + WhatsNewCloseButton, + "Got it", + None, + ButtonVariant::Primary, + font_res, + ); + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use solitaire_data::Settings; + + const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\ +### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \ +preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\ +### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\ +### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\ +## [0.45.0] — 2026-07-13\n\n### Added\n\n- Old stuff.\n"; + + #[test] + fn parses_top_section_version_and_skips_unreleased() { + let notes = parse_latest_release(SAMPLE).expect("sample must parse"); + assert_eq!(notes.version, "0.46.0"); + } + + #[test] + fn drops_internal_sections_and_older_releases() { + let notes = parse_latest_release(SAMPLE).expect("sample must parse"); + let headings: Vec<&str> = notes + .lines + .iter() + .filter_map(|l| match l { + NoteLine::Heading(h) => Some(h.as_str()), + _ => None, + }) + .collect(); + assert_eq!(headings, vec!["Added", "Fixed"]); + assert!( + !notes + .lines + .iter() + .any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))), + "Internal bullets must not reach the card" + ); + assert!( + !notes + .lines + .iter() + .any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))), + "older release sections must not bleed in" + ); + } + + #[test] + fn bullets_reduce_to_their_lead_sentence() { + let notes = parse_latest_release(SAMPLE).expect("sample must parse"); + assert!( + notes + .lines + .contains(&NoteLine::Bullet("Theme-store previews".to_string())) + ); + assert!( + notes + .lines + .contains(&NoteLine::Bullet("Hint ghost preview".to_string())) + ); + } + + #[test] + fn embedded_changelog_parses_to_a_nonempty_version() { + let version = current_release_version(); + assert!( + !version.is_empty(), + "the real CHANGELOG.md must yield a version" + ); + assert!( + version.chars().next().is_some_and(|c| c.is_ascii_digit()), + "version must be bare (no v prefix): {version}" + ); + } + + fn app_with(settings: Settings) -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin); + app.insert_resource(SettingsResource(settings)); + app.update(); + app + } + + fn card_count(app: &mut App) -> usize { + app.world_mut() + .query::<&WhatsNewScreen>() + .iter(app.world()) + .count() + } + + #[test] + fn upgrade_shows_card_and_stamps_version() { + let mut app = app_with(Settings { + first_run_complete: true, + last_seen_whats_new: "0.1.0".into(), + ..Settings::default() + }); + app.update(); + + assert_eq!(card_count(&mut app), 1, "an upgrade must show the card"); + assert_eq!( + app.world() + .resource::() + .0 + .last_seen_whats_new, + current_release_version(), + "the seen version must stamp on spawn" + ); + assert!( + app.world().resource::().0, + "the launch beat stays held until dismissal" + ); + } + + #[test] + fn seen_version_shows_nothing_and_releases_the_beat() { + let mut app = app_with(Settings { + first_run_complete: true, + last_seen_whats_new: current_release_version(), + ..Settings::default() + }); + app.update(); + + assert_eq!(card_count(&mut app), 0); + assert!( + !app.world().resource::().0, + "nothing to show must release the launch beat" + ); + } + + #[test] + fn fresh_install_waits_for_onboarding_and_never_shows() { + let mut app = app_with(Settings { + first_run_complete: false, + ..Settings::default() + }); + app.update(); + assert_eq!(card_count(&mut app), 0); + assert!( + app.world().resource::().0, + "mid-onboarding the beat stays held" + ); + + // Onboarding completion stamps the version (mirrored from + // complete_onboarding) — afterwards the card must stand down. + { + let mut settings = app.world_mut().resource_mut::(); + settings.0.first_run_complete = true; + settings.0.last_seen_whats_new = current_release_version(); + } + app.update(); + assert_eq!(card_count(&mut app), 0, "fresh installs never see the card"); + assert!(!app.world().resource::().0); + } + + #[test] + fn got_it_dismisses_and_releases_the_beat() { + let mut app = app_with(Settings { + first_run_complete: true, + ..Settings::default() + }); + app.update(); + assert_eq!(card_count(&mut app), 1); + + let button = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .expect("Got it button must exist"); + app.world_mut() + .entity_mut(button) + .insert(Interaction::Pressed); + app.update(); + app.update(); + + assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card"); + assert!(!app.world().resource::().0); + } +} -- 2.47.3