feat(engine): one-shot What's-new card after updates (Phase I)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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, ¬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<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 ¬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::<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user