Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25f1fd27d9 | |||
| 9a0d6496c5 | |||
| 7669a1bb56 | |||
| ddee605874 | |||
| ae8118d7df | |||
| 739c94d3bb | |||
| 3388169329 | |||
| 0e07e1d1ad |
@@ -6,6 +6,36 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.46.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme-store previews.** The store modal now shows each theme's
|
||||
preview image next to its name — the server has advertised them since
|
||||
the store launched; the client finally fetches them. Thumbnails load
|
||||
in the background, pop in as they arrive, and are remembered for the
|
||||
session. (#179)
|
||||
- **Hint ghost preview.** Asking for a hint now also plays a translucent
|
||||
copy of the suggested card gliding to its destination (twice, then it
|
||||
fades) alongside the usual highlights — you see the move, not just the
|
||||
pieces. Automatically disabled when reduce-motion is on. (#179)
|
||||
|
||||
### Changed
|
||||
|
||||
- **One toast style, one place.** Queued info banners and instant
|
||||
celebration/warning/error toasts now share a single bottom-anchored
|
||||
stack that clears the touch action bar; simultaneous toasts stack
|
||||
upward instead of overlapping. (#178)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
|
||||
players to left/right-click; it now describes drag, double-tap, and
|
||||
the bottom-bar Hint button. (#178)
|
||||
- **First launch shows one screen at a time.** On a fresh install the
|
||||
Home screen no longer spawns underneath the welcome tour; the order is
|
||||
now onboarding → Home → table. (#178)
|
||||
|
||||
## [0.45.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,10 @@ use thiserror::Error;
|
||||
/// so downloading it is pure waste.
|
||||
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
/// Hard cap on a preview PNG download — previews are small decorative
|
||||
/// thumbnails; anything past this is a misconfigured server.
|
||||
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
|
||||
|
||||
/// Errors surfaced by [`ThemeStoreClient`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ThemeStoreError {
|
||||
@@ -110,6 +114,42 @@ impl ThemeStoreClient {
|
||||
verify_archive(&bytes, entry)?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Fetch the preview PNG the catalog advertises for `entry`.
|
||||
///
|
||||
/// Returns `Http(404)` when the entry carries no `preview_url` —
|
||||
/// the same shape the server answers with when the file is absent,
|
||||
/// so callers only handle one "no preview" case. Previews are
|
||||
/// decorative; unlike archives they carry no checksum, only the
|
||||
/// [`MAX_PREVIEW_BYTES`] size cap.
|
||||
pub async fn fetch_preview(
|
||||
&self,
|
||||
entry: &ThemeCatalogEntry,
|
||||
) -> Result<Vec<u8>, ThemeStoreError> {
|
||||
let Some(path) = entry.preview_url.as_deref() else {
|
||||
return Err(ThemeStoreError::Http(404));
|
||||
};
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}{}", self.base_url, path))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||
}
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
|
||||
return Err(ThemeStoreError::Oversized {
|
||||
expected: MAX_PREVIEW_BYTES,
|
||||
got: bytes.len() as u64,
|
||||
});
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||
|
||||
@@ -29,7 +29,9 @@ use crate::events::{
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||
use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||
use crate::ui_theme::{
|
||||
@@ -160,6 +162,68 @@ pub struct ActiveToast {
|
||||
/// Duration of each queued info-toast in seconds.
|
||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||
|
||||
/// Marker on the persistent bottom-anchored flex column every toast
|
||||
/// spawns into (Phase H). Stacking through one container gives queued
|
||||
/// and immediate toasts a single shared anchor — simultaneous toasts
|
||||
/// stack upward instead of relying on the old staggered-percentage
|
||||
/// anchors to dodge each other.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ToastStackRoot;
|
||||
|
||||
/// Marker on every toast card node (both paths). Freshly spawned toasts
|
||||
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
|
||||
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
|
||||
/// of latency, imperceptible at toast timescales, in exchange for the 14
|
||||
/// toast handlers keeping their `Commands`-only signatures.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ToastNode;
|
||||
|
||||
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||
/// before safe-area insets. Clears the Phase F bottom action bar on
|
||||
/// touch (compact 44px buttons + primary 64px trio + bar padding);
|
||||
/// desktop's shorter bar needs less.
|
||||
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
|
||||
|
||||
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
ToastStackRoot,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
// Newest toast sits nearest the bottom edge; older ones
|
||||
// push upward.
|
||||
flex_direction: FlexDirection::ColumnReverse,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
SafeAreaAnchoredBottom {
|
||||
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
|
||||
},
|
||||
ZIndex(Z_TOAST),
|
||||
));
|
||||
}
|
||||
|
||||
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
|
||||
/// reveals them. No-op when every toast is already adopted.
|
||||
fn adopt_toasts_into_stack(
|
||||
mut commands: Commands,
|
||||
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
|
||||
root: Query<Entity, With<ToastStackRoot>>,
|
||||
) {
|
||||
let Ok(root) = root.single() else {
|
||||
return;
|
||||
};
|
||||
for toast in &orphans {
|
||||
commands
|
||||
.entity(toast)
|
||||
.insert((ChildOf(root), Visibility::Inherited));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||
pub struct AnimationPlugin;
|
||||
|
||||
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
.init_resource::<ToastQueue>()
|
||||
.init_resource::<ActiveToast>()
|
||||
.add_systems(Startup, init_slide_duration)
|
||||
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
|
||||
handle_warning_toast,
|
||||
tick_toasts,
|
||||
(enqueue_toasts, drive_toast_display).chain(),
|
||||
adopt_toasts_into_stack,
|
||||
)
|
||||
.after(GameMutation),
|
||||
);
|
||||
@@ -637,26 +702,14 @@ impl ToastVariant {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
||||
/// Spawns a `ToastEntity` for the queued toast system.
|
||||
///
|
||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||
/// Variants other than `Info` belong on the immediate-fire path
|
||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||
spawn_toast_node(
|
||||
commands,
|
||||
ToastEntity,
|
||||
message,
|
||||
ToastVariant::Info,
|
||||
// Slightly taller anchor than the immediate-fire path so a
|
||||
// queued info banner doesn't collide with a celebration toast
|
||||
// fired in the same frame.
|
||||
Val::Percent(6.0),
|
||||
Val::Percent(15.0),
|
||||
Val::Percent(70.0),
|
||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
)
|
||||
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||
}
|
||||
|
||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||
@@ -744,12 +797,6 @@ fn spawn_toast(
|
||||
(ToastOverlay, ToastTimer(duration_secs)),
|
||||
message,
|
||||
variant,
|
||||
// Sits above the queued banner so a celebration toast spawned
|
||||
// alongside a queued info message remains readable.
|
||||
Val::Percent(14.0),
|
||||
Val::Percent(25.0),
|
||||
Val::Percent(50.0),
|
||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -766,31 +813,25 @@ fn spawn_toast(
|
||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||
/// tested in `ui_theme::tests`.
|
||||
/// - [`RADIUS_MD`] corners.
|
||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
||||
/// queued and immediate paths so they layer instead of overlap.
|
||||
// The 8-argument signature is intentional — these are the per-toast
|
||||
// layout values that genuinely differ between the queued and fire-and-
|
||||
// forget call sites. A struct wrapper would just rename the same data.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
///
|
||||
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||
/// simultaneous toasts stack instead of overlapping.
|
||||
fn spawn_toast_node<B: Bundle>(
|
||||
commands: &mut Commands,
|
||||
bundle: B,
|
||||
message: String,
|
||||
variant: ToastVariant,
|
||||
bottom_pct: Val,
|
||||
left_pct: Val,
|
||||
width_pct: Val,
|
||||
padding: UiRect,
|
||||
) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
bundle,
|
||||
ToastNode,
|
||||
Visibility::Hidden,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: left_pct,
|
||||
bottom: bottom_pct,
|
||||
width: width_pct,
|
||||
padding,
|
||||
max_width: Val::Percent(70.0),
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(variant.border_color()),
|
||||
ZIndex(Z_TOAST),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -1315,4 +1355,85 @@ mod tests {
|
||||
fn cascade_duration_instant_is_zero() {
|
||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase H: unified toast stack
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Both toast paths must end up as visible children of the single
|
||||
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
|
||||
#[test]
|
||||
fn queued_and_immediate_toasts_stack_under_one_root() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||
app.update(); // Startup: spawns the stack root.
|
||||
|
||||
// One immediate (error) toast + one queued (info) toast in the
|
||||
// same frame — the exact collision case the old staggered
|
||||
// anchors existed to dodge.
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
app.world_mut().write_message(MoveRejectedEvent {
|
||||
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||
count: 1,
|
||||
});
|
||||
app.world_mut()
|
||||
.write_message(InfoToastEvent("stacked info".to_string()));
|
||||
app.update(); // handlers spawn both toasts (hidden, unparented)
|
||||
app.update(); // adopt_toasts_into_stack re-parents + reveals
|
||||
|
||||
let root = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<ToastStackRoot>>()
|
||||
.single(app.world())
|
||||
.expect("exactly one ToastStackRoot must exist");
|
||||
|
||||
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
|
||||
.world_mut()
|
||||
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
toasts.len(),
|
||||
2,
|
||||
"both the immediate and the queued toast must be adopted"
|
||||
);
|
||||
for (entity, child_of, visibility) in toasts {
|
||||
assert_eq!(
|
||||
child_of.parent(),
|
||||
root,
|
||||
"toast {entity} must be a child of the shared stack root"
|
||||
);
|
||||
assert_eq!(
|
||||
*visibility,
|
||||
Visibility::Inherited,
|
||||
"adopted toast {entity} must be revealed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast nodes must not carry their own absolute positioning — the
|
||||
/// stack root owns layout (regression guard against reintroducing
|
||||
/// per-path anchors).
|
||||
#[test]
|
||||
fn toast_nodes_have_no_absolute_position() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||
app.update();
|
||||
|
||||
app.world_mut()
|
||||
.write_message(WarningToastEvent("layout check".to_string()));
|
||||
app.update();
|
||||
|
||||
let node = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<ToastNode>>()
|
||||
.single(app.world())
|
||||
.expect("warning toast must spawn a ToastNode");
|
||||
assert_eq!(
|
||||
node.position_type,
|
||||
PositionType::Relative,
|
||||
"toast nodes are flex children of the stack, not absolute overlays"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::card_animation::{MotionCurve, sample_curve};
|
||||
use crate::card_plugin::{CardEntity, CardEntityIndex};
|
||||
use crate::events::{
|
||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent,
|
||||
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<HintVisualEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
start_deal_anim.after(GameMutation),
|
||||
start_foundation_flourish.after(GameMutation),
|
||||
),
|
||||
)
|
||||
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
|
||||
// so it orders after the board painters; the tick only touches
|
||||
// ghost entities (Without<CardEntity>) and stays conflict-free.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
spawn_hint_ghost
|
||||
.after(GameMutation)
|
||||
.after(crate::card_plugin::BoardVisuals),
|
||||
tick_hint_ghosts,
|
||||
despawn_hint_ghosts_on_state_change.after(GameMutation),
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -664,6 +681,147 @@ fn pile_cards(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase H — hint ghost-motion preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Duration of one ghost glide from the hinted card to its destination.
|
||||
const HINT_GHOST_PASS_SECS: f32 = 0.7;
|
||||
/// How many glides one hint plays before the ghost despawns. Two reads
|
||||
/// as "this move, over there" without outstaying the 2 s static
|
||||
/// highlight it accompanies.
|
||||
const HINT_GHOST_PASSES: f32 = 2.0;
|
||||
/// Ghost translucency — clearly a projection, never mistakable for the
|
||||
/// real card.
|
||||
const HINT_GHOST_ALPHA: f32 = 0.45;
|
||||
/// Ghost render depth: above every settled pile (~1.04 max) and the
|
||||
/// in-flight `CardAnim` lift (50), below a dragged card (500).
|
||||
const HINT_GHOST_Z: f32 = 400.0;
|
||||
|
||||
/// A translucent copy of the hinted card gliding to the suggested
|
||||
/// destination (Phase H). Purely decorative — despawned by timer, by a
|
||||
/// newer hint, or by any state change.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HintGhost {
|
||||
start: Vec3,
|
||||
target: Vec3,
|
||||
elapsed: f32,
|
||||
}
|
||||
|
||||
/// Normalised progress of the current glide pass, restarting from the
|
||||
/// source each pass. Pure for unit testing.
|
||||
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
|
||||
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
|
||||
}
|
||||
|
||||
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
|
||||
/// fading over the last 20 % so the loop restart reads as a repeat
|
||||
/// rather than a teleport. Pure for unit testing.
|
||||
fn hint_ghost_alpha(pass_t: f32) -> f32 {
|
||||
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
|
||||
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
|
||||
}
|
||||
|
||||
/// Spawns the ghost when a hint fires. The static highlights (source
|
||||
/// card + gold destination pile) still spawn regardless; under
|
||||
/// reduce-motion they are the whole story and no ghost appears
|
||||
/// (`design-system.md` §Accessibility).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_hint_ghost(
|
||||
mut events: MessageReader<HintVisualEvent>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
index: Option<Res<CardEntityIndex>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
|
||||
existing: Query<Entity, With<HintGhost>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
|
||||
events.clear();
|
||||
return;
|
||||
}
|
||||
let (Some(index), Some(layout)) = (index, layout) else {
|
||||
events.clear();
|
||||
return;
|
||||
};
|
||||
for ev in events.read() {
|
||||
// A fresh hint replaces any ghost still in flight.
|
||||
for entity in &existing {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
let Some(card_entity) = index.get(&ev.source_card) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((transform, sprite)) = cards.get(card_entity) else {
|
||||
continue;
|
||||
};
|
||||
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
|
||||
continue;
|
||||
};
|
||||
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
|
||||
let mut ghost_sprite = sprite.clone();
|
||||
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
|
||||
commands.spawn((
|
||||
HintGhost {
|
||||
start,
|
||||
target: dest.extend(HINT_GHOST_Z),
|
||||
elapsed: 0.0,
|
||||
},
|
||||
ghost_sprite,
|
||||
Transform::from_translation(start),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances every ghost: eased glide per pass, tail fade, despawn after
|
||||
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
|
||||
/// decorative animation.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn tick_hint_ghosts(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut ghosts: Query<
|
||||
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
|
||||
(Without<CardEntity>, Without<PileMarker>),
|
||||
>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
|
||||
ghost.elapsed += dt;
|
||||
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
|
||||
commands.entity(entity).despawn();
|
||||
continue;
|
||||
}
|
||||
let pass_t = hint_ghost_pass_t(ghost.elapsed);
|
||||
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
|
||||
transform.translation = ghost.start.lerp(ghost.target, eased);
|
||||
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
|
||||
}
|
||||
}
|
||||
|
||||
/// A ghost previews a move against the *current* board; the moment the
|
||||
/// board changes (move, undo, draw, new game) it is stale and vanishes.
|
||||
fn despawn_hint_ghosts_on_state_change(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
ghosts: Query<Entity, With<HintGhost>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
events.clear();
|
||||
for entity in &ghosts {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -955,4 +1113,147 @@ mod tests {
|
||||
"FoundationFlourish must not be inserted under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase H — hint ghost-motion preview
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hint_ghost_pass_t_restarts_each_pass() {
|
||||
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
|
||||
// Just past one full pass, progress wraps back near zero.
|
||||
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_ghost_alpha_fades_only_in_the_tail() {
|
||||
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
|
||||
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
|
||||
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
|
||||
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// App with one indexed card entity and a real layout, ready to
|
||||
/// receive `HintVisualEvent`s.
|
||||
fn ghost_app(reduce_motion: bool) -> (App, Card) {
|
||||
use solitaire_core::{Deck, Rank, Suit};
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
// Sibling systems in the plugin take GameStateResource
|
||||
// non-optionally; give them a fresh deal.
|
||||
app.insert_resource(GameStateResource(
|
||||
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
|
||||
));
|
||||
app.insert_resource(SettingsResource(solitaire_data::Settings {
|
||||
reduce_motion_mode: reduce_motion,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
CardEntity { card: card.clone() },
|
||||
Sprite::default(),
|
||||
Transform::from_xyz(-100.0, 40.0, 1.0),
|
||||
))
|
||||
.id();
|
||||
let mut index = CardEntityIndex::default();
|
||||
index.0.insert(card.clone(), entity);
|
||||
app.insert_resource(index);
|
||||
app.insert_resource(LayoutResource(crate::layout::compute_layout(
|
||||
Vec2::new(1280.0, 720.0),
|
||||
0.0,
|
||||
0.0,
|
||||
true,
|
||||
)));
|
||||
app.update();
|
||||
(app, card)
|
||||
}
|
||||
|
||||
fn fire_hint(app: &mut App, card: Card) {
|
||||
use solitaire_core::Tableau;
|
||||
app.world_mut().write_message(HintVisualEvent {
|
||||
source_card: card,
|
||||
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn ghost_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&HintGhost>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_event_spawns_one_ghost_at_the_source_card() {
|
||||
let (mut app, card) = ghost_app(false);
|
||||
fire_hint(&mut app, card);
|
||||
|
||||
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
|
||||
let transform = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Transform, With<HintGhost>>()
|
||||
.single(app.world())
|
||||
.expect("ghost transform");
|
||||
// The tick system may have advanced the ghost by one real-time
|
||||
// frame already; assert it is still essentially at the source.
|
||||
assert!(
|
||||
transform
|
||||
.translation
|
||||
.truncate()
|
||||
.distance(Vec2::new(-100.0, 40.0))
|
||||
< 5.0,
|
||||
"ghost must start at the hinted card, got {:?}",
|
||||
transform.translation
|
||||
);
|
||||
assert_eq!(transform.translation.z, HINT_GHOST_Z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_ghost_suppressed_under_reduce_motion() {
|
||||
let (mut app, card) = ghost_app(true);
|
||||
fire_hint(&mut app, card);
|
||||
|
||||
assert_eq!(
|
||||
ghost_count(&mut app),
|
||||
0,
|
||||
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_change_despawns_the_ghost() {
|
||||
let (mut app, card) = ghost_app(false);
|
||||
fire_hint(&mut app, card);
|
||||
assert_eq!(ghost_count(&mut app), 1);
|
||||
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
ghost_count(&mut app),
|
||||
0,
|
||||
"a board change makes the previewed move stale — the ghost must vanish"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_hint_replaces_the_first_ghost() {
|
||||
let (mut app, card) = ghost_app(false);
|
||||
fire_hint(&mut app, card.clone());
|
||||
fire_hint(&mut app, card);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
ghost_count(&mut app),
|
||||
1,
|
||||
"re-hinting must replace the in-flight ghost, not stack a second"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +475,15 @@ fn persist_last_mode(
|
||||
/// must be empty — when the player has a saved in-progress game the
|
||||
/// restore prompt takes precedence; the home picker would compete
|
||||
/// with it for attention.
|
||||
/// * First-run onboarding must be finished (`Settings.first_run_complete`
|
||||
/// and no `OnboardingScreen` open) — on a fresh profile the onboarding
|
||||
/// 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
|
||||
@@ -488,6 +497,8 @@ fn spawn_home_on_launch(
|
||||
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
||||
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
||||
onboarding: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
|
||||
whats_new: Option<Res<crate::whats_new_plugin::WhatsNewPending>>,
|
||||
existing: Query<(), With<HomeScreen>>,
|
||||
sources: HomeSpawnSources,
|
||||
mut deal_expanded: ResMut<DealOptionsExpanded>,
|
||||
@@ -496,6 +507,12 @@ fn spawn_home_on_launch(
|
||||
|| !splash.is_empty()
|
||||
|| !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()
|
||||
.is_some_and(|s| !s.0.first_run_complete)
|
||||
|| !existing.is_empty()
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -382,20 +386,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
});
|
||||
}
|
||||
|
||||
/// How-to-play body copy, phrased for the platform's input vocabulary —
|
||||
/// a touch player never left-clicks (Phase H polish; spotted in the
|
||||
/// emulator smoke of v0.44.0).
|
||||
#[cfg(target_os = "android")]
|
||||
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
|
||||
You can drag a whole column at once by grabbing the topmost card \
|
||||
you want to move. Double-tap a face-up card to send it to a \
|
||||
foundation pile automatically (when the move is legal). \
|
||||
Tap Hint in the bottom bar for a suggested move.";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
|
||||
You can drag a whole column at once by grabbing the topmost card \
|
||||
you want to move. Double-click a face-up card to send it to a \
|
||||
foundation pile automatically (when the move is legal). \
|
||||
Right-click a card for a hint — valid destinations will highlight.";
|
||||
|
||||
/// Slide 2 — How to play.
|
||||
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||
spawn_modal_header(card, "Drag cards to play", font_res);
|
||||
spawn_modal_body_text(
|
||||
card,
|
||||
"Left-click and drag any face-up card to move it between piles. \
|
||||
You can drag a whole column at once by grabbing the topmost card \
|
||||
you want to move. Double-click a face-up card to send it to a \
|
||||
foundation pile automatically (when the move is legal). \
|
||||
Right-click a card for a hint — valid destinations will highlight.",
|
||||
TEXT_SECONDARY,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||
//! `SyncPlugin`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use thiserror::Error;
|
||||
@@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
|
||||
#[derive(Resource, Default)]
|
||||
struct StoreBaseUrl(Option<String>);
|
||||
|
||||
/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime:
|
||||
/// previews survive closing and reopening the store, so a revisit
|
||||
/// renders instantly without refetching.
|
||||
#[derive(Resource, Default)]
|
||||
struct PreviewCache(HashMap<String, Handle<Image>>);
|
||||
|
||||
/// One in-flight preview download: raw PNG bytes or the fetch error.
|
||||
type PreviewFetch = Task<Result<Vec<u8>, ThemeStoreError>>;
|
||||
|
||||
/// In-flight preview downloads, one per catalog entry that advertises
|
||||
/// a `preview_url` not already in [`PreviewCache`]. `String` is the
|
||||
/// theme id the bytes belong to.
|
||||
#[derive(Resource, Default)]
|
||||
struct PreviewTasks(Vec<(String, PreviewFetch)>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin {
|
||||
.init_resource::<CatalogTask>()
|
||||
.init_resource::<InstallTask>()
|
||||
.init_resource::<StoreBaseUrl>()
|
||||
.init_resource::<PreviewCache>()
|
||||
.init_resource::<PreviewTasks>()
|
||||
// Esc-close reads keyboard input; register defensively so
|
||||
// the plugin works under MinimalPlugins in tests.
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
@@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin {
|
||||
(
|
||||
handle_open_request,
|
||||
poll_catalog_task,
|
||||
poll_preview_tasks,
|
||||
handle_install_buttons,
|
||||
poll_install_task,
|
||||
handle_close_button,
|
||||
@@ -176,6 +197,7 @@ fn handle_open_request(
|
||||
mut catalog_task: ResMut<CatalogTask>,
|
||||
mut store_base: ResMut<StoreBaseUrl>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -207,16 +229,28 @@ fn handle_open_request(
|
||||
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
||||
}));
|
||||
|
||||
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
|
||||
spawn_store_modal(
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
None,
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
|
||||
/// Polls the catalog fetch; on completion updates [`CatalogState`],
|
||||
/// kicks off preview downloads for entries not yet in the cache, and
|
||||
/// rebuilds the modal if it is still open.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_catalog_task(
|
||||
mut catalog_task: ResMut<CatalogTask>,
|
||||
mut catalog_state: ResMut<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
rt: Option<Res<TokioRuntimeResource>>,
|
||||
store_base: Res<StoreBaseUrl>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut preview_tasks: ResMut<PreviewTasks>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -229,7 +263,29 @@ fn poll_catalog_task(
|
||||
catalog_task.0 = None;
|
||||
|
||||
*catalog_state = match result {
|
||||
Ok(entries) => CatalogState::Loaded(entries),
|
||||
Ok(entries) => {
|
||||
// Fetch previews for anything new. Decorative — failures
|
||||
// just leave the row text-only, so errors only log.
|
||||
if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) {
|
||||
for entry in entries
|
||||
.iter()
|
||||
.filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id))
|
||||
{
|
||||
let rt = rt.0.clone();
|
||||
let base_url = base_url.to_owned();
|
||||
let entry = entry.clone();
|
||||
preview_tasks.0.push((
|
||||
entry.id.clone(),
|
||||
AsyncComputeTaskPool::get().spawn(async move {
|
||||
rt.block_on(async {
|
||||
ThemeStoreClient::new(base_url).fetch_preview(&entry).await
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
CatalogState::Loaded(entries)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("theme store: catalog fetch failed: {e}");
|
||||
CatalogState::Error(e.to_string())
|
||||
@@ -241,10 +297,69 @@ fn poll_catalog_task(
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
registry.as_deref(),
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls in-flight preview downloads. Each finished PNG is decoded into
|
||||
/// an [`Image`] asset and cached by theme id; the open modal rebuilds
|
||||
/// once per frame that added at least one preview, so thumbnails pop in
|
||||
/// as they arrive. Failures log and leave the row text-only. Skipped
|
||||
/// entirely when `Assets<Image>` is absent (`MinimalPlugins` tests).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_preview_tasks(
|
||||
mut preview_tasks: ResMut<PreviewTasks>,
|
||||
mut previews: ResMut<PreviewCache>,
|
||||
images: Option<ResMut<Assets<Image>>>,
|
||||
catalog_state: Res<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if preview_tasks.0.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut images) = images else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut added = false;
|
||||
preview_tasks.0.retain_mut(|(id, task)| {
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return true; // still downloading
|
||||
};
|
||||
match result {
|
||||
Ok(bytes) => match image::load_from_memory(&bytes) {
|
||||
Ok(dyn_img) => {
|
||||
let handle = images.add(Image::from_dynamic(
|
||||
dyn_img,
|
||||
true,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
));
|
||||
previews.0.insert(id.clone(), handle);
|
||||
added = true;
|
||||
}
|
||||
Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"),
|
||||
},
|
||||
Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"),
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
if added {
|
||||
rebuild_open_modal(
|
||||
&screens,
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
registry.as_deref(),
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a download + import task when an Install button is pressed.
|
||||
/// One install at a time; repeat clicks while busy are ignored.
|
||||
fn handle_install_buttons(
|
||||
@@ -308,6 +423,7 @@ fn poll_install_task(
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -343,6 +459,7 @@ fn poll_install_task(
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
registry.as_deref(),
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
@@ -378,11 +495,12 @@ fn rebuild_open_modal(
|
||||
commands: &mut Commands,
|
||||
catalog_state: &CatalogState,
|
||||
registry: Option<&ThemeRegistry>,
|
||||
previews: &PreviewCache,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
for entity in screens {
|
||||
commands.entity(entity).despawn();
|
||||
spawn_store_modal(commands, catalog_state, registry, font_res);
|
||||
spawn_store_modal(commands, catalog_state, registry, previews, font_res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +509,7 @@ fn spawn_store_modal(
|
||||
commands: &mut Commands,
|
||||
catalog_state: &CatalogState,
|
||||
registry: Option<&ThemeRegistry>,
|
||||
previews: &PreviewCache,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let body_font = TextFont {
|
||||
@@ -433,7 +552,15 @@ fn spawn_store_modal(
|
||||
for entry in entries {
|
||||
let installed =
|
||||
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
|
||||
spawn_store_row(
|
||||
card,
|
||||
entry,
|
||||
installed,
|
||||
previews.0.get(&entry.id),
|
||||
&body_font,
|
||||
&caption_font,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,12 +579,20 @@ fn spawn_store_modal(
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// One catalog row: name + author/size caption on the left, Install
|
||||
/// button (or "Installed" caption) on the right.
|
||||
/// Height of a row's preview thumbnail in logical pixels; the width
|
||||
/// follows the theme's own `card_aspect` so a preview is never
|
||||
/// stretched.
|
||||
const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0;
|
||||
|
||||
/// One catalog row: preview thumbnail (when downloaded) + name +
|
||||
/// author/size caption on the left, Install button (or "Installed"
|
||||
/// caption) on the right.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_store_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
entry: &ThemeCatalogEntry,
|
||||
installed: bool,
|
||||
preview: Option<&Handle<Image>>,
|
||||
body_font: &TextFont,
|
||||
caption_font: &TextFont,
|
||||
font_res: Option<&FontResource>,
|
||||
@@ -477,6 +612,25 @@ fn spawn_store_row(
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|left| {
|
||||
if let Some(handle) = preview {
|
||||
let (aw, ah) = entry.card_aspect;
|
||||
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
|
||||
left.spawn((
|
||||
ImageNode::new(handle.clone()),
|
||||
Node {
|
||||
width: Val::Px(width),
|
||||
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
left.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
@@ -497,6 +651,7 @@ fn spawn_store_row(
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
});
|
||||
if installed {
|
||||
row.spawn((
|
||||
Text::new("Installed"),
|
||||
@@ -661,4 +816,85 @@ mod tests {
|
||||
));
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase H part 2: preview thumbnails
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry {
|
||||
ThemeCatalogEntry {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
author: "Test".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
size_bytes: 1024,
|
||||
sha256: "00".into(),
|
||||
download_url: format!("/api/themes/{id}/download"),
|
||||
preview_url: preview.then(|| format!("/api/themes/{id}/preview")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row whose theme id is in the [`PreviewCache`] renders an
|
||||
/// `ImageNode` thumbnail; a row without one stays text-only.
|
||||
#[test]
|
||||
fn loaded_row_shows_preview_thumbnail_only_when_cached() {
|
||||
let mut app = headless_app();
|
||||
let cached = catalog_entry("with-preview", true);
|
||||
let uncached = catalog_entry("without-preview", false);
|
||||
|
||||
let mut cache = PreviewCache::default();
|
||||
cache
|
||||
.0
|
||||
.insert(cached.id.clone(), Handle::<Image>::default());
|
||||
let state = CatalogState::Loaded(vec![cached, uncached]);
|
||||
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_store_modal(&mut commands, &state, None, &cache, None);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let thumbs = app
|
||||
.world_mut()
|
||||
.query::<&ImageNode>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
thumbs, 1,
|
||||
"exactly the cached entry must render a preview thumbnail"
|
||||
);
|
||||
}
|
||||
|
||||
/// The thumbnail width follows the theme's own card aspect so a
|
||||
/// preview never renders stretched.
|
||||
#[test]
|
||||
fn preview_thumbnail_width_follows_card_aspect() {
|
||||
let mut app = headless_app();
|
||||
let mut entry = catalog_entry("wide", true);
|
||||
entry.card_aspect = (1, 1); // square theme art
|
||||
let mut cache = PreviewCache::default();
|
||||
cache.0.insert(entry.id.clone(), Handle::<Image>::default());
|
||||
let state = CatalogState::Loaded(vec![entry]);
|
||||
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_store_modal(&mut commands, &state, None, &cache, None);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let node = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<ImageNode>>()
|
||||
.single(app.world())
|
||||
.expect("thumbnail node must exist");
|
||||
assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX));
|
||||
assert_eq!(
|
||||
node.width,
|
||||
Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||
"a 1:1 aspect must yield a square thumbnail"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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