Merge pull request 'feat(engine): one-shot What's-new card after updates (Phase I)' (#180) from feat/whats-new-card into master
Test / fmt (push) Successful in 5s
Test / test (push) Successful in 4m23s
Build and Deploy / build-and-push (push) Failing after 6m36s
Web E2E / web-e2e (push) Successful in 8m43s

This commit was merged in pull request #180.
This commit is contained in:
2026-07-14 01:43:47 +00:00
6 changed files with 550 additions and 1 deletions
+9
View File
@@ -257,6 +257,14 @@ pub struct Settings {
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`. /// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub last_mode: GameMode, 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 /// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters /// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this /// 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(), replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None, last_difficulty: None,
last_mode: GameMode::Classic, last_mode: GameMode::Classic,
last_seen_whats_new: String::new(),
leaderboard_display_name: None, leaderboard_display_name: None,
leaderboard_opted_in: false, leaderboard_opted_in: false,
take_from_foundation: true, take_from_foundation: true,
+2 -1
View File
@@ -21,7 +21,7 @@ use crate::{
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin, WhatsNewPlugin, WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
@@ -115,6 +115,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
+6
View File
@@ -480,6 +480,10 @@ fn persist_last_mode(
/// owns the launch beat; Home appearing underneath it stacked two /// owns the launch beat; Home appearing underneath it stacked two
/// modals (Phase H fix; spotted in the v0.44.0 emulator smoke). Home /// modals (Phase H fix; spotted in the v0.44.0 emulator smoke). Home
/// spawns on the first frame after the player finishes or skips. /// 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 /// * `HomeScreen` must not already exist (defensive — e.g. the player
/// pressed `M` between ticks). /// pressed `M` between ticks).
/// * `LaunchHomeShown` flips to `true` after the first spawn so this /// * `LaunchHomeShown` flips to `true` after the first spawn so this
@@ -494,6 +498,7 @@ fn spawn_home_on_launch(
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>, restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>, pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
onboarding: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>, onboarding: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
whats_new: Option<Res<crate::whats_new_plugin::WhatsNewPending>>,
existing: Query<(), With<HomeScreen>>, existing: Query<(), With<HomeScreen>>,
sources: HomeSpawnSources, sources: HomeSpawnSources,
mut deal_expanded: ResMut<DealOptionsExpanded>, mut deal_expanded: ResMut<DealOptionsExpanded>,
@@ -503,6 +508,7 @@ fn spawn_home_on_launch(
|| !restore_prompts.is_empty() || !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some()) || pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|| !onboarding.is_empty() || !onboarding.is_empty()
|| whats_new.as_ref().is_some_and(|w| w.0)
|| sources || sources
.settings .settings
.as_ref() .as_ref()
+2
View File
@@ -64,6 +64,7 @@ pub mod ui_modal;
pub mod ui_theme; pub mod ui_theme;
pub mod ui_tooltip; pub mod ui_tooltip;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod whats_new_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub mod you_hub_plugin; pub mod you_hub_plugin;
@@ -194,6 +195,7 @@ pub use ui_modal::{
}; };
pub use ui_tooltip::{Tooltip, UiTooltipPlugin}; pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
pub use win_summary_plugin::{ pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
}; };
@@ -313,6 +313,10 @@ fn complete_onboarding(
despawn_screen(commands, screens); despawn_screen(commands, screens);
if let Some(s) = settings { if let Some(s) = settings {
s.0.first_run_complete = true; 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); persist(path.map(|p| &p.0), &s.0);
} }
} }
+527
View File
@@ -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<NoteLine>,
}
/// 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<ReleaseNotes> {
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<String> = None;
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
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::<WhatsNewPending>()
.init_resource::<ButtonInput<KeyCode>>()
.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<WhatsNewPending>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
onboarding: Query<(), With<OnboardingScreen>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
other_scrims: Query<(), With<ModalScrim>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
font_res: Option<Res<FontResource>>,
screens: Query<(), With<WhatsNewScreen>>,
) {
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, &notes, 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<Res<ButtonInput<KeyCode>>>,
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<WhatsNewScreen>>,
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
mut pending: ResMut<WhatsNewPending>,
) {
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 &notes.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::<SettingsResource>()
.0
.last_seen_whats_new,
current_release_version(),
"the seen version must stamp on spawn"
);
assert!(
app.world().resource::<WhatsNewPending>().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::<WhatsNewPending>().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::<WhatsNewPending>().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::<SettingsResource>();
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::<WhatsNewPending>().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::<Entity, With<WhatsNewCloseButton>>()
.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::<WhatsNewPending>().0);
}
}