fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+88 -79
View File
@@ -11,12 +11,12 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{
achievement_by_id, check_achievements, AchievementContext, AchievementDef, Reward,
ALL_ACHIEVEMENTS,
ALL_ACHIEVEMENTS, AchievementContext, AchievementDef, Reward, achievement_by_id,
check_achievements,
};
use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
AchievementRecord, save_progress_to,
AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to,
save_progress_to, save_settings_to,
};
use crate::events::{
@@ -31,8 +31,8 @@ use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalScrim, ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -140,7 +140,10 @@ impl Plugin for AchievementPlugin {
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
@@ -235,17 +238,23 @@ fn evaluate_on_win(
unlocks.write(AchievementUnlockedEvent(record.clone()));
}
if achievements_changed
&& let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0) {
warn!("failed to save achievements: {e}");
}
// Persist progress FIRST. Only if that succeeds do we mark
// `reward_granted = true` on the achievements and save them.
// This prevents the corruption where reward_granted is persisted
// but the XP was not (permanent XP loss on next launch).
if progress_changed
&& let Some(target) = &progress_path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after reward: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after reward: {e}");
}
if achievements_changed
&& let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0)
{
warn!("failed to save achievements: {e}");
}
}
}
@@ -486,9 +495,7 @@ fn spawn_achievements_screen(
// greyed-out grid.
if !any_unlocked {
card.spawn((
Text::new(
"Complete games and try new modes to unlock achievements and rewards.",
),
Text::new("Complete games and try new modes to unlock achievements and rewards."),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
@@ -802,7 +809,10 @@ mod tests {
// trigger update_stats_on_win first (StatsUpdate runs before
// evaluate_on_win), bumping draw_three_wins to 10 — the unlock
// threshold for the draw_three_master achievement.
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 9;
app.world_mut()
.resource_mut::<StatsResource>()
.0
.draw_three_wins = 9;
// The current game must be in DrawThree mode so update_on_win
// increments draw_three_wins (and not draw_one_wins).
@@ -830,7 +840,10 @@ mod tests {
.find(|r| r.id == "draw_three_master")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win");
assert!(
unlocked,
"draw_three_master must unlock at the 10th Draw-Three win"
);
// Verify the AchievementUnlockedEvent fired for this id.
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
@@ -848,7 +861,10 @@ mod tests {
// Pre-seed eight prior Draw-Three wins. The pending GameWonEvent
// brings draw_three_wins to 9 — one short of the threshold.
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 8;
app.world_mut()
.resource_mut::<StatsResource>()
.0
.draw_three_wins = 8;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
@@ -871,7 +887,10 @@ mod tests {
.find(|r| r.id == "draw_three_master")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins");
assert!(
!unlocked,
"draw_three_master must remain locked at 9 Draw-Three wins"
);
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
@@ -892,10 +911,8 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
@@ -1170,9 +1187,9 @@ mod tests {
// canonical secret description in `solitaire_core` is already
// generic ("A secret achievement"); these checks guard against a
// future leak where someone replaces it with the literal predicate.
let leaked_predicate = tips.iter().any(|t| {
t.contains("90") && t.to_lowercase().contains("without undo")
});
let leaked_predicate = tips
.iter()
.any(|t| t.contains("90") && t.to_lowercase().contains("without undo"));
assert!(
!leaked_predicate,
"no tooltip may state the speed_and_skill predicate: {tips:?}"
@@ -1375,9 +1392,9 @@ mod tests {
// -----------------------------------------------------------------------
use crate::replay_playback::ReplayPlaybackState;
use solitaire_data::{Replay, ReplayMove};
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
@@ -1441,13 +1458,12 @@ mod tests {
// Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
assert!(
!cinephile_unlocked(&app),
@@ -1456,8 +1472,7 @@ mod tests {
// Frame 2: transition to Completed. The observer must detect
// `last_was_playing = true && now_completed = true` and unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
assert!(
@@ -1477,19 +1492,17 @@ mod tests {
fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
// Direct Playing → Inactive — the path the Stop button takes via
// `stop_replay_playback`. Must not unlock cinephile.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update();
assert!(
@@ -1510,18 +1523,19 @@ mod tests {
let mut app = cinephile_app();
// First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
assert!(
cinephile_unlocked(&app),
"precondition: first cycle must unlock"
);
// Drain the event queue so the next assertion doesn't double-count
// the legitimate first-time unlock event.
@@ -1530,19 +1544,16 @@ mod tests {
.clear();
// Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
assert_eq!(
@@ -1559,16 +1570,14 @@ mod tests {
fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
// Stay in Completed for a few more frames as the real auto-clear
// does. Each subsequent frame the resource is still `Completed`
+21 -9
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use solitaire_core::game_state::GameMode;
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use solitaire_data::{Settings, matomo_client::MatomoClient, settings::SyncBackend};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::{GameStateResource, TokioRuntimeResource};
@@ -59,13 +59,13 @@ impl Plugin for AnalyticsPlugin {
// refuses to create threads (resource-limited / sandboxed environments).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt).add_systems(
Update,
(on_game_won, on_forfeit, tick_flush_timer),
);
app.insert_resource(rt)
.add_systems(Update, (on_game_won, on_forfeit, tick_flush_timer));
}
Err(e) => {
bevy::log::warn!("analytics_plugin: Tokio runtime unavailable — analytics flush disabled: {e}");
bevy::log::warn!(
"analytics_plugin: Tokio runtime unavailable — analytics flush disabled: {e}"
);
}
}
}
@@ -96,9 +96,13 @@ fn on_game_won(
let Some(client) = analytics.client.clone() else {
return;
};
let mut any = false;
for ev in wins.read() {
client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), rt.0.clone());
any = true;
}
if any {
fire_flush(client, rt.0.clone());
}
}
@@ -110,9 +114,13 @@ fn on_forfeit(
let Some(client) = analytics.client.clone() else {
return;
};
let mut any = false;
for _ev in forfeits.read() {
client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), rt.0.clone());
any = true;
}
if any {
fire_flush(client, rt.0.clone());
}
}
@@ -172,7 +180,11 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None,
};
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
Some(Arc::new(MatomoClient::new(
url,
settings.matomo_site_id,
uid,
)))
}
fn fire_flush(client: Arc<MatomoClient>, rt: Arc<tokio::runtime::Runtime>) {
+1 -1
View File
@@ -6,8 +6,8 @@
pub fn set_text(text: &str) -> Result<(), String> {
use bevy::android::ANDROID_APP;
use jni::{
objects::{JObject, JValueOwned},
JavaVM,
objects::{JObject, JValueOwned},
};
let app = ANDROID_APP
+87 -41
View File
@@ -17,7 +17,7 @@ use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_animation::{CardAnimation, MotionCurve, sample_curve};
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
@@ -32,9 +32,9 @@ use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{
scaled_duration, ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS,
MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO,
STATE_WARNING, TEXT_PRIMARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST, scaled_duration,
};
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
@@ -53,7 +53,9 @@ pub struct EffectiveSlideDuration {
impl Default for EffectiveSlideDuration {
fn default() -> Self {
Self { slide_secs: SLIDE_SECS }
Self {
slide_secs: SLIDE_SECS,
}
}
}
@@ -329,12 +331,12 @@ fn handle_win_cascade(
Vec3::new(-margin, 0.0, 300.0),
];
let step = settings
.as_ref()
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
let duration = settings
.as_ref()
.map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed));
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| {
cascade_step_secs(s.0.animation_speed)
});
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| {
cascade_duration_secs(s.0.animation_speed)
});
for (i, (entity, transform)) in cards.iter().enumerate() {
// Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can
@@ -444,7 +446,11 @@ fn handle_time_attack_toast(
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
format!(
"Time Attack: {} win{}",
ev.wins,
if ev.wins == 1 { "" } else { "s" }
),
TIME_ATTACK_TOAST_SECS,
ToastVariant::Info,
);
@@ -528,10 +534,7 @@ fn handle_auto_complete_toast(
/// This is the first half of the two-system toast queue (Task #67). The queue
/// decouples event production from rendering so multiple simultaneous events do
/// not cause overlapping toast text on screen.
fn enqueue_toasts(
mut events: MessageReader<InfoToastEvent>,
mut queue: ResMut<ToastQueue>,
) {
fn enqueue_toasts(mut events: MessageReader<InfoToastEvent>, mut queue: ResMut<ToastQueue>) {
for ev in events.read() {
queue.0.push_back(ev.0.clone());
}
@@ -572,11 +575,12 @@ fn drive_toast_display(
// If no active toast and the queue has messages, show the next one.
if active.entity.is_none()
&& let Some(message) = queue.0.pop_front() {
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
&& let Some(message) = queue.0.pop_front()
{
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
}
/// Visual variant of a toast — drives the 1px border accent per the
@@ -682,10 +686,7 @@ fn handle_move_rejected_toast(
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(
mut commands: Commands,
mut events: MessageReader<WarningToastEvent>,
) {
fn handle_warning_toast(mut commands: Commands, mut events: MessageReader<WarningToastEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
@@ -832,7 +833,11 @@ mod tests {
reduce_motion_mode: true,
..Settings::default()
};
assert_eq!(effective_slide_secs(&s), 0.0, "Fast + reduce-motion still 0.0");
assert_eq!(
effective_slide_secs(&s),
0.0,
"Fast + reduce-motion still 0.0"
);
}
#[test]
@@ -869,13 +874,24 @@ mod tests {
.world_mut()
.spawn((
Transform::from_translation(start),
CardAnim { start, target, elapsed: 0.5, duration: 1.0, delay: 0.0 },
CardAnim {
start,
target,
elapsed: 0.5,
duration: 1.0,
delay: 0.0,
},
))
.id();
app.update();
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(
pos.x > 50.0 && pos.x < 100.0,
"with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}",
@@ -897,7 +913,13 @@ mod tests {
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnim { start: Vec3::ZERO, target, elapsed: 1.0, duration: 1.0, delay: 0.0 },
CardAnim {
start: Vec3::ZERO,
target,
elapsed: 1.0,
duration: 1.0,
delay: 0.0,
},
))
.id();
@@ -907,7 +929,12 @@ mod tests {
app.world().entity(entity).get::<CardAnim>().is_none(),
"CardAnim should be removed when done"
);
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!((pos.x - 10.0).abs() < 1e-3);
}
@@ -932,7 +959,12 @@ mod tests {
app.update();
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
}
@@ -1021,7 +1053,8 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.world_mut().write_message(InfoToastEvent("hello".to_string()));
app.world_mut()
.write_message(InfoToastEvent("hello".to_string()));
app.update();
let count = app
@@ -1125,8 +1158,12 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
app.world_mut().write_message(SettingsChangedEvent(fast_settings));
let fast_settings = Settings {
animation_speed: AnimSpeed::Fast,
..Default::default()
};
app.world_mut()
.write_message(SettingsChangedEvent(fast_settings));
app.update();
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
@@ -1144,8 +1181,10 @@ mod tests {
.count();
assert_eq!(before, 0, "no animations before win");
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 60 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let after = app
@@ -1162,8 +1201,10 @@ mod tests {
#[test]
fn win_cascade_uses_expressive_curve() {
let mut app = app_with_anim();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let mut q = app.world_mut().query::<&CardAnimation>();
@@ -1179,8 +1220,10 @@ mod tests {
#[test]
fn win_cascade_applies_per_card_rotation() {
let mut app = app_with_anim();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
// At least one card's rotation must differ from identity — the
@@ -1190,7 +1233,10 @@ mod tests {
let any_rotated = q
.iter(app.world())
.any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999);
assert!(any_rotated, "expected at least one card to receive a Z rotation drift");
assert!(
any_rotated,
"expected at least one card to receive a Z rotation drift"
);
}
#[test]
+2 -2
View File
@@ -11,9 +11,9 @@ pub mod svg_loader;
pub mod user_dir;
pub use sources::{
AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_THEME_MANIFEST_URL, USER_THEMES,
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes,
populate_embedded_classic_theme, populate_embedded_dark_theme, register_theme_asset_sources,
AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_THEME_MANIFEST_URL, USER_THEMES,
};
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use svg_loader::{SvgLoader, SvgLoaderError, SvgLoaderSettings, rasterize_svg};
pub use user_dir::{set_user_theme_dir, user_theme_dir};
+14 -14
View File
@@ -47,10 +47,10 @@
//! comments on each call out the pairing so a future reader doesn't
//! accidentally drop one half.
use bevy::asset::AssetApp;
use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
use bevy::asset::io::file::FileAssetReader;
use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::AssetApp;
use bevy::prelude::*;
use crate::assets::user_dir::user_theme_dir;
@@ -75,8 +75,7 @@ pub const DARK_THEME_MANIFEST_URL: &str =
const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/theme.ron";
/// Bytes of the bundled Dark theme manifest, embedded at compile time.
const DARK_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/dark/theme.ron");
const DARK_THEME_MANIFEST_BYTES: &[u8] = include_bytes!("../../assets/themes/dark/theme.ron");
/// Stable embedded asset URL of the bundled Classic theme manifest.
pub const CLASSIC_THEME_MANIFEST_URL: &str =
@@ -89,8 +88,7 @@ pub const CLASSIC_THEME_MANIFEST_URL: &str =
const CLASSIC_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/classic/theme.ron";
/// Bytes of the bundled Classic theme manifest, embedded at compile time.
const CLASSIC_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/classic/theme.ron");
const CLASSIC_THEME_MANIFEST_BYTES: &[u8] = include_bytes!("../../assets/themes/classic/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG.
macro_rules! embed_dark_svg {
@@ -377,10 +375,11 @@ mod tests {
fn populate_embedded_dark_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_dark_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
assert!(
app.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some()
);
}
#[test]
@@ -425,10 +424,11 @@ mod tests {
fn populate_embedded_classic_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_classic_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
assert!(
app.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some()
);
}
#[test]
+6 -5
View File
@@ -248,8 +248,7 @@ mod tests {
#[test]
fn rasterizes_svg_with_unmatched_font_family() {
let image =
rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation");
let image = rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation");
assert_eq!(image.size().x, 64);
assert_eq!(image.size().y, 96);
}
@@ -262,9 +261,11 @@ mod tests {
#[test]
fn pixmap_data_is_rgba_with_target_byte_count() {
let image =
rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
let pixels = image.data.as_ref().expect("rasterised image carries pixel data");
let image = rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
let pixels = image
.data
.as_ref()
.expect("rasterised image carries pixel data");
// 32 × 48 × 4 (RGBA bytes) = 6144 bytes
assert_eq!(pixels.len(), 32 * 48 * 4);
}
+4 -1
View File
@@ -123,7 +123,10 @@ mod tests {
// user's `$HOME` on desktop, but it must at least be a
// non-empty path with a parent component.
let dir = detected_platform_data_dir();
assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute");
assert!(
dir.parent().is_some(),
"data dir {dir:?} should be absolute"
);
}
// The OnceLock-based override is intentionally NOT covered here:
+32 -14
View File
@@ -22,8 +22,8 @@
use std::io::Cursor;
use bevy::prelude::*;
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::sound::Region;
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::track::{TrackBuilder, TrackHandle};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
@@ -178,8 +178,7 @@ fn build_library() -> Option<SoundLibrary> {
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
let foundation_complete =
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
let foundation_complete = decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
Some(SoundLibrary {
deal,
flip,
@@ -212,8 +211,7 @@ fn start_ambient_loop(
) -> Option<StaticSoundHandle> {
let manager = manager?;
let ambient_bytes: &'static [u8] =
include_bytes!("../../assets/audio/ambient_loop.wav");
let ambient_bytes: &'static [u8] = include_bytes!("../../assets/audio/ambient_loop.wav");
let mut data = match StaticSoundData::from_cursor(Cursor::new(ambient_bytes.to_vec())) {
Ok(d) => d,
Err(e) => {
@@ -280,13 +278,19 @@ impl AudioState {
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.sfx_track.as_mut() {
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
track.set_volume(
amplitude_to_decibels(volume.clamp(0.0, 1.0)),
Tween::default(),
);
}
}
fn set_music_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.music_track.as_mut() {
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
track.set_volume(
amplitude_to_decibels(volume.clamp(0.0, 1.0)),
Tween::default(),
);
}
}
@@ -319,7 +323,10 @@ fn apply_volume_on_change(
let sfx_muted = mute.as_ref().is_some_and(|m| m.sfx_muted);
let music_muted = mute.as_ref().is_some_and(|m| m.music_muted);
set_sfx_volume(&mut audio, if sfx_muted { 0.0 } else { ev.0.sfx_volume });
set_music_volume(&mut audio, if music_muted { 0.0 } else { ev.0.music_volume });
set_music_volume(
&mut audio,
if music_muted { 0.0 } else { ev.0.music_volume },
);
}
}
@@ -374,8 +381,7 @@ fn play_on_draw(
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
data.settings.volume =
Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
data.settings.volume = Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
@@ -516,7 +522,10 @@ mod tests {
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M should mute both channels");
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "second M should unmute both channels");
assert!(
!m.sfx_muted && !m.music_muted,
"second M should unmute both channels"
);
}
#[test]
@@ -537,14 +546,23 @@ mod tests {
assert!(m.music_muted && !m.sfx_muted);
// M should mute sfx (not-all-muted → mute-all).
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M unmutes neither — it mutes all when sfx was audible");
assert!(
m.sfx_muted && m.music_muted,
"M unmutes neither — it mutes all when sfx was audible"
);
}
#[test]
fn mute_all_when_both_already_muted_unmutes_both() {
let mut m = MuteState { sfx_muted: true, music_muted: true };
let mut m = MuteState {
sfx_muted: true,
music_muted: true,
};
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "M should unmute both when all were muted");
assert!(
!m.sfx_muted && !m.music_muted,
"M should unmute both when all were muted"
);
}
// -----------------------------------------------------------------------
+28 -19
View File
@@ -39,17 +39,16 @@ pub struct AutoCompletePlugin;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
.add_systems(
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
app.init_resource::<AutoCompleteState>().add_systems(
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
}
}
@@ -103,7 +102,9 @@ fn on_auto_complete_start(
return;
}
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else { return };
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else {
return;
};
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
}
@@ -163,14 +164,22 @@ mod tests {
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
g.is_auto_completable = true;
g
}
+5 -10
View File
@@ -19,7 +19,7 @@
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use crate::resources::TokioRuntimeResource;
@@ -60,7 +60,9 @@ impl Plugin for AvatarPlugin {
.add_systems(Update, handle_avatar_fetch);
}
Err(e) => {
bevy::log::warn!("avatar_plugin: Tokio runtime unavailable — avatar fetch disabled: {e}");
bevy::log::warn!(
"avatar_plugin: Tokio runtime unavailable — avatar fetch disabled: {e}"
);
}
}
}
@@ -78,14 +80,7 @@ fn handle_avatar_fetch(
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async move {
let client = reqwest::Client::new();
let bytes = client
.get(&url)
.send()
.await
.ok()?
.bytes()
.await
.ok()?;
let bytes = client.get(&url).send().await.ok()?.bytes().await.ok()?;
Some(bytes.to_vec())
})
}));
@@ -34,7 +34,7 @@ use std::f32::consts::PI;
use bevy::prelude::*;
use super::curves::{sample_curve, MotionCurve};
use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration;
use crate::pause_plugin::PausedResource;
@@ -192,7 +192,11 @@ pub fn retarget_animation(
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
_ => (
transform.translation.truncate(),
transform.translation.z,
0.0,
),
};
let distance = current_xy.distance(new_end);
@@ -328,7 +332,10 @@ mod tests {
fn current_xy_at_start() {
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 0.0, 1.0);
let pos = anim.current_xy();
assert!(pos.x < 5.0, "at t=0 position should be near start, got {pos:?}");
assert!(
pos.x < 5.0,
"at t=0 position should be near start, got {pos:?}"
);
}
#[test]
@@ -390,7 +397,10 @@ mod tests {
fn win_scatter_targets_are_off_center() {
for t in win_scatter_targets(400.0) {
let dist = t.length();
assert!(dist > 100.0, "scatter target should be well off-center: {t:?}");
assert!(
dist > 100.0,
"scatter target should be well off-center: {t:?}"
);
}
}
}
+32 -6
View File
@@ -126,7 +126,12 @@ mod tests {
MotionCurve::Responsive,
MotionCurve::Expressive,
] {
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
assert_near(
sample_curve(curve, 0.0),
0.0,
1e-5,
&format!("{curve:?} at t=0"),
);
}
}
@@ -137,7 +142,12 @@ mod tests {
MotionCurve::SoftBounce,
MotionCurve::Responsive,
] {
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
assert_near(
sample_curve(curve, 1.0),
1.0,
1e-4,
&format!("{curve:?} at t=1"),
);
}
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
assert_near(
@@ -159,8 +169,14 @@ mod tests {
fn smooth_snap_overshoots_slightly_near_end() {
// Peak overshoot is around t = 0.875.
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
assert!(
peak > 1.0,
"SmoothSnap should overshoot at t=0.875, got {peak}"
);
assert!(
peak < 1.03,
"SmoothSnap overshoot should be small (<3 %), got {peak}"
);
}
#[test]
@@ -186,11 +202,21 @@ mod tests {
#[test]
fn sample_curve_clamps_t_below_zero() {
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
assert_near(
sample_curve(MotionCurve::SmoothSnap, -1.0),
0.0,
1e-5,
"t<0 clamped",
);
}
#[test]
fn sample_curve_clamps_t_above_one() {
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
assert_near(
sample_curve(MotionCurve::Responsive, 2.0),
1.0,
1e-5,
"t>1 clamped",
);
}
}
@@ -190,7 +190,10 @@ mod tests {
// is_above_target(30.0) is strict: fps must be > 30, not >=.
// At exactly 30 FPS the result depends on floating-point rounding,
// so just check that it's consistent with > 60 being false.
assert!(!d.is_above_target(60.0), "30 FPS is not above 60 FPS target");
assert!(
!d.is_above_target(60.0),
"30 FPS is not above 60 FPS target"
);
}
#[test]
@@ -71,7 +71,9 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move { from: crate::events::MoveRequestEvent },
Move {
from: crate::events::MoveRequestEvent,
},
Draw,
Undo,
}
@@ -139,9 +141,7 @@ pub(crate) fn detect_hover(
let mut best: Option<(Entity, f32)> = None;
for (entity, transform) in &cards {
let pos = transform.translation.truncate();
if (cursor_world.x - pos.x).abs() < half_w
&& (cursor_world.y - pos.y).abs() < half_h
{
if (cursor_world.x - pos.x).abs() < half_w && (cursor_world.y - pos.y).abs() < half_h {
let z = transform.translation.z;
if best.is_none_or(|(_, bz)| z > bz) {
best = Some((entity, z));
@@ -187,9 +187,7 @@ pub(crate) fn apply_hover_scale(
// Update the tracked scale for external inspection.
hover_state.scale = if let Some(entity) = target_entity {
cards
.get(entity)
.map_or(hover_target, |(_, t)| t.scale.x)
cards.get(entity).map_or(hover_target, |(_, t)| t.scale.x)
} else {
1.0
};
+30 -20
View File
@@ -80,14 +80,14 @@ pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
pub use chain::AnimationChain;
pub use curves::{sample_curve, MotionCurve};
pub use curves::{MotionCurve, sample_curve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
cascade_delay, compute_duration, micro_vary,
};
pub use tuning::{AnimationTuning, InputPlatform};
@@ -179,10 +179,7 @@ pub struct WinCascadePlugin;
impl Plugin for WinCascadePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
trigger_expressive_win_cascade.after(GameMutation),
);
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
}
}
@@ -200,9 +197,7 @@ fn trigger_expressive_win_cascade(
return;
}
let radius = layout
.as_ref()
.map_or(800.0, |l| l.0.card_size.x * 8.0);
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
let targets = win_scatter_targets(radius);
@@ -212,10 +207,16 @@ fn trigger_expressive_win_cascade(
let target = targets[index % targets.len()];
commands.entity(entity).insert(
CardAnimation::slide(start_xy, start_z, target, start_z + 60.0, MotionCurve::Expressive)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
CardAnimation::slide(
start_xy,
start_z,
target,
start_z + 60.0,
MotionCurve::Expressive,
)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
);
}
}
@@ -265,7 +266,8 @@ mod tests {
#[test]
fn card_animation_advances_and_removes_itself() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
let start = Vec2::new(0.0, 0.0);
let end = Vec2::new(100.0, 0.0);
@@ -306,7 +308,8 @@ mod tests {
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
let end = Vec2::new(200.0, 100.0);
let entity = app
@@ -353,7 +356,8 @@ mod tests {
#[test]
fn card_animation_respects_delay() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
let entity = app
.world_mut()
@@ -391,8 +395,14 @@ mod tests {
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
// FIFO: Draw comes out first.
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw));
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo));
assert!(matches!(
buf.queue.pop_front().unwrap(),
BufferedInput::Draw
));
assert!(matches!(
buf.queue.pop_front().unwrap(),
BufferedInput::Undo
));
}
#[test]
@@ -88,7 +88,10 @@ mod tests {
let mut prev = 0.0f32;
for d in [10, 50, 100, 200, 400, 600] {
let dur = compute_duration(d as f32);
assert!(dur >= prev, "duration must be monotone: d={d} dur={dur} prev={prev}");
assert!(
dur >= prev,
"duration must be monotone: d={d} dur={dur} prev={prev}"
);
prev = dur;
}
}
@@ -129,7 +132,10 @@ mod tests {
let a = micro_vary(0.2, 1);
let b = micro_vary(0.2, 2);
// Very unlikely to be equal (would require hash collision mod 65536).
assert!((a - b).abs() > 1e-9, "micro_vary should differ for different indices");
assert!(
(a - b).abs() > 1e-9,
"micro_vary should differ for different indices"
);
}
#[test]
+13 -4
View File
@@ -114,7 +114,7 @@ impl AnimationTuning {
platform: InputPlatform::Touch,
duration_scale: 0.75,
overshoot_scale: 0.5,
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
drag_scale: 1.12,
hover_scale: 1.0, // no hover affordance on touch
hover_lerp_speed: 20.0,
@@ -182,15 +182,24 @@ mod tests {
assert_eq!(t.duration_scale, 1.0);
assert_eq!(t.platform, InputPlatform::Mouse);
assert!(t.hover_scale > 1.0, "desktop hover must lift the card");
assert!(t.drag_threshold_px < 10.0, "desktop threshold must be smaller than mobile");
assert!(
t.drag_threshold_px < 10.0,
"desktop threshold must be smaller than mobile"
);
}
#[test]
fn mobile_is_faster_than_desktop() {
let d = AnimationTuning::desktop();
let m = AnimationTuning::mobile();
assert!(m.duration_scale < d.duration_scale, "mobile must animate faster");
assert!(m.overshoot_scale < d.overshoot_scale, "mobile must bounce less");
assert!(
m.duration_scale < d.duration_scale,
"mobile must animate faster"
);
assert!(
m.overshoot_scale < d.overshoot_scale,
"mobile must bounce less"
);
}
#[test]
File diff suppressed because it is too large Load Diff
+17 -8
View File
@@ -58,12 +58,15 @@ fn advance_on_challenge_win(
let prev = progress.0.challenge_index;
progress.0.challenge_index = prev.saturating_add(1);
if let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after challenge advance: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after challenge advance: {e}");
}
// Human-readable level is 1-based (index 0 → "Challenge 1").
let level_number = prev.saturating_add(1);
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
toast.write(InfoToastEvent(format!(
"Challenge {level_number} complete!"
)));
advanced.write(ChallengeAdvancedEvent {
previous_index: prev,
new_index: progress.0.challenge_index,
@@ -184,8 +187,7 @@ mod tests {
#[test]
fn pressing_x_at_unlock_level_fires_new_game_with_challenge_seed() {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.level =
CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
@@ -215,7 +217,10 @@ mod tests {
fn challenge_win_fires_complete_toast_with_level_number() {
let mut app = headless_app();
// Set challenge_index to 2 so the completed level is "Challenge 3".
app.world_mut().resource_mut::<ProgressResource>().0.challenge_index = 2;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.challenge_index = 2;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
@@ -228,7 +233,11 @@ mod tests {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
assert_eq!(
fired.len(),
1,
"exactly one toast must fire on challenge win"
);
assert!(
fired[0].0.contains("Challenge 3"),
"toast must name the 1-based level that was just completed"
+59 -17
View File
@@ -41,7 +41,7 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
};
@@ -126,7 +126,9 @@ fn update_cursor_icon(
button_q: Query<&Interaction, With<Button>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.single() else { return };
let Ok((win_entity, window)) = windows.single() else {
return;
};
let is_dragging = !drag.is_idle();
@@ -225,7 +227,9 @@ fn update_drop_highlights(
let Some(game) = game else { return };
// The first element of drag.cards is the bottom card that lands on the target.
let Some(&bottom_id) = drag.cards.first() else { return };
let Some(&bottom_id) = drag.cards.first() else {
return;
};
let bottom_card = game
.0
.piles
@@ -233,7 +237,9 @@ fn update_drop_highlights(
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else { return };
let Some(bottom_card) = bottom_card else {
return;
};
let drag_count = drag.cards.len();
for (marker, mut sprite, _rch) in &mut markers {
@@ -532,10 +538,7 @@ mod tests {
fn marker_valid_and_default_colours_are_distinct() {
// Regression guard — ensure these constants haven't been accidentally
// set to the same value.
assert_ne!(
format!("{MARKER_VALID:?}"),
format!("{MARKER_DEFAULT:?}")
);
assert_ne!(format!("{MARKER_VALID:?}"), format!("{MARKER_DEFAULT:?}"));
}
#[test]
@@ -603,13 +606,17 @@ mod tests {
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState};
use crate::layout::compute_layout;
use solitaire_core::game_state::{DrawMode, GameState};
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
assert!(!cursor_over_draggable(
Vec2::new(-9999.0, -9999.0),
&game,
&layout
));
}
// -----------------------------------------------------------------------
@@ -627,7 +634,12 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true)))
.insert_resource(LayoutResource(compute_layout(
Vec2::new(1280.0, 800.0),
0.0,
0.0,
true,
)))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
@@ -674,9 +686,19 @@ mod tests {
set_tableau_top(
&mut game,
2,
Card { id: 9001, suit: Suit::Spades, rank: Rank::Six, face_up: true },
Card {
id: 9001,
suit: Suit::Spades,
rank: Rank::Six,
face_up: true,
},
);
let dragged = Card { id: 9002, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
let dragged = Card {
id: 9002,
suit: Suit::Hearts,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
@@ -704,9 +726,19 @@ mod tests {
set_tableau_top(
&mut game,
2,
Card { id: 9101, suit: Suit::Clubs, rank: Rank::Six, face_up: true },
Card {
id: 9101,
suit: Suit::Clubs,
rank: Rank::Six,
face_up: true,
},
);
let dragged = Card { id: 9102, suit: Suit::Spades, rank: Rank::Five, face_up: true };
let dragged = Card {
id: 9102,
suit: Suit::Spades,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
@@ -734,9 +766,19 @@ mod tests {
set_tableau_top(
&mut game,
2,
Card { id: 9201, suit: Suit::Spades, rank: Rank::Six, face_up: true },
Card {
id: 9201,
suit: Suit::Spades,
rank: Rank::Six,
face_up: true,
},
);
let dragged = Card { id: 9202, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
let dragged = Card {
id: 9202,
suit: Suit::Hearts,
rank: Rank::Five,
face_up: true,
};
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
+93 -55
View File
@@ -13,7 +13,7 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal;
@@ -89,6 +89,16 @@ struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
#[derive(Resource, Default, Debug)]
struct DailyExpiryWarningShown(Option<NaiveDate>);
/// Throttle timer so `check_date_rollover` does not call `Local::now()` every frame.
#[derive(Resource)]
struct DateRolloverTimer(Timer);
impl Default for DateRolloverTimer {
fn default() -> Self {
Self(Timer::from_seconds(60.0, TimerMode::Repeating))
}
}
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin;
@@ -98,6 +108,7 @@ impl Plugin for DailyChallengePlugin {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.init_resource::<DateRolloverTimer>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
@@ -111,7 +122,8 @@ impl Plugin for DailyChallengePlugin {
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning);
.add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover);
}
}
@@ -161,8 +173,7 @@ fn poll_server_challenge(
daily.max_time_secs = goal.max_time_secs;
info!(
"daily challenge seed updated from server: {old_seed} → {} ({})",
goal.seed,
goal.description
goal.seed, goal.description
);
}
}
@@ -184,28 +195,35 @@ fn handle_daily_completion(
}
// Enforce server-supplied goal constraints when present.
if let Some(target) = daily.target_score
&& ev.score < target {
continue; // score goal not met
}
&& ev.score < target
{
continue; // score goal not met
}
if let Some(max_secs) = daily.max_time_secs
&& ev.time_seconds > max_secs {
continue; // time limit exceeded
}
&& ev.time_seconds > max_secs
{
continue; // time limit exceeded
}
if !progress.0.record_daily_completion(daily.date) {
// Already counted today — no-op.
continue;
}
progress.0.add_xp(DAILY_BONUS_XP);
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
xp_awarded.write(XpAwardedEvent {
amount: DAILY_BONUS_XP,
});
if let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after daily completion: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after daily completion: {e}");
}
completed.write(DailyChallengeCompletedEvent {
date: daily.date,
streak: progress.0.daily_challenge_streak,
});
toast.write(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
toast.write(InfoToastEvent(
"Daily challenge complete! +100 XP".to_string(),
));
}
}
@@ -298,12 +316,40 @@ fn check_daily_expiry_warning(
)));
}
/// Detects when the local calendar day changes while the app is running
/// (e.g. the app stays open past midnight) and refreshes the daily
/// challenge resource for the new day.
fn check_date_rollover(
time: Res<Time>,
mut timer: ResMut<DateRolloverTimer>,
mut daily: ResMut<DailyChallengeResource>,
mut shown: ResMut<DailyExpiryWarningShown>,
) {
timer.0.tick(time.delta());
if !timer.0.just_finished() {
return;
}
let today = Local::now().date_naive();
if today != daily.date {
info!(
"daily_challenge: date rolled over from {} to {}; refreshing challenge",
daily.date, today
);
*daily = DailyChallengeResource::for_today();
// Reset the expiry-warning state so the new day's warning can fire.
shown.0 = None;
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
@@ -346,7 +392,9 @@ mod tests {
// +100 from the daily bonus
assert!(progress.total_xp >= DAILY_BONUS_XP);
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let events = app
.world()
.resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -370,7 +418,9 @@ mod tests {
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 0);
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let events = app
.world()
.resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -395,7 +445,10 @@ mod tests {
app.update();
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 1, "streak does not double-count");
assert_eq!(
progress.daily_challenge_streak, 1,
"streak does not double-count"
);
}
#[test]
@@ -428,7 +481,9 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let events = app
.world()
.resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -439,14 +494,21 @@ mod tests {
fn pressing_c_with_no_description_uses_fallback() {
let mut app = headless_app();
// Ensure no description is set.
assert!(app.world().resource::<DailyChallengeResource>().goal_description.is_none());
assert!(
app.world()
.resource::<DailyChallengeResource>()
.goal_description
.is_none()
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let events = app
.world()
.resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -511,13 +573,8 @@ mod tests {
fn warning_suppressed_when_already_completed_today() {
// 23:50 UTC inside threshold, but today is already done.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 8)),
None,
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), Some(ymd(2026, 5, 8)), None, now, 30);
assert_eq!(mins, None);
}
@@ -525,26 +582,16 @@ mod tests {
fn warning_suppressed_when_yesterdays_completion_is_stale() {
// Yesterday's completion is irrelevant — we want to warn about today.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 7)),
None,
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), Some(ymd(2026, 5, 7)), None, now, 30);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_suppressed_when_already_shown_for_this_date() {
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 8)),
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), None, Some(ymd(2026, 5, 8)), now, 30);
assert_eq!(mins, None);
}
@@ -553,13 +600,8 @@ mod tests {
// Player kept the app open across a midnight rollover. Stale
// "shown" date doesn't suppress today's warning.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 7)),
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), None, Some(ymd(2026, 5, 7)), now, 30);
assert_eq!(mins, Some(10));
}
@@ -578,9 +620,7 @@ mod tests {
let today = app.world().resource::<DailyChallengeResource>().date;
// Pre-mark warning as already shown for today.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
app.world_mut().resource_mut::<DailyExpiryWarningShown>().0 = Some(today);
// Flush any stale events from headless_app()'s initial update (the
// double-buffer keeps them visible for one extra frame).
app.update();
@@ -596,9 +636,7 @@ mod tests {
);
// Reset shown, mark today as completed.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = None;
app.world_mut().resource_mut::<DailyExpiryWarningShown>().0 = None;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
+5 -5
View File
@@ -74,10 +74,7 @@ impl Plugin for DifficultyPlugin {
app.init_resource::<DifficultyIndexResource>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(
Update,
handle_difficulty_request.before(GameMutation),
);
.add_systems(Update, handle_difficulty_request.before(GameMutation));
}
}
@@ -210,7 +207,10 @@ mod tests {
let events = drain_new_game_events(&mut app);
assert_eq!(events.len(), 1);
assert!(events[0].seed.is_some(), "Random should always produce Some(seed)");
assert!(
events[0].seed.is_some(),
"Random should always produce Some(seed)"
);
assert_eq!(
events[0].mode,
Some(GameMode::Difficulty(DifficultyLevel::Random))
+24 -10
View File
@@ -244,7 +244,9 @@ fn start_shake_anim(
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
let Some(pile) = game.0.piles.get(dest_pile) else {
continue;
};
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
if dest_card_ids.is_empty() {
@@ -395,7 +397,9 @@ fn start_deal_anim(
return;
}
let Some(layout) = layout else { return };
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { return };
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
return;
};
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
let speed = settings.as_ref().map(|s| &s.0.animation_speed);
@@ -501,7 +505,12 @@ fn start_foundation_flourish(
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut pile_markers: Query<(
Entity,
&PileMarker,
&Sprite,
Option<&FoundationMarkerFlourish>,
)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
@@ -767,7 +776,8 @@ mod tests {
"flourish scale at t=0 must be 1.0"
);
assert!(
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs()
< 1e-5,
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
);
assert!(
@@ -848,10 +858,8 @@ mod tests {
// Spawn a minimal CardEntity matching that id so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut().spawn((
CardEntity { card_id },
Transform::default(),
));
app.world_mut()
.spawn((CardEntity { card_id }, Transform::default()));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
@@ -867,7 +875,10 @@ mod tests {
.query::<&ShakeAnim>()
.iter(app.world())
.count();
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
assert_eq!(
shake_count, 0,
"ShakeAnim must not be inserted under reduce-motion"
);
}
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
@@ -901,6 +912,9 @@ mod tests {
.query::<&FoundationFlourish>()
.iter(app.world())
.count();
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
assert_eq!(
flourish_count, 0,
"FoundationFlourish must not be inserted under reduce-motion"
);
}
}
File diff suppressed because it is too large Load Diff
+178 -48
View File
@@ -13,12 +13,12 @@ use crate::font_plugin::FontResource;
use crate::hud_plugin::ANDROID_HINT_LABEL;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalScrim, ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Marker on the help overlay root node.
@@ -145,26 +145,56 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Touch",
rows: &[
ControlRow { keys: "Tap stock", description: "Draw from stock" },
ControlRow { keys: "Drag card", description: "Move cards between piles" },
ControlRow { keys: "Tap foundation area", description: "Auto-move top card to foundation" },
ControlRow {
keys: "Tap stock",
description: "Draw from stock",
},
ControlRow {
keys: "Drag card",
description: "Move cards between piles",
},
ControlRow {
keys: "Tap foundation area",
description: "Auto-move top card to foundation",
},
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "New+", description: "Start a new Classic game" },
ControlRow { keys: "Modes↓", description: "Pick Daily, Zen, Challenge, or Time Attack" },
ControlRow {
keys: "New+",
description: "Start a new Classic game",
},
ControlRow {
keys: "Modes↓",
description: "Pick Daily, Zen, Challenge, or Time Attack",
},
],
},
ControlSection {
title: "HUD buttons",
rows: &[
ControlRow { keys: "", description: "Undo last move" },
ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: ANDROID_HINT_LABEL, description: "Show a hint" },
ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
ControlRow {
keys: "",
description: "Undo last move",
},
ControlRow {
keys: "||",
description: "Pause / resume",
},
ControlRow {
keys: "?",
description: "This help screen",
},
ControlRow {
keys: ANDROID_HINT_LABEL,
description: "Show a hint",
},
ControlRow {
keys: "",
description: "Open menu (Stats, Settings, Profile...)",
},
],
},
];
@@ -174,17 +204,35 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Gameplay",
rows: &[
ControlRow { keys: "Drag", description: "Move cards between piles" },
ControlRow { keys: "D / Space", description: "Draw from stock" },
ControlRow { keys: "U", description: "Undo last move" },
ControlRow { keys: "Click stock", description: "Draw" },
ControlRow {
keys: "Drag",
description: "Move cards between piles",
},
ControlRow {
keys: "D / Space",
description: "Draw from stock",
},
ControlRow {
keys: "U",
description: "Undo last move",
},
ControlRow {
keys: "Click stock",
description: "Draw",
},
],
},
ControlSection {
title: "Mouse",
rows: &[
ControlRow { keys: "Double-click", description: "Auto-move card to its best destination" },
ControlRow { keys: "Right-click", description: "Highlight legal destinations briefly" },
ControlRow {
keys: "Double-click",
description: "Auto-move card to its best destination",
},
ControlRow {
keys: "Right-click",
description: "Highlight legal destinations briefly",
},
ControlRow {
keys: "Hold RMB",
description: "Open radial menu — release over an icon to quick-drop",
@@ -194,48 +242,129 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Keyboard drag",
rows: &[
ControlRow { keys: "Tab", description: "Focus next draggable card" },
ControlRow { keys: "Enter", description: "Lift focused card (then arrows pick where)" },
ControlRow { keys: "Arrows / Tab", description: "Cycle legal destinations while lifted" },
ControlRow { keys: "Enter", description: "Drop the lifted cards on the focused pile" },
ControlRow { keys: "Esc", description: "Cancel lift (Esc again clears focus)" },
ControlRow { keys: "Space", description: "Auto-move focused card (foundation first)" },
ControlRow {
keys: "Tab",
description: "Focus next draggable card",
},
ControlRow {
keys: "Enter",
description: "Lift focused card (then arrows pick where)",
},
ControlRow {
keys: "Arrows / Tab",
description: "Cycle legal destinations while lifted",
},
ControlRow {
keys: "Enter",
description: "Drop the lifted cards on the focused pile",
},
ControlRow {
keys: "Esc",
description: "Cancel lift (Esc again clears focus)",
},
ControlRow {
keys: "Space",
description: "Auto-move focused card (foundation first)",
},
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "N", description: "New Classic game (N twice if in progress)" },
ControlRow { keys: "C", description: "Start today's daily challenge" },
ControlRow { keys: "Z", description: "Start a Zen game (level 5+)" },
ControlRow { keys: "X", description: "Start the next Challenge (level 5+)" },
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
ControlRow {
keys: "N",
description: "New Classic game (N twice if in progress)",
},
ControlRow {
keys: "C",
description: "Start today's daily challenge",
},
ControlRow {
keys: "Z",
description: "Start a Zen game (level 5+)",
},
ControlRow {
keys: "X",
description: "Start the next Challenge (level 5+)",
},
ControlRow {
keys: "T",
description: "Start a Time Attack session (level 5+)",
},
],
},
ControlSection {
title: "Mode Launcher (M)",
rows: &[
ControlRow { keys: "1", description: "Launch Classic" },
ControlRow { keys: "2", description: "Launch Daily Challenge" },
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
ControlRow {
keys: "1",
description: "Launch Classic",
},
ControlRow {
keys: "2",
description: "Launch Daily Challenge",
},
ControlRow {
keys: "3",
description: "Launch Zen (level 5+)",
},
ControlRow {
keys: "4",
description: "Launch Challenge (level 5+)",
},
ControlRow {
keys: "5",
description: "Launch Time Attack (level 5+)",
},
],
},
ControlSection {
title: "Overlays",
rows: &[
ControlRow { keys: "M", description: "Mode launcher (Home)" },
ControlRow { keys: "P", description: "Profile" },
ControlRow { keys: "S", description: "Stats & progression" },
ControlRow { keys: "A", description: "Achievements" },
ControlRow { keys: "L", description: "Leaderboard" },
ControlRow { keys: "O", description: "Settings" },
ControlRow { keys: "F1", description: "This help screen" },
ControlRow { keys: "F11", description: "Toggle fullscreen" },
ControlRow { keys: "Esc", description: "Pause / resume" },
ControlRow { keys: "[ / ]", description: "SFX volume down / up" },
ControlRow { keys: "Enter", description: "Play Again (on the Win Summary)" },
ControlRow {
keys: "M",
description: "Mode launcher (Home)",
},
ControlRow {
keys: "P",
description: "Profile",
},
ControlRow {
keys: "S",
description: "Stats & progression",
},
ControlRow {
keys: "A",
description: "Achievements",
},
ControlRow {
keys: "L",
description: "Leaderboard",
},
ControlRow {
keys: "O",
description: "Settings",
},
ControlRow {
keys: "F1",
description: "This help screen",
},
ControlRow {
keys: "F11",
description: "Toggle fullscreen",
},
ControlRow {
keys: "Esc",
description: "Pause / resume",
},
ControlRow {
keys: "[ / ]",
description: "SFX volume down / up",
},
ControlRow {
keys: "Enter",
description: "Play Again (on the Win Summary)",
},
],
},
];
@@ -315,7 +444,8 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
});
}
line.spawn(( Text::new(row.description),
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
+63 -52
View File
@@ -13,8 +13,8 @@
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
//! or close the overlay.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_data::save_settings_to;
@@ -28,15 +28,12 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::{
SettingsChangedEvent, SettingsResource, SettingsStoragePath,
};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalButton,
ScrimDismissible,
ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
@@ -189,7 +186,10 @@ impl HomeMode {
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
fn requires_unlock(self) -> bool {
matches!(self, HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack)
matches!(
self,
HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack
)
}
/// `true` if the player at `level` is allowed to launch the mode.
@@ -342,7 +342,10 @@ fn spawn_home_on_launch(
}
// Pre-expand the difficulty section when the player has a saved preference.
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
if settings
.as_ref()
.is_some_and(|s| s.0.last_difficulty.is_some())
{
diff_expanded.0 = true;
}
@@ -429,9 +432,7 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings
.map(|s| s.0.draw_mode)
.unwrap_or(DrawMode::DrawOne),
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
@@ -1113,8 +1114,16 @@ fn spawn_draw_mode_chip<M: Component>(
/// update without Visibility component surgery.
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
let font_label = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_chip = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
@@ -1184,11 +1193,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|c| {
c.spawn((
Text::new(level.label()),
font_chip.clone(),
TextColor(fg),
));
c.spawn((Text::new(level.label()), font_chip.clone(), TextColor(fg)));
});
}
});
@@ -1223,12 +1228,11 @@ fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option<String>
HomeMode::Zen if ctx.zen_best > 0 => {
Some(format!("Best {}", format_compact(ctx.zen_best as u64)))
}
HomeMode::Challenge if ctx.challenge_best > 0 => {
Some(format!("Best {}", format_compact(ctx.challenge_best as u64)))
}
HomeMode::Daily if ctx.daily_streak > 0 => {
Some(format!("Streak {}", ctx.daily_streak))
}
HomeMode::Challenge if ctx.challenge_best > 0 => Some(format!(
"Best {}",
format_compact(ctx.challenge_best as u64)
)),
HomeMode::Daily if ctx.daily_streak > 0 => Some(format!("Streak {}", ctx.daily_streak)),
_ => None,
}
}
@@ -1302,11 +1306,7 @@ fn attach_focusable_to_home_mode_cards(
/// feedback is supplied by `paint_modal_buttons` via the `ModalButton`
/// component, which we attach with `ButtonVariant::Secondary` so the card
/// reads as a standard interactive surface.
fn spawn_mode_card(
parent: &mut ChildSpawnerCommands,
mode: HomeMode,
ctx: &HomeContext<'_>,
) {
fn spawn_mode_card(parent: &mut ChildSpawnerCommands, mode: HomeMode, ctx: &HomeContext<'_>) {
let level = ctx.level;
let font_res = ctx.font_res;
let score_chip = score_chip_text_for(mode, ctx);
@@ -1338,10 +1338,26 @@ fn spawn_mode_card(
// Locked cards mute their text to communicate the disabled state at
// a glance; the explicit "Unlocks at level N" caption underneath
// backs that up with copy.
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED };
let title_color = if unlocked {
TEXT_PRIMARY
} else {
TEXT_DISABLED
};
let desc_color = if unlocked {
TEXT_SECONDARY
} else {
TEXT_DISABLED
};
let border_color = if unlocked {
BORDER_SUBTLE
} else {
BORDER_STRONG
};
let glyph_color = if unlocked {
ACCENT_PRIMARY
} else {
TEXT_DISABLED
};
parent
.spawn((
@@ -1489,9 +1505,7 @@ fn spawn_mode_card(
// Locked footnote — explicit copy so the gate is unambiguous.
if !unlocked {
c.spawn((
Text::new(format!(
"Unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)),
Text::new(format!("Unlocks at level {CHALLENGE_UNLOCK_LEVEL}")),
TextFont {
font: font_desc.font.clone(),
font_size: TYPE_CAPTION,
@@ -1734,10 +1748,7 @@ mod tests {
fn unlocked_zen_click_fires_start_zen_event_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
@@ -1991,10 +2002,7 @@ mod tests {
let mut app = headless_app();
// Bump the player to the unlock level *before* opening the modal
// so the Mode Launcher is in its unlocked state.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
@@ -2026,10 +2034,7 @@ mod tests {
let mut app = headless_app();
// Modal is NOT open. Bump level so Zen would otherwise be allowed
// — this isolates the modal-scope guard from the unlock check.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
// Drain any pre-existing events.
app.world_mut()
@@ -2071,19 +2076,25 @@ mod tests {
zc.read(zen).next().is_none(),
"Digit keys with no modal open must not fire StartZenRequestEvent"
);
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
let chal = app
.world()
.resource::<Messages<StartChallengeRequestEvent>>();
let mut cc = chal.get_cursor();
assert!(
cc.read(chal).next().is_none(),
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
);
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
let ta = app
.world()
.resource::<Messages<StartTimeAttackRequestEvent>>();
let mut tc = ta.get_cursor();
assert!(
tc.read(ta).next().is_none(),
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
);
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
let daily = app
.world()
.resource::<Messages<StartDailyChallengeRequestEvent>>();
let mut dc = daily.get_cursor();
assert!(
dc.read(daily).next().is_none(),
+182 -86
View File
@@ -14,21 +14,8 @@ use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::avatar_plugin::AvatarResource;
use solitaire_data::SyncBackend;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
@@ -40,18 +27,32 @@ use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::{DragState, GameInputConsumedResource};
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::selection_plugin::SelectionState;
use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
scaled_duration,
};
use crate::ui_tooltip::Tooltip;
use solitaire_data::SyncBackend;
/// Marker on the score text node.
#[derive(Component, Debug)]
@@ -310,9 +311,25 @@ pub struct HintButton;
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
#[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = ["\u{2261}", "\u{2190}", "||", "?", ANDROID_HINT_LABEL, "M", "+"];
const ACTION_BAR_LABELS: [&str; 7] = [
"\u{2261}",
"\u{2190}",
"||",
"?",
ANDROID_HINT_LABEL,
"M",
"+",
];
#[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = ["Menu \u{2193}", "Undo", "Pause", "Help", "Hint", "Modes \u{2193}", "New Game"];
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
#[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))]
@@ -564,10 +581,7 @@ fn spawn_hud_band(mut commands: Commands) {
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
fn spawn_hud(
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
@@ -637,9 +651,7 @@ fn spawn_hud(
));
t1.spawn((
HudMoves,
Tooltip::new(
"Moves you've made this game. Counts placements and stock draws.",
),
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
@@ -680,9 +692,7 @@ fn spawn_hud(
));
t2.spawn((
HudWonPreviously,
Tooltip::new(
"You've won this deal before. Same seed in your replay history.",
),
Tooltip::new("You've won this deal before. Same seed in your replay history."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
@@ -695,9 +705,7 @@ fn spawn_hud(
hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Tooltip::new(
"Undos used this game. Any undo blocks the No Undo achievement.",
),
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
@@ -874,7 +882,8 @@ fn spawn_action_buttons(
windows: Query<&Window>,
mut commands: Commands,
) {
let action_font_size = action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
let action_font_size =
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: action_font_size,
@@ -915,13 +924,76 @@ fn spawn_action_buttons(
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, MenuButton, ACTION_BAR_LABELS[0], None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, UndoButton, ACTION_BAR_LABELS[1], Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
spawn_action_button(row, PauseButton, ACTION_BAR_LABELS[2], Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, HelpButton, ACTION_BAR_LABELS[3], Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
spawn_action_button(row, HintButton, ACTION_BAR_LABELS[4], Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
spawn_action_button(row, ModesButton, ACTION_BAR_LABELS[5], None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
spawn_action_button(row, NewGameButton, ACTION_BAR_LABELS[6], Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
spawn_action_button(
row,
MenuButton,
ACTION_BAR_LABELS[0],
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
ACTION_BAR_LABELS[1],
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
ACTION_BAR_LABELS[2],
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
ACTION_BAR_LABELS[3],
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
ACTION_BAR_LABELS[4],
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
&font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
ACTION_BAR_LABELS[5],
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
ACTION_BAR_LABELS[6],
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
6,
TEXT_SECONDARY,
);
});
}
@@ -952,7 +1024,11 @@ fn spawn_action_button<M: Component>(
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. The chevrons on
// Menu/Modes remain because they indicate dropdown behaviour.
let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None };
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
hotkey
} else {
None
};
let hotkey_font = TextFont {
font: font.font.clone(),
@@ -1082,9 +1158,7 @@ fn handle_modes_button(
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query
.iter()
.any(|i| *i == Interaction::Pressed);
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
@@ -1261,9 +1335,7 @@ fn handle_mode_option_click(
}
}
}
if clicked_any
&& let Ok(entity) = popovers.single()
{
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
@@ -1282,9 +1354,7 @@ fn handle_menu_button(
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query
.iter()
.any(|i| *i == Interaction::Pressed);
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
@@ -1462,13 +1532,12 @@ fn handle_menu_option_click(
}
}
}
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
@@ -1587,11 +1656,7 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
let Ok(window) = windows.single() else {
return;
};
@@ -2022,12 +2087,14 @@ fn update_won_previously(
let won_before = !game.0.is_won
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed
&& r.draw_mode == game.0.draw_mode
&& r.mode == game.0.mode
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode
})
});
let next = if won_before { "\u{2713} Won before" } else { "" };
let next = if won_before {
"\u{2713} Won before"
} else {
""
};
if text.0 != next {
text.0 = next.to_string();
}
@@ -2290,13 +2357,14 @@ fn update_hud(
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if (ac_changed || game.is_changed())
&& let Ok(mut t) = auto_q.single_mut() {
**t = if ac_active {
"AUTO".to_string()
} else {
String::new()
};
}
&& let Ok(mut t) = auto_q.single_mut()
{
**t = if ac_active {
"AUTO".to_string()
} else {
String::new()
};
}
}
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
@@ -2480,9 +2548,17 @@ fn action_bar_font_size(window_width: f32) -> f32 {
fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT {
(UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0))
(
UiRect::axes(Val::Px(4.0), Val::Px(4.0)),
Val::Px(52.0),
Val::Px(44.0),
)
} else {
(UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0))
(
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
Val::Px(48.0),
Val::Px(48.0),
)
}
}
@@ -2493,7 +2569,12 @@ fn spawn_action_button_label(
text_color: Color,
) {
if USE_TOUCH_UI_LAYOUT {
parent.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
parent.spawn((
ActionButtonLabel,
Text::new(label),
font.clone(),
TextColor(text_color),
));
} else {
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
}
@@ -2508,7 +2589,10 @@ fn resize_action_bar_labels(
windows: Query<&Window>,
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
) {
let w = windows.iter().next().map_or(layout.0.card_size.x * 7.25, |win| win.width());
let w = windows
.iter()
.next()
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
let new_size = action_bar_font_size(w);
for mut font in &mut labels {
font.font_size = new_size;
@@ -2545,8 +2629,7 @@ fn toggle_hud_on_tap(
// Record whether the finger-down landed on a button so
// the finger-up doesn't double-fire (toggle bar + press
// button at the same time).
tracker.started_on_button =
buttons.iter().any(|i| *i != Interaction::None);
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
}
TouchPhase::Ended if drag.is_idle() => {
// Also treat taps where game logic consumed the touch (e.g.
@@ -2630,7 +2713,10 @@ mod tests {
#[test]
fn moves_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 42;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 42;
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
@@ -2660,7 +2746,10 @@ mod tests {
#[test]
fn time_display_uses_mm_ss_format() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.elapsed_seconds = 125;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.elapsed_seconds = 125;
app.update();
// 125 seconds = 2 minutes 5 seconds → "2:05"
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
@@ -2834,7 +2923,10 @@ mod tests {
#[test]
fn undos_hud_shows_count_after_undo() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.undo_count = 3;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 3;
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
@@ -2859,7 +2951,10 @@ mod tests {
let mut app = headless_app_with_auto_complete();
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
// Also trigger game state change so the update fires.
app.world_mut().resource_mut::<GameStateResource>().0.move_count += 1;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
@@ -2868,7 +2963,10 @@ mod tests {
fn auto_complete_badge_empty_when_inactive() {
let mut app = headless_app_with_auto_complete();
// active is false by default.
app.world_mut().resource_mut::<GameStateResource>().0.move_count += 1;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
}
@@ -2928,9 +3026,9 @@ mod tests {
fn set_manual_time_step(app: &mut App, secs: f32) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// Counts entities matching component `M` currently in the world.
@@ -3130,9 +3228,7 @@ mod tests {
/// which is the invariant we want to enforce for HUD readouts and
/// action buttons (each marker is spawned exactly once).
fn tooltip_for<M: Component>(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Tooltip, With<M>>();
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
let world = app.world();
let mut iter = q.iter(world);
let first = iter
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -183,7 +183,12 @@ pub struct Layout {
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations.
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, hud_visible: bool) -> Layout {
pub fn compute_layout(
window: Vec2,
safe_area_top: f32,
safe_area_bottom: f32,
hud_visible: bool,
) -> Layout {
let window = window.max(MIN_WINDOW);
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
@@ -213,7 +218,8 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = (window.y - safe_area_top - effective_safe_bottom - band_h).max(0.0) / height_denom;
let card_width_height_based =
(window.y - safe_area_top - effective_safe_bottom - band_h).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT;
@@ -262,7 +268,8 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
let avail = (tableau_y - (-window.y / 2.0 + effective_safe_bottom + h_gap) - card_height / 2.0).max(0.0);
let avail = (tableau_y - (-window.y / 2.0 + effective_safe_bottom + h_gap) - card_height / 2.0)
.max(0.0);
let ideal_fan_frac = if card_height > 0.0 {
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
} else {
@@ -298,7 +305,9 @@ mod tests {
assert!(layout.pile_positions.contains_key(&PileType::Waste));
for slot in 0..4_u8 {
assert!(
layout.pile_positions.contains_key(&PileType::Foundation(slot)),
layout
.pile_positions
.contains_key(&PileType::Foundation(slot)),
"missing foundation slot {slot}",
);
}
+105 -51
View File
@@ -9,9 +9,13 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately.
use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}};
use bevy::input::{
ButtonState,
keyboard::KeyboardInput,
mouse::{MouseScrollUnit, MouseWheel},
};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_data::{save_settings_to, settings::SyncBackend};
use solitaire_sync::LeaderboardEntry;
@@ -20,13 +24,13 @@ use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalScrim, ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
};
// ---------------------------------------------------------------------------
@@ -208,18 +212,30 @@ fn toggle_leaderboard_screen(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
let dn = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(
&mut commands,
&data,
remote_available,
dn,
font_res.as_deref(),
);
// Start a background fetch if not already in flight.
if task_res.0.is_none()
&& let Some(p) = provider {
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.fetch_leaderboard().await.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
&& let Some(p) = provider
{
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.fetch_leaderboard()
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
/// Poll the background fetch task; store results when complete.
@@ -227,8 +243,12 @@ fn poll_leaderboard_fetch(
mut task_res: ResMut<LeaderboardFetchTask>,
mut result_res: ResMut<LeaderboardFetchResult>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
result_res.0 = Some(result);
}
@@ -247,7 +267,9 @@ fn update_leaderboard_panel(
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
let Some(result) = result_res.0.take() else { return };
let Some(result) = result_res.0.take() else {
return;
};
match result {
Ok(entries) => {
@@ -272,10 +294,18 @@ fn update_leaderboard_panel(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
let dn = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref());
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
spawn_leaderboard_screen(
&mut commands,
&data,
remote_available,
dn,
font_res.as_deref(),
);
}
}
@@ -358,8 +388,12 @@ fn handle_opt_in_button(
.unwrap_or_else(|| "Player".to_string());
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_in_leaderboard(&display_name).await.map_err(|e| e.to_string()) });
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_in_leaderboard(&display_name)
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
@@ -372,8 +406,12 @@ fn poll_opt_in_task(
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
match result {
Ok(()) => {
@@ -409,8 +447,12 @@ fn handle_opt_out_button(
continue;
}
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_out_leaderboard().await.map_err(|e| e.to_string()) });
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_out_leaderboard()
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
@@ -423,8 +465,12 @@ fn poll_opt_out_task(
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
match result {
Ok(()) => {
@@ -941,7 +987,10 @@ fn update_leaderboard_public_name_label(
if labels.is_empty() {
return;
}
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
let new_label = match settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref())
{
Some(n) => format!("Public name: {n}"),
None => "Public name: (same as username)".to_string(),
};
@@ -974,14 +1023,14 @@ fn format_secs(secs: u64) -> String {
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use crate::sync_plugin::SyncPlugin;
use solitaire_data::SyncError;
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::table_plugin::TablePlugin;
use chrono::Utc;
use uuid::Uuid;
use solitaire_sync::PlayerProgress;
use solitaire_data::StatsSnapshot;
use solitaire_data::SyncError;
use solitaire_sync::PlayerProgress;
use solitaire_sync::{SyncPayload, SyncResponse};
use uuid::Uuid;
struct NoOpProvider;
@@ -1009,18 +1058,20 @@ mod tests {
conflicts: vec![],
})
}
fn backend_name(&self) -> &'static str { "no-op" }
fn is_authenticated(&self) -> bool { false }
fn backend_name(&self) -> &'static str {
"no-op"
}
fn is_authenticated(&self) -> bool {
false
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Ok(vec![
LeaderboardEntry {
display_name: "Alice".to_string(),
best_score: Some(5000),
best_time_secs: Some(180),
recorded_at: Utc::now(),
},
])
Ok(vec![LeaderboardEntry {
display_name: "Alice".to_string(),
best_score: Some(5000),
best_time_secs: Some(180),
recorded_at: Utc::now(),
}])
}
}
@@ -1148,7 +1199,9 @@ mod tests {
fn headless_app_with_settings() -> App {
let mut app = headless_app();
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
app.insert_resource(SettingsResource(
solitaire_data::settings::Settings::default(),
));
app
}
@@ -1231,11 +1284,12 @@ mod tests {
let mut app = headless_app_with_settings();
// Confirm the flag starts false.
assert!(!app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in
);
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
+79 -80
View File
@@ -1,46 +1,46 @@
//! Bevy integration layer for Ferrous Solitaire.
#[cfg(target_os = "android")]
pub mod android_clipboard;
pub mod assets;
pub mod card_animation;
pub mod achievement_plugin;
pub mod analytics_plugin;
#[cfg(target_os = "android")]
pub mod android_clipboard;
pub mod animation_plugin;
pub mod avatar_plugin;
pub mod auto_complete_plugin;
pub mod assets;
pub mod audio_plugin;
pub mod auto_complete_plugin;
pub mod avatar_plugin;
pub mod card_animation;
pub mod card_plugin;
pub mod font_plugin;
pub mod feedback_anim_plugin;
pub mod challenge_plugin;
pub mod core_game_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin;
pub mod difficulty_plugin;
pub mod diagnostics_hud;
pub mod difficulty_plugin;
pub mod events;
pub mod core_game_plugin;
pub mod feedback_anim_plugin;
pub mod font_plugin;
pub mod game_plugin;
pub mod help_plugin;
pub mod home_plugin;
pub mod hud_plugin;
pub mod leaderboard_plugin;
pub mod input_plugin;
pub mod layout;
pub mod leaderboard_plugin;
pub mod onboarding_plugin;
pub mod pause_plugin;
pub mod pending_hint;
pub mod play_by_seed_plugin;
pub mod platform;
pub mod play_by_seed_plugin;
pub mod profile_plugin;
pub mod progress_plugin;
pub mod radial_menu;
pub mod replay_overlay;
pub mod replay_playback;
pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
pub mod safe_area;
pub mod selection_plugin;
pub mod settings_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
pub mod sync_plugin;
@@ -55,50 +55,37 @@ pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
pub use assets::{
bundled_theme_url, populate_embedded_dark_theme, register_theme_asset_sources,
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES,
};
pub use theme::{
set_theme, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin,
};
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
};
pub use core_game_plugin::CoreGamePlugin;
pub use daily_challenge_plugin::{
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
};
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
pub use card_animation::{
CardAnimation, CardAnimationPlugin, MotionCurve, WinCascadePlugin,
retarget_animation, sample_curve, compute_duration, cascade_delay, micro_vary,
HoverState, InputBuffer, BufferedInput,
win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS,
MIN_DURATION_SECS, MAX_DURATION_SECS,
AnimationChain,
AnimationTuning, InputPlatform,
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
pub use assets::{
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
populate_embedded_dark_theme, register_theme_asset_sources,
};
pub use feedback_anim_plugin::{
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
ShakeAnim,
};
pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use auto_complete_plugin::AutoCompletePlugin;
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
sample_curve, win_scatter_targets,
};
pub use card_plugin::{
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
RightClickHighlight, RightClickHighlightTimer,
};
pub use font_plugin::{FontPlugin, FontResource};
pub use challenge_plugin::{
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
};
pub use core_game_plugin::CoreGamePlugin;
pub use cursor_plugin::CursorPlugin;
pub use daily_challenge_plugin::{
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
};
pub use diagnostics_hud::DiagnosticsHudPlugin;
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
@@ -107,12 +94,15 @@ pub use events::{
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
WinStreakMilestoneEvent, XpAwardedEvent,
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
XpAwardedEvent,
};
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
pub use platform::{PlatformTime, StorageBackend};
pub use feedback_anim_plugin::{
FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim, ShakeAnim,
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, settle_scale,
shake_offset,
};
pub use font_plugin::{FontPlugin, FontResource};
pub use game_plugin::{
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
ReplayPath,
@@ -120,60 +110,69 @@ pub use game_plugin::{
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen};
pub use hud_plugin::{
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility,
MenuButton, MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton,
PauseButton, StreakFlourish, UndoButton,
ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility, MenuButton, MenuOption,
MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, StreakFlourish,
UndoButton, streak_flourish_scale,
};
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin;
pub use layout::{Layout, LayoutResource, compute_layout};
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use platform::{PlatformTime, StorageBackend};
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use radial_menu::{
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
RadialIcon, RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index,
};
pub use replay_overlay::{
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
ReplayStopButton, Z_REPLAY_OVERLAY,
};
pub use replay_playback::{
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS, ReplayPlaybackPlugin,
ReplayPlaybackState, start_replay_playback, stop_replay_playback,
};
pub use settings_plugin::{
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
pub use resources::{
DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource,
};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{
format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
StatsScreen, StatsUpdate, WatchReplayButton,
pub use settings_plugin::{
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
};
pub use solitaire_data::SyncProvider;
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
StatsUpdate, WatchReplayButton, format_replay_caption,
};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use sync_setup_plugin::SyncSetupPlugin;
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard,
ModalHeader, ModalScrim, UiModalPlugin,
};
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use table_plugin::{
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
};
pub use theme::{
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin, set_theme,
};
pub use time_attack_plugin::{
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
UiModalPlugin, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header,
};
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use win_summary_plugin::{
format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin,
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
};
+98 -34
View File
@@ -23,20 +23,20 @@
use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_data::{save_settings_to, Settings};
use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
#[cfg(not(target_os = "android"))]
use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// ---------------------------------------------------------------------------
// Constants
@@ -101,16 +101,46 @@ struct HotkeyRow {
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
HotkeyRow { keys: "U", description: "Undo last move" },
HotkeyRow { keys: "Tab → Enter", description: "Pick a card; arrows pick where; Enter to drop" },
HotkeyRow { keys: "N", description: "New Classic game" },
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 15 to pick)" },
HotkeyRow { keys: "S", description: "Stats & progression" },
HotkeyRow { keys: "A", description: "Achievements" },
HotkeyRow { keys: "O", description: "Settings" },
HotkeyRow { keys: "Esc", description: "Pause / resume" },
HotkeyRow { keys: "F1", description: "Help / controls" },
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// ---------------------------------------------------------------------------
@@ -126,11 +156,7 @@ impl Plugin for OnboardingPlugin {
.add_systems(PostStartup, spawn_if_first_run)
.add_systems(
Update,
(
handle_onboarding_buttons,
handle_onboarding_keyboard,
)
.chain(),
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
);
}
}
@@ -523,11 +549,16 @@ mod tests {
assert_eq!(current_slide(&app), 0);
// Spawn a Next button with Pressed interaction.
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.update();
assert_eq!(current_slide(&app), 1, "Next must advance to slide 1");
assert_eq!(count_screens(&mut app), 1, "exactly one modal must be visible");
assert_eq!(
count_screens(&mut app),
1,
"exactly one modal must be visible"
);
}
#[test]
@@ -548,10 +579,15 @@ mod tests {
}
}
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.update();
assert_eq!(current_slide(&app), 1, "Back must retreat from slide 2 to slide 1");
assert_eq!(
current_slide(&app),
1,
"Back must retreat from slide 2 to slide 1"
);
}
#[test]
@@ -561,7 +597,8 @@ mod tests {
assert_eq!(current_slide(&app), 0);
// Pressing Back on slide 0 must be a no-op (no underflow to u8::MAX).
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.update();
assert_eq!(current_slide(&app), 0, "Back on slide 0 must not underflow");
@@ -576,15 +613,23 @@ mod tests {
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = SLIDE_COUNT - 1;
// Next on the last slide should complete onboarding, not advance further.
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.update();
// first_run_complete must be set.
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Next on last slide must set first_run_complete"
);
assert_eq!(count_screens(&mut app), 0, "modal must be gone after completion");
assert_eq!(
count_screens(&mut app),
0,
"modal must be gone after completion"
);
}
// -----------------------------------------------------------------------
@@ -596,11 +641,15 @@ mod tests {
let mut app = headless_app();
app.update();
app.world_mut().spawn((OnboardingSkipButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingSkipButton, Button, Interaction::Pressed));
app.update();
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Skip must set first_run_complete"
);
assert_eq!(count_screens(&mut app), 0);
@@ -658,7 +707,10 @@ mod tests {
assert_eq!(count_screens(&mut app), 0, "Esc must dismiss onboarding");
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Esc must set first_run_complete"
);
}
@@ -675,7 +727,10 @@ mod tests {
app.update();
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Enter on last slide must complete onboarding"
);
assert_eq!(count_screens(&mut app), 0);
@@ -694,7 +749,10 @@ mod tests {
#[test]
#[cfg(target_os = "android")]
fn slide_count_constant_is_two_on_android() {
assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)");
assert_eq!(
SLIDE_COUNT, 2,
"SLIDE_COUNT must be 2 on Android (no keyboard slide)"
);
}
#[test]
@@ -727,7 +785,10 @@ mod tests {
app.update();
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"completing the last slide must set first_run_complete"
);
assert_eq!(count_screens(&mut app), 0);
@@ -746,7 +807,10 @@ mod tests {
fn all_hotkey_rows_have_non_empty_fields() {
for row in HOTKEYS {
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
assert!(!row.description.is_empty(), "hotkey description must not be empty");
assert!(
!row.description.is_empty(),
"hotkey description must not be empty"
);
}
}
}
+89 -61
View File
@@ -29,21 +29,21 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameOverScreen, GameStatePath};
use crate::hud_plugin::HudPopoverOpen;
use crate::progress_plugin::ProgressResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
use crate::hud_plugin::HudPopoverOpen;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, ModalScrim,
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
spawn_modal_button, spawn_modal_header,
};
use bevy::ecs::system::SystemParam;
use crate::ui_theme::{
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
};
use bevy::ecs::system::SystemParam;
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
#[derive(Resource, Debug, Default)]
@@ -223,11 +223,12 @@ fn toggle_pause(
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
// back to their resting positions exactly as a rejected drop does.
if let Some(ref mut d) = drag
&& !d.is_idle() {
d.clear();
changed.write(StateChangedEvent);
return;
}
&& !d.is_idle()
{
d.clear();
changed.write(StateChangedEvent);
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
paused.0 = false;
@@ -236,21 +237,16 @@ fn toggle_pause(
let level = progress.as_deref().map(|p| p.0.level);
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
spawn_pause_screen(
&mut commands,
level,
streak,
draw_mode,
font_res.as_deref(),
);
spawn_pause_screen(&mut commands, level, streak, draw_mode, font_res.as_deref());
paused.0 = true;
// Persist the current game state whenever the player opens the pause
// overlay so an OS-level kill still leaves a resumable save.
if let (Some(g), Some(p)) = (game, path)
&& let Some(disk_path) = p.0.as_deref()
&& let Err(e) = save_game_state_to(disk_path, &g.0) {
warn!("game_state: failed to save on pause: {e}");
}
&& let Err(e) = save_game_state_to(disk_path, &g.0)
{
warn!("game_state: failed to save on pause: {e}");
}
}
}
@@ -276,16 +272,21 @@ fn handle_pause_draw_buttons(
return;
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
let new_mode = if pressed_one {
DrawMode::DrawOne
} else {
DrawMode::DrawThree
};
if settings.0.draw_mode == new_mode {
return;
}
settings.0.draw_mode = new_mode;
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode change: {e}");
}
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
{
warn!("failed to save settings after draw-mode change: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
}
@@ -440,7 +441,11 @@ fn close_forfeit_modal(
/// Query filter for modals that are not part of the pause flow.
/// Excludes both `PauseScreen` (the pause modal itself) and
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
type NonPauseFamilyScrim = (
With<ModalScrim>,
Without<PauseScreen>,
Without<ForfeitConfirmScreen>,
);
fn auto_resume_on_overlay(
mut commands: Commands,
@@ -536,13 +541,23 @@ fn spawn_draw_mode_row(
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Draw Mode"),
label_font,
TextColor(TEXT_PRIMARY),
));
spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
row.spawn((Text::new("Draw Mode"), label_font, TextColor(TEXT_PRIMARY)));
spawn_modal_button(
row,
PauseDrawOneButton,
"Draw 1",
None,
one_variant,
font_res,
);
spawn_modal_button(
row,
PauseDrawThreeButton,
"Draw 3",
None,
three_variant,
font_res,
);
});
parent.spawn((
Text::new("Takes effect next game"),
@@ -744,7 +759,10 @@ mod tests {
// Set known values.
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
app.world_mut().resource_mut::<StatsResource>().0.win_streak_current = 3;
app.world_mut()
.resource_mut::<StatsResource>()
.0
.win_streak_current = 3;
press_esc(&mut app);
app.update();
@@ -797,7 +815,10 @@ mod tests {
fn draw_mode_label_covers_all_variants() {
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
let label = draw_mode_label(mode);
assert!(!label.is_empty(), "draw_mode_label must never return an empty string");
assert!(
!label.is_empty(),
"draw_mode_label must never return an empty string"
);
}
}
@@ -827,19 +848,12 @@ mod tests {
app.world_mut().resource_mut::<PausedResource>().0 = true;
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
app.world_mut().spawn((
PauseDrawThreeButton,
Button,
Interaction::Pressed,
));
app.world_mut()
.spawn((PauseDrawThreeButton, Button, Interaction::Pressed));
app.update();
let mode = &app
.world()
.resource::<SettingsResource>()
.0
.draw_mode;
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode,
DrawMode::DrawThree,
@@ -847,19 +861,12 @@ mod tests {
);
// Pressing "Draw 1" while DrawThree is active should switch back.
app.world_mut().spawn((
PauseDrawOneButton,
Button,
Interaction::Pressed,
));
app.world_mut()
.spawn((PauseDrawOneButton, Button, Interaction::Pressed));
app.update();
let mode2 = &app
.world()
.resource::<SettingsResource>()
.0
.draw_mode;
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
assert_eq!(
*mode2,
DrawMode::DrawOne,
@@ -896,8 +903,14 @@ mod tests {
.query::<&PauseForfeitButton>()
.iter(app.world())
.count();
assert_eq!(resume_count, 1, "Resume button must be present on the pause modal");
assert_eq!(forfeit_count, 1, "Forfeit button must be present on the pause modal");
assert_eq!(
resume_count, 1,
"Resume button must be present on the pause modal"
);
assert_eq!(
forfeit_count, 1,
"Forfeit button must be present on the pause modal"
);
}
/// Clicking the Resume button (via Pressed interaction) closes the
@@ -911,20 +924,29 @@ mod tests {
// Mark the Resume button as Pressed.
let resume_entity = {
let mut q = app.world_mut().query_filtered::<Entity, With<PauseResumeButton>>();
q.iter(app.world()).next().expect("Resume button must exist")
let mut q = app
.world_mut()
.query_filtered::<Entity, With<PauseResumeButton>>();
q.iter(app.world())
.next()
.expect("Resume button must exist")
};
app.world_mut()
.entity_mut(resume_entity)
.insert(Interaction::Pressed);
// Clear keys so the simulated "click" isn't competing with a real Esc press.
app.world_mut().resource_mut::<ButtonInput<KeyCode>>().clear();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.update();
// One more frame so the resulting PauseRequestEvent is consumed by toggle_pause.
app.update();
assert!(!app.world().resource::<PausedResource>().0, "Resume must clear PausedResource");
assert!(
!app.world().resource::<PausedResource>().0,
"Resume must clear PausedResource"
);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
@@ -1137,7 +1159,10 @@ mod tests {
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
1
);
@@ -1150,7 +1175,10 @@ mod tests {
"auto_resume_on_overlay must clear PausedResource when another modal opens"
);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
0,
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
);
+26 -24
View File
@@ -25,10 +25,10 @@
//! old state would be confusing.
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::solver::{try_solve_from_state, SolverConfig, SolverResult};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
@@ -101,10 +101,7 @@ struct HintTask {
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove {
from: PileType,
to: PileType,
},
SolverMove { from: PileType, to: PileType },
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists.
@@ -162,15 +159,13 @@ pub fn poll_pending_hint_task(
let (from, to) = match output {
HintTaskOutput::SolverMove { from, to } => (from, to),
HintTaskOutput::NeedsHeuristic => {
match find_heuristic_hint(&g.0, &mut hint_cycle) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
}
},
};
emit_hint_visuals(
&g.0,
@@ -209,11 +204,7 @@ mod tests {
// poll fire before the drop.
app.add_systems(
Update,
(
drop_pending_hint_on_state_change,
poll_pending_hint_task,
)
.chain(),
(drop_pending_hint_on_state_change, poll_pending_hint_task).chain(),
);
app
}
@@ -241,9 +232,18 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen,
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
];
for (slot, suit) in suits.iter().enumerate() {
let pile = game
@@ -304,7 +304,8 @@ mod tests {
let mut cursor = messages.get_cursor();
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
assert_eq!(
collected.len(), 1,
collected.len(),
1,
"exactly one HintVisualEvent must fire when the solver returns Winnable",
);
assert!(
@@ -395,7 +396,8 @@ mod tests {
let mut cursor = messages.get_cursor();
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
assert_eq!(
collected.len(), 1,
collected.len(),
1,
"cancel-on-replace: only the surviving task's result emits a visual",
);
}
+42 -16
View File
@@ -22,17 +22,17 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::settings_plugin::SettingsResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal_header,
ButtonVariant, ScrimDismissible,
ButtonVariant, ScrimDismissible, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_PRESSED, BORDER_SUBTLE, HighContrastBorder, RADIUS_MD,
@@ -341,8 +341,7 @@ fn tick_debounce_and_spawn_solver_task(
.as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default();
let task = AsyncComputeTaskPool::get()
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) });
pending.seed = Some(seed);
pending.handle = Some(task);
@@ -407,7 +406,9 @@ fn handle_confirm(
}
let Ok(buf) = buffers.single() else { return };
let Ok(seed) = buf.text.parse::<u64>() else { return };
let Ok(seed) = buf.text.parse::<u64>() else {
return;
};
new_game.write(NewGameRequestEvent {
seed: Some(seed),
@@ -470,8 +471,7 @@ mod tests {
}
fn open_dialog(app: &mut App) {
app.world_mut()
.write_message(StartPlayBySeedRequestEvent);
app.world_mut().write_message(StartPlayBySeedRequestEvent);
app.update();
}
@@ -547,7 +547,10 @@ mod tests {
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = msgs.get_cursor();
assert!(cursor.read(msgs).next().is_none(), "no NewGameRequestEvent when buffer empty");
assert!(
cursor.read(msgs).next().is_none(),
"no NewGameRequestEvent when buffer empty"
);
// Dialog should still be open.
assert!(dialog_present(&mut app));
}
@@ -607,7 +610,10 @@ mod tests {
}
let pending = app.world().resource::<PendingVerification>();
assert!(pending.handle.is_some(), "solver task should have been spawned after debounce");
assert!(
pending.handle.is_some(),
"solver task should have been spawned after debounce"
);
assert_eq!(pending.seed, Some(42));
}
@@ -623,11 +629,21 @@ mod tests {
for _ in 0..DEBOUNCE_FRAMES {
app.update();
}
assert!(app.world().resource::<PendingVerification>().handle.is_some());
assert!(
app.world()
.resource::<PendingVerification>()
.handle
.is_some()
);
// New keypress should cancel the in-flight task.
press_key(&mut app, KeyCode::Digit3);
assert!(app.world().resource::<PendingVerification>().handle.is_none());
assert!(
app.world()
.resource::<PendingVerification>()
.handle
.is_none()
);
assert_eq!(app.world().resource::<PendingVerification>().seed, None);
}
@@ -649,7 +665,11 @@ mod tests {
// Poll until the solver task resolves (cap at 15 s wall-clock).
let deadline = Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingVerification>().handle.is_some()
while app
.world()
.resource::<PendingVerification>()
.handle
.is_some()
&& Instant::now() < deadline
{
app.update();
@@ -664,7 +684,13 @@ mod tests {
.next()
.map(|(t, _)| t.0.clone())
.unwrap_or_default();
assert_ne!(badge_text, "Verifying\u{2026}", "badge should have resolved to a verdict");
assert_ne!(badge_text, "Type a number", "badge should show verdict, not idle state");
assert_ne!(
badge_text, "Verifying\u{2026}",
"badge should have resolved to a verdict"
);
assert_ne!(
badge_text, "Type a number",
"badge should show verdict, not idle state"
);
}
}
+17 -14
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_data::{
load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress,
PlayerProgress, load_progress_from, progress_file_path, save_progress_to, xp_for_win,
};
use crate::events::{GameWonEvent, XpAwardedEvent};
@@ -74,9 +74,7 @@ impl Plugin for ProgressPlugin {
.add_message::<GameWonEvent>()
.add_systems(
Update,
award_xp_on_win
.after(GameMutation)
.in_set(ProgressUpdate),
award_xp_on_win.after(GameMutation).in_set(ProgressUpdate),
);
}
}
@@ -102,9 +100,10 @@ fn award_xp_on_win(
});
}
if let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress: {e}");
}
}
}
@@ -183,7 +182,10 @@ mod tests {
fn crossing_500_xp_fires_levelup_event() {
let mut app = headless_app();
// Pre-load 480 XP so a 75-XP win pushes us over the 500 boundary.
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.total_xp = 480;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -233,7 +235,10 @@ mod tests {
#[test]
fn levelup_event_total_xp_matches_progress_resource() {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.total_xp = 480;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -255,13 +260,11 @@ mod tests {
// score=0 in the event (Zen keeps score at 0), time=300 (no speed bonus),
// undo_count=0 so no-undo bonus applies: expected 50+25=75.
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0, // Zen mode keeps score at 0
score: 0, // Zen mode keeps score at 0
time_seconds: 300,
});
app.update();
+86 -23
View File
@@ -42,8 +42,8 @@
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
//! neither.
use bevy::input::touch::Touches;
use bevy::input::ButtonInput;
use bevy::input::touch::Touches;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
@@ -58,7 +58,9 @@ use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS,
};
/// Seconds a finger must be held on a face-up card (without crossing the
/// drag threshold) before the radial menu opens. Matches Android's long-press
@@ -219,7 +221,10 @@ pub fn radial_anchor_for_index(centre: Vec2, count: usize, index: usize, radius:
// index 0 sits at 12 o'clock and increasing indices sweep right.
let frac = (index as f32) / (count as f32);
let angle = std::f32::consts::TAU * frac;
Vec2::new(centre.x + radius * angle.sin(), centre.y + radius * angle.cos())
Vec2::new(
centre.x + radius * angle.sin(),
centre.y + radius * angle.cos(),
)
}
/// Returns `(hit?, index)` — whether `cursor` falls within any icon's
@@ -363,7 +368,12 @@ fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileTyp
dests
.into_iter()
.enumerate()
.map(|(i, d)| (d, radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX)))
.map(|(i, d)| {
(
d,
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
)
})
.collect()
}
@@ -493,7 +503,9 @@ fn radial_open_on_long_press(
let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
return;
};
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
let Some((camera, cam_xf)) = cameras.single().ok() else {
return;
};
let Some(world) = camera.viewport_to_world_2d(cam_xf, touch.position()).ok() else {
return;
};
@@ -668,7 +680,11 @@ fn radial_redraw_overlay(
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
let focused = *hovered_index == Some(i);
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
let fill = if focused {
STATE_SUCCESS
} else {
ACCENT_PRIMARY
};
let outline = radial_rim_outline(focused, high_contrast);
commands
@@ -758,10 +774,18 @@ mod tests {
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
g.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
// Ace of Clubs on Tableau(0).
g.piles
@@ -784,10 +808,18 @@ mod tests {
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
g.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
g.piles
.get_mut(&PileType::Tableau(0))
@@ -804,7 +836,12 @@ mod tests {
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
app.insert_resource(GameStateResource(state));
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0, true)));
app.insert_resource(LayoutResource(compute_layout(
layout_window,
0.0,
0.0,
true,
)));
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
}
@@ -867,13 +904,19 @@ mod tests {
fn radial_hovered_index_inside_box_returns_index() {
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
// Cursor squarely inside icon 1's box.
assert_eq!(radial_hovered_index(Vec2::new(0.0, 100.0), &anchors), Some(1));
assert_eq!(
radial_hovered_index(Vec2::new(0.0, 100.0), &anchors),
Some(1)
);
}
#[test]
fn radial_hovered_index_outside_returns_none() {
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
assert_eq!(radial_hovered_index(Vec2::new(500.0, 500.0), &anchors), None);
assert_eq!(
radial_hovered_index(Vec2::new(500.0, 500.0), &anchors),
None
);
}
#[test]
@@ -888,7 +931,10 @@ mod tests {
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
// Ace can be placed on every empty foundation. We only need
// the count to be ≥ 1 and the source pile to be excluded.
assert!(!dests.is_empty(), "Ace must have at least one legal destination");
assert!(
!dests.is_empty(),
"Ace must have at least one legal destination"
);
assert!(!dests.contains(&PileType::Tableau(0)));
}
@@ -921,7 +967,10 @@ mod tests {
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
// Initial state — Idle.
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
assert_eq!(
*app.world().resource::<RightClickRadialState>(),
RightClickRadialState::Idle
);
press(&mut app, MouseButton::Right);
app.update();
@@ -939,9 +988,11 @@ mod tests {
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(!legal_destinations.is_empty());
assert!(legal_destinations
.iter()
.any(|(p, _)| matches!(p, PileType::Foundation(_))));
assert!(
legal_destinations
.iter()
.any(|(p, _)| matches!(p, PileType::Foundation(_)))
);
}
other => panic!("expected Active, got {other:?}"),
}
@@ -962,7 +1013,9 @@ mod tests {
// Capture the destination chosen — pull anchor[0] from the state.
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
RightClickRadialState::Active { legal_destinations, .. } => legal_destinations[0].clone(),
RightClickRadialState::Active {
legal_destinations, ..
} => legal_destinations[0].clone(),
_ => panic!("expected Active"),
};
@@ -983,7 +1036,10 @@ mod tests {
assert_eq!(evt.to, dest_pile);
assert_eq!(evt.count, 1);
// State must return to Idle.
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
assert_eq!(
*app.world().resource::<RightClickRadialState>(),
RightClickRadialState::Idle
);
}
/// Releasing the right button far from any icon must clear state
@@ -1001,7 +1057,8 @@ mod tests {
assert!(app.world().resource::<RightClickRadialState>().is_active());
// Move cursor far away — well outside every icon's hit-box.
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(Vec2::new(10_000.0, 10_000.0));
app.world_mut().resource_mut::<RadialCursorOverride>().0 =
Some(Vec2::new(10_000.0, 10_000.0));
app.update();
clear_buttons(&mut app);
@@ -1010,7 +1067,10 @@ mod tests {
let events = collect_move_events(&mut app);
assert!(events.is_empty(), "no MoveRequestEvent on outside-release");
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
assert_eq!(
*app.world().resource::<RightClickRadialState>(),
RightClickRadialState::Idle
);
}
/// Pressing Escape while the radial is active must cancel cleanly,
@@ -1034,7 +1094,10 @@ mod tests {
let events = collect_move_events(&mut app);
assert!(events.is_empty(), "no MoveRequestEvent on Escape cancel");
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
assert_eq!(
*app.world().resource::<RightClickRadialState>(),
RightClickRadialState::Idle
);
}
/// Right-clicking on a face-down card must NOT open the radial.
+65 -70
View File
@@ -26,25 +26,25 @@
use bevy::prelude::*;
use chrono::Datelike;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::font_plugin::FontResource;
use crate::layout::LayoutResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::replay_playback::{
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
toggle_pause_replay_playback, ReplayPlaybackState,
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
stop_replay_playback, toggle_pause_replay_playback,
};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_data::ReplayMove;
use crate::resources::GameStateResource;
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_modal::{ButtonVariant, spawn_modal_button};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_data::ReplayMove;
// ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
@@ -866,9 +866,7 @@ fn spawn_overlay(
..default()
})
.with_children(|row| {
for (i, (label, pct)) in
labels.iter().zip(positions.iter()).enumerate()
{
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
// Endpoints flush to the row's edges; middle
// three labels use the `translateX(-50%)`
// pattern for Bevy 0.18 UI: a fixed-width
@@ -1080,10 +1078,7 @@ fn spawn_overlay(
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
panel.spawn((
ReplayOverlayMoveLogPrevRow { offset },
Text::new(format_kth_recent_row(
state,
offset as usize + 1,
)),
Text::new(format_kth_recent_row(state, offset as usize + 1)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
@@ -1574,7 +1569,11 @@ fn format_move_body(m: &ReplayMove) -> String {
fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, replay.moves.len())
format!(
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
}
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(),
@@ -1661,19 +1660,19 @@ fn format_active_move_row(state: &ReplayPlaybackState) -> String {
/// follows and disambiguates from an ambiguous "T".
fn format_rank_short(rank: Rank) -> &'static str {
match rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
Rank::King => "K",
}
}
@@ -1682,10 +1681,10 @@ fn format_rank_short(rank: Rank) -> &'static str {
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
fn format_suit_glyph(suit: Suit) -> &'static str {
match suit {
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Diamonds => "\u{2666}", // ♦
Suit::Clubs => "\u{2663}", // ♣
Suit::Clubs => "\u{2663}", // ♣
}
}
@@ -1694,7 +1693,7 @@ fn format_suit_glyph(suit: Suit) -> &'static str {
fn format_card_short(card: Option<&Card>) -> String {
match card {
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
None => "--".to_string(),
None => "--".to_string(),
}
}
@@ -1704,7 +1703,8 @@ fn format_card_short(card: Option<&Card>) -> String {
/// (matching the visual left-to-right order on screen).
fn format_foundations_row(game: &GameState) -> String {
let slots: [String; 4] = std::array::from_fn(|i| {
let top = game.piles
let top = game
.piles
.get(&PileType::Foundation(i as u8))
.and_then(|p| p.cards.last());
format_card_short(top)
@@ -1716,11 +1716,13 @@ fn format_foundations_row(game: &GameState) -> String {
/// Renders as `STK:N WST:X♠` where N is the stock card count and
/// X♠ is the top waste card (or `--` when the waste pile is empty).
fn format_stock_waste_row(game: &GameState) -> String {
let stock_count = game.piles
let stock_count = game
.piles
.get(&PileType::Stock)
.map(|p| p.cards.len())
.unwrap_or(0);
let waste_top = game.piles
let waste_top = game
.piles
.get(&PileType::Waste)
.and_then(|p| p.cards.last());
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
@@ -2023,7 +2025,8 @@ mod tests {
/// they can drive every state transition deterministically.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
app.add_plugins(MinimalPlugins)
.add_plugins(ReplayOverlayPlugin);
app.init_resource::<ReplayPlaybackState>();
app
}
@@ -2619,13 +2622,11 @@ mod tests {
.next()
.expect("WIN MOVE marker must carry HighContrastBackground");
assert_eq!(
marker.default_color,
STATE_SUCCESS,
marker.default_color, STATE_SUCCESS,
"default colour must be STATE_SUCCESS"
);
assert_eq!(
marker.hc_color,
STATE_SUCCESS_HC,
marker.hc_color, STATE_SUCCESS_HC,
"HC colour must be STATE_SUCCESS_HC (brighter lime, not gray)"
);
}
@@ -2851,10 +2852,8 @@ mod tests {
let mut texts = scrub_notch_label_texts(&mut app);
texts.sort();
let mut expected: Vec<String> = scrub_notch_labels()
.iter()
.map(|s| s.to_string())
.collect();
let mut expected: Vec<String> =
scrub_notch_labels().iter().map(|s| s.to_string()).collect();
expected.sort();
assert_eq!(
texts, expected,
@@ -3144,7 +3143,10 @@ mod tests {
secs_to_next: 0.5,
paused: false,
};
assert_eq!(format_move_log_header(&playing), "\u{258C} MOVE LOG \u{00B7} 3/10");
assert_eq!(
format_move_log_header(&playing),
"\u{258C} MOVE LOG \u{00B7} 3/10"
);
assert_eq!(
format_move_log_header(&ReplayPlaybackState::Completed),
"\u{258C} MOVE LOG \u{00B7} COMPLETE",
@@ -3622,8 +3624,7 @@ mod tests {
app.update();
let world = app.world_mut();
let mut q = world
.query_filtered::<&TextColor, With<ReplayOverlayMoveLogActiveRow>>();
let mut q = world.query_filtered::<&TextColor, With<ReplayOverlayMoveLogActiveRow>>();
let color = q
.iter(world)
.next()
@@ -3945,10 +3946,7 @@ mod tests {
*cursor, 1,
"→ must advance the cursor by exactly one while paused",
);
assert!(
*paused,
"→ must leave the paused flag untouched",
);
assert!(*paused, "→ must leave the paused flag untouched",);
}
other => panic!("expected Playing, got {other:?}"),
}
@@ -4001,10 +3999,7 @@ mod tests {
*cursor, 2,
"← must decrement the cursor by exactly one while paused",
);
assert!(
*paused,
"← must leave the paused flag untouched",
);
assert!(*paused, "← must leave the paused flag untouched",);
}
other => panic!("expected Playing, got {other:?}"),
}
@@ -4044,9 +4039,9 @@ mod tests {
app.init_resource::<ButtonInput<KeyCode>>();
// Drive each frame as a SCRUB_REPEAT_INTERVAL_SECS step so
// every update past the just_pressed crosses the threshold.
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(SCRUB_REPEAT_INTERVAL_SECS),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
SCRUB_REPEAT_INTERVAL_SECS,
)));
// Start paused at cursor 0 so there's room to step forward.
set_state(&mut app, pressed_paused_state(10, 0));
app.update();
@@ -4094,9 +4089,9 @@ mod tests {
// Drive sub-threshold ticks so the accumulator builds but
// never fires while held.
let half_interval = SCRUB_REPEAT_INTERVAL_SECS * 0.5;
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(half_interval),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
half_interval,
)));
set_state(&mut app, pressed_paused_state(10, 5));
app.update();
@@ -4280,29 +4275,29 @@ mod tests {
/// character except Ten which maps to `"T"`.
#[test]
fn format_rank_short_all_ranks() {
assert_eq!(format_rank_short(Rank::Ace), "A");
assert_eq!(format_rank_short(Rank::Two), "2");
assert_eq!(format_rank_short(Rank::Ace), "A");
assert_eq!(format_rank_short(Rank::Two), "2");
assert_eq!(format_rank_short(Rank::Three), "3");
assert_eq!(format_rank_short(Rank::Four), "4");
assert_eq!(format_rank_short(Rank::Five), "5");
assert_eq!(format_rank_short(Rank::Six), "6");
assert_eq!(format_rank_short(Rank::Four), "4");
assert_eq!(format_rank_short(Rank::Five), "5");
assert_eq!(format_rank_short(Rank::Six), "6");
assert_eq!(format_rank_short(Rank::Seven), "7");
assert_eq!(format_rank_short(Rank::Eight), "8");
assert_eq!(format_rank_short(Rank::Nine), "9");
assert_eq!(format_rank_short(Rank::Ten), "T");
assert_eq!(format_rank_short(Rank::Jack), "J");
assert_eq!(format_rank_short(Rank::Nine), "9");
assert_eq!(format_rank_short(Rank::Ten), "T");
assert_eq!(format_rank_short(Rank::Jack), "J");
assert_eq!(format_rank_short(Rank::Queen), "Q");
assert_eq!(format_rank_short(Rank::King), "K");
assert_eq!(format_rank_short(Rank::King), "K");
}
/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
/// glyphs for each `Suit` variant (U+2660U+2666 confirmed on Android).
#[test]
fn format_suit_glyph_all_suits() {
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
}
/// `format_foundations_row` with a freshly-dealt game (all empty).
+8 -13
View File
@@ -222,10 +222,7 @@ pub fn start_replay_playback(
/// [`start_replay_playback`] signature — leaves room to hook in
/// cleanup (e.g. despawning playback-only overlays) without a future
/// API break.
pub fn stop_replay_playback(
_commands: &mut Commands,
state: &mut ResMut<ReplayPlaybackState>,
) {
pub fn stop_replay_playback(_commands: &mut Commands, state: &mut ResMut<ReplayPlaybackState>) {
**state = ReplayPlaybackState::Inactive;
}
@@ -566,9 +563,9 @@ mod tests {
/// so we drive 200 ms steps and call `update` enough times to pass
/// the requested duration.
fn advance_by(app: &mut App, total_secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(0.2),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
0.2,
)));
let ticks = (total_secs / 0.2).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
@@ -651,9 +648,7 @@ mod tests {
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing {
cursor,
replay: r,
..
cursor, replay: r, ..
} => {
assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed);
@@ -931,9 +926,9 @@ mod tests {
.add_systems(Update, collect_draws);
start_playback(&mut app, ten_draws_replay());
app.update();
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(tick_secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
tick_secs,
)));
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
-1
View File
@@ -145,4 +145,3 @@ impl TokioRuntimeResource {
Ok(Self(Arc::new(rt)))
}
}
+49 -24
View File
@@ -108,7 +108,15 @@ fn apply_safe_area_anchors(
// expects logical pixels (≈ dp). Divide by the window scale factor so
// the HUD band shifts by the correct number of dp on high-DPI devices.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let top_logical = insets.top / scale;
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
let max_inset = window_height * 0.25;
let raw_top = insets.top / scale;
if raw_top > max_inset {
warn!(
"safe_area: top inset {raw_top:.0}px exceeds 25% of window height ({max_inset:.0}px); clamping"
);
}
let top_logical = raw_top.min(max_inset);
for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical);
}
@@ -125,7 +133,15 @@ fn apply_safe_area_bottom_anchors(
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let bottom_logical = insets.bottom / scale;
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
let max_inset = window_height * 0.25;
let raw_bottom = insets.bottom / scale;
if raw_bottom > max_inset {
warn!(
"safe_area: bottom inset {raw_bottom:.0}px exceeds 25% of window height ({max_inset:.0}px); clamping"
);
}
let bottom_logical = raw_bottom.min(max_inset);
for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
}
@@ -148,7 +164,8 @@ fn apply_safe_area_to_modal_scrims(
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let bottom_logical = insets.bottom / scale;
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
for mut node in &mut scrims {
node.padding.bottom = Val::Px(bottom_logical);
}
@@ -260,7 +277,7 @@ mod android {
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{objects::JObject, JavaVM};
use jni::{JavaVM, objects::JObject};
let app = ANDROID_APP
.get()
@@ -353,25 +370,33 @@ mod tests {
#[test]
fn is_populated_returns_true_for_any_nonzero_edge() {
assert!(SafeAreaInsets {
top: 24.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
bottom: 16.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
left: 8.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
right: 8.0,
..Default::default()
}
.is_populated());
assert!(
SafeAreaInsets {
top: 24.0,
..Default::default()
}
.is_populated()
);
assert!(
SafeAreaInsets {
bottom: 16.0,
..Default::default()
}
.is_populated()
);
assert!(
SafeAreaInsets {
left: 8.0,
..Default::default()
}
.is_populated()
);
assert!(
SafeAreaInsets {
right: 8.0,
..Default::default()
}
.is_populated()
);
}
}
+194 -86
View File
@@ -156,13 +156,11 @@ impl Plugin for SelectionPlugin {
.in_set(SelectionKeySet)
.before(GameMutation),
clear_selection_on_state_change.after(GameMutation),
update_selection_highlight
.after(GameMutation)
.run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
),
update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
),
),
);
}
@@ -191,10 +189,7 @@ fn cycled_piles() -> Vec<PileType> {
///
/// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(
available: &[PileType],
current: Option<&PileType>,
) -> Option<PileType> {
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
if available.is_empty() {
return None;
}
@@ -227,11 +222,7 @@ pub fn cycle_next_pile(
///
/// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`.
fn did_wrap(
available: &[PileType],
current: Option<&PileType>,
next: Option<&PileType>,
) -> bool {
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else {
return false;
};
@@ -306,8 +297,7 @@ fn handle_selection_keys(
destination_index,
} = &mut *kbd_drag
{
let shift_held =
keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
// Cycle destinations forward / backward.
let advance = keys.just_pressed(KeyCode::ArrowRight)
@@ -436,9 +426,7 @@ fn handle_selection_keys(
return;
}
// Priority 2: tableau stack move.
let run_len = face_up_run_len(
game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()),
);
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
let bottom_card = game.0.piles.get(pile).and_then(|p| {
let start = p.cards.len().saturating_sub(run_len);
p.cards.get(start)
@@ -486,16 +474,13 @@ fn handle_selection_keys(
return;
}
let start = pile_cards.cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> =
pile_cards.cards[start..].iter().map(|c| c.id).collect();
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = pile_cards.cards.get(start) else {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
if legal.is_empty() {
info_toast.write(InfoToastEvent(
"No legal moves for this card".to_string(),
));
info_toast.write(InfoToastEvent("No legal moves for this card".to_string()));
return;
}
@@ -603,9 +588,10 @@ fn try_foundation_dest(
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile) {
return Some(dest);
}
&& can_place_on_foundation(card, pile)
{
return Some(dest);
}
}
None
}
@@ -831,22 +817,34 @@ mod tests {
// Press 1: no current selection → first pile, no wrap.
let sel1 = cycle_next_pile(&available, None);
assert_eq!(sel1, Some(PileType::Waste));
assert!(!did_wrap(&available, None, sel1.as_ref()), "first Tab should not wrap");
assert!(
!did_wrap(&available, None, sel1.as_ref()),
"first Tab should not wrap"
);
// Press 2: Waste → Tableau(0), no wrap.
let sel2 = cycle_next_pile(&available, sel1.as_ref());
assert_eq!(sel2, Some(PileType::Tableau(0)));
assert!(!did_wrap(&available, sel1.as_ref(), sel2.as_ref()), "second Tab should not wrap");
assert!(
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
"second Tab should not wrap"
);
// Press 3: Tableau(0) → Tableau(1), still no wrap.
let sel3 = cycle_next_pile(&available, sel2.as_ref());
assert_eq!(sel3, Some(PileType::Tableau(1)));
assert!(!did_wrap(&available, sel2.as_ref(), sel3.as_ref()), "third Tab (T0→T1) should not wrap");
assert!(
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
"third Tab (T0→T1) should not wrap"
);
// Press 4: Tableau(1) → Waste, this IS the wrap.
let sel4 = cycle_next_pile(&available, sel3.as_ref());
assert_eq!(sel4, Some(PileType::Waste));
assert!(did_wrap(&available, sel3.as_ref(), sel4.as_ref()), "fourth Tab should wrap back to Waste");
assert!(
did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
"fourth Tab should wrap back to Waste"
);
}
#[test]
@@ -869,9 +867,24 @@ mod tests {
fn face_up_run_len_all_face_up() {
use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
];
assert_eq!(face_up_run_len(&cards), 3);
}
@@ -880,10 +893,30 @@ mod tests {
fn face_up_run_len_mixed_stops_at_face_down() {
use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: false },
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
Card { id: 3, suit: Suit::Diamonds, rank: Rank::Ten, face_up: true },
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: false,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
Card {
id: 3,
suit: Suit::Diamonds,
rank: Rank::Ten,
face_up: true,
},
];
// Only the top two cards are face-up.
assert_eq!(face_up_run_len(&cards), 2);
@@ -893,8 +926,18 @@ mod tests {
fn face_up_run_len_top_card_face_down_is_zero() {
use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
];
assert_eq!(face_up_run_len(&cards), 0);
}
@@ -902,9 +945,12 @@ mod tests {
#[test]
fn face_up_run_len_single_face_up_card() {
use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![
Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
];
let cards = vec![Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
}];
assert_eq!(face_up_run_len(&cards), 1);
}
@@ -956,27 +1002,43 @@ mod tests {
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
// Place test cards.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
});
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles.get_mut(&PileType::Tableau(2)).unwrap().cards.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(2))
.unwrap()
.cards
.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
g
}
@@ -1014,17 +1076,32 @@ mod tests {
app.update();
// Initial state: nothing selected, KeyboardDragState::Idle.
assert!(app.world().resource::<SelectionState>().selected_pile.is_none());
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
assert!(
app.world()
.resource::<SelectionState>()
.selected_pile
.is_none()
);
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle
);
press_key(&mut app, KeyCode::Tab);
app.update();
let selected = app.world().resource::<SelectionState>().selected_pile.clone();
let selected = app
.world()
.resource::<SelectionState>()
.selected_pile
.clone();
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(PileType::Tableau(0)));
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle
);
}
/// Test 2 — Enter while a source is selected lifts the stack.
@@ -1038,8 +1115,9 @@ mod tests {
app.update();
// Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1081,8 +1159,9 @@ mod tests {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1091,7 +1170,9 @@ mod tests {
// higher. Verify that the destinations are exactly those tableaus
// (in cycle order T1 then T2).
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
KeyboardDragState::Lifted { legal_destinations, .. } => legal_destinations.clone(),
KeyboardDragState::Lifted {
legal_destinations, ..
} => legal_destinations.clone(),
_ => panic!("expected Lifted"),
};
assert_eq!(
@@ -1109,7 +1190,14 @@ mod tests {
rank: Rank::Five,
face_up: true,
};
let pile = app.world().resource::<GameStateResource>().0.piles.get(dest).unwrap().clone();
let pile = app
.world()
.resource::<GameStateResource>()
.0
.piles
.get(dest)
.unwrap()
.clone();
assert!(
can_place_on_tableau(&bottom_card, &pile),
"destination {dest:?} must be legal for the lifted stack",
@@ -1118,7 +1206,9 @@ mod tests {
// Initial focused destination = first entry.
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(1)),
);
@@ -1127,7 +1217,9 @@ mod tests {
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(2)),
);
@@ -1136,7 +1228,9 @@ mod tests {
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
app.world()
.resource::<KeyboardDragState>()
.focused_destination(),
Some(&PileType::Tableau(1)),
"destination index must wrap back to 0 after exhausting the list",
);
@@ -1150,8 +1244,9 @@ mod tests {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1194,8 +1289,9 @@ mod tests {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
@@ -1240,10 +1336,18 @@ mod tests {
drag.active_touch_id = None;
}
let before = app.world().resource::<SelectionState>().selected_pile.clone();
let before = app
.world()
.resource::<SelectionState>()
.selected_pile
.clone();
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app.world().resource::<SelectionState>().selected_pile.clone();
let after = app
.world()
.resource::<SelectionState>()
.selected_pile
.clone();
assert_eq!(
before, after,
@@ -1258,8 +1362,9 @@ mod tests {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1276,7 +1381,10 @@ mod tests {
press_key(&mut app, KeyCode::Escape);
app.update();
assert!(
app.world().resource::<SelectionState>().selected_pile.is_none(),
app.world()
.resource::<SelectionState>()
.selected_pile
.is_none(),
"second Esc clears the source selection",
);
}
+237 -73
View File
@@ -17,13 +17,14 @@ use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode;
use solitaire_data::{
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS,
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
settings_file_path,
};
use solitaire_data::settings::SyncBackend;
use crate::assets::user_theme_dir;
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
@@ -31,20 +32,20 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::assets::user_theme_dir;
use crate::theme::{import_theme, ImportError, ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry};
use crate::theme::{
ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry,
};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalButton, ModalScrim,
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
HighContrastBorder,
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
HighContrastBorder, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
use crate::ui_tooltip::Tooltip;
/// Side length of a swatch button in the card-back / background pickers.
/// Smaller than the smallest spacing rung so it stays a literal.
@@ -401,10 +402,8 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text,
update_color_blind_text,
update_high_contrast_text,
update_high_contrast_borders
.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds
.run_if(resource_changed::<SettingsResource>),
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
@@ -454,7 +453,12 @@ fn merge_geometry(
let (x, y) = new_pos
.or_else(|| existing.map(|g| (g.x, g.y)))
.unwrap_or((0, 0));
Some(WindowGeometry { width, height, x, y })
Some(WindowGeometry {
width,
height,
x,
y,
})
}
// ---------------------------------------------------------------------------
@@ -527,8 +531,10 @@ fn sync_settings_panel_visibility(
}
if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() {
let status_label = sync_status
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
@@ -894,14 +900,110 @@ fn handle_settings_buttons(
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut high_contrast_text: Query<&mut Text, (With<HighContrastText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<ReduceMotionText>)>,
mut reduce_motion_text: Query<&mut Text, (With<ReduceMotionText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>)>,
mut sfx_text: Query<
&mut Text,
(
With<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut music_text: Query<
&mut Text,
(
With<MusicVolumeText>,
Without<SfxVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut draw_text: Query<
&mut Text,
(
With<DrawModeText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut theme_text: Query<
&mut Text,
(
With<ThemeText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut anim_speed_text: Query<
&mut Text,
(
With<AnimSpeedText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut color_blind_text: Query<
&mut Text,
(
With<ColorBlindText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut high_contrast_text: Query<
&mut Text,
(
With<HighContrastText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<ReduceMotionText>,
),
>,
mut reduce_motion_text: Query<
&mut Text,
(
With<ReduceMotionText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
),
>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
@@ -995,7 +1097,9 @@ fn handle_settings_buttons(
}
SettingsButton::TimeBonusDown => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
let after = settings
.0
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
@@ -1006,7 +1110,9 @@ fn handle_settings_buttons(
}
SettingsButton::TimeBonusUp => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
let after = settings
.0
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
@@ -1085,8 +1191,7 @@ fn handle_settings_buttons(
// Text refreshed by `update_analytics_enabled_text` next frame.
}
SettingsButton::ToggleSmartDefaultSize => {
settings.0.disable_smart_default_size =
!settings.0.disable_smart_default_size;
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
@@ -1144,15 +1249,21 @@ fn handle_sync_buttons(
continue;
}
match button {
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
SettingsButton::SyncNow => {
manual_sync.write(ManualSyncRequestEvent);
}
SettingsButton::ConnectSync => {
// Close settings before the sync-setup modal opens so the
// guard in open_sync_setup_modal doesn't block on our own scrim.
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent);
}
SettingsButton::DeleteAccount => {
delete_account.write(DeleteAccountRequestEvent);
}
_ => {}
}
}
@@ -1334,10 +1445,11 @@ fn scroll_focus_into_view(
Err(_) => break,
}
}
let Some(container) = container_entity else { return };
let Some(container) = container_entity else {
return;
};
let Ok((mut scroll, container_transform, container_node)) =
containers.get_mut(container)
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
else {
return;
};
@@ -1430,10 +1542,12 @@ fn record_window_geometry_changes(
) {
// Read .last() — only the final event matters for persistence; the
// intermediate sizes/positions are noise during a drag.
let new_size = resized
.read()
.last()
.map(|ev| (ev.width.round().max(0.0) as u32, ev.height.round().max(0.0) as u32));
let new_size = resized.read().last().map(|ev| {
(
ev.width.round().max(0.0) as u32,
ev.height.round().max(0.0) as u32,
)
});
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
if new_size.is_none() && new_pos.is_none() {
@@ -2030,7 +2144,12 @@ fn toggle_row<Marker: Component>(
..default()
})
.with_children(|cluster| {
cluster.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
cluster.spawn((
marker,
Text::new(value),
value_font,
TextColor(TEXT_PRIMARY),
));
icon_button(cluster, "", action, tooltip, font_res);
});
});
@@ -2082,7 +2201,11 @@ fn picker_row(
let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked };
for &idx in entries {
let is_selected = idx == selected;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
let bg = if is_selected {
STATE_SUCCESS
} else {
BG_ELEVATED_HI
};
row.spawn((
make_button(idx),
Button,
@@ -2215,7 +2338,11 @@ fn theme_picker_row(
));
for entry in themes {
let is_selected = entry.id == selected_id;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
let bg = if is_selected {
STATE_SUCCESS
} else {
BG_ELEVATED_HI
};
row.spawn((
SettingsButton::SelectTheme(entry.id.clone()),
Button,
@@ -2274,16 +2401,14 @@ fn spawn_thumbnail_pair(
align_items: AlignItems::Center,
..default()
})
.with_children(|pair| {
match thumbnails {
Some(t) if t.is_fully_populated() => {
spawn_thumbnail_image(pair, t.ace.clone());
spawn_thumbnail_image(pair, t.back.clone());
}
_ => {
spawn_thumbnail_placeholder(pair);
spawn_thumbnail_placeholder(pair);
}
.with_children(|pair| match thumbnails {
Some(t) if t.is_fully_populated() => {
spawn_thumbnail_image(pair, t.ace.clone());
spawn_thumbnail_image(pair, t.back.clone());
}
_ => {
spawn_thumbnail_placeholder(pair);
spawn_thumbnail_placeholder(pair);
}
});
}
@@ -2360,11 +2485,7 @@ fn sync_row(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
font,
TextColor(TEXT_PRIMARY),
));
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
});
};
@@ -2658,7 +2779,11 @@ fn icon_button(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
b.spawn((
Text::new(label.to_string()),
glyph_font,
TextColor(TEXT_PRIMARY),
));
});
}
@@ -2714,7 +2839,10 @@ mod tests {
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
@@ -2726,7 +2854,10 @@ mod tests {
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
@@ -2739,7 +2870,10 @@ mod tests {
#[test]
fn volume_clamped_at_zero_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.0;
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.0;
press(&mut app, KeyCode::BracketLeft);
app.update();
@@ -2749,21 +2883,34 @@ mod tests {
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
assert_eq!(
cursor.read(events).count(),
0,
"no event when clamped at floor"
);
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
assert!(!app.world().resource::<SettingsScreen>().0, "screen is closed initially");
assert!(
!app.world().resource::<SettingsScreen>().0,
"screen is closed initially"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens settings");
assert!(
app.world().resource::<SettingsScreen>().0,
"O opens settings"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(!app.world().resource::<SettingsScreen>().0, "second O closes settings");
assert!(
!app.world().resource::<SettingsScreen>().0,
"second O closes settings"
);
}
// cycle_unlocked pure-function tests
@@ -2819,7 +2966,8 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0.y;
.0
.y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
@@ -2850,8 +2998,12 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0.y;
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
.0
.y;
assert!(
(offset - 200.0).abs() < 1e-3,
"scrolling down should increase offset_y; got {offset}"
);
}
// -----------------------------------------------------------------------
@@ -3102,7 +3254,12 @@ mod tests {
#[test]
fn merge_geometry_uses_existing_when_event_components_missing() {
let existing = WindowGeometry { width: 1280, height: 800, x: 100, y: 50 };
let existing = WindowGeometry {
width: 1280,
height: 800,
x: 100,
y: 50,
};
// Position-only event keeps existing size.
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
assert_eq!(merged.width, 1280);
@@ -3214,7 +3371,10 @@ mod tests {
.0
.window_geometry
.unwrap();
assert_eq!(geom.width, 1280, "size must be preserved across a move-only update");
assert_eq!(
geom.width, 1280,
"size must be preserved across a move-only update"
);
assert_eq!(geom.height, 800);
assert_eq!(geom.x, 250);
assert_eq!(geom.y, 175);
@@ -3280,7 +3440,11 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0.y;
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
.0
.y;
assert_eq!(
offset, 0.0,
"scrolling past top must clamp to 0, got {offset}"
);
}
}
+55 -46
View File
@@ -76,8 +76,8 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_BASE, BORDER_SUBTLE, MOTION_SPLASH_FADE_SECS,
MOTION_SPLASH_TOTAL_SECS, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
TEXT_DISABLED, TEXT_PRIMARY, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2,
VAL_SPACE_3, VAL_SPACE_5, VAL_SPACE_6, VAL_SPACE_7, Z_SPLASH,
TEXT_DISABLED, TEXT_PRIMARY, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_5, VAL_SPACE_6, VAL_SPACE_7, Z_SPLASH,
};
// ---------------------------------------------------------------------------
@@ -99,12 +99,7 @@ impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems(
Update,
(
dismiss_splash_on_input,
advance_splash,
pulse_splash_cursor,
)
.chain(),
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor).chain(),
);
}
}
@@ -325,11 +320,7 @@ fn build_scanline_image() -> Image {
// because `TextureFormat::pixel_size()` returns a `Result` in this
// Bevy version and a `debug_assert_eq!` shouldn't carry the
// unwrap noise.
debug_assert_eq!(
pixels.len(),
16,
"scanline pixel buffer must be 2x2 RGBA8",
);
debug_assert_eq!(pixels.len(), 16, "scanline pixel buffer must be 2x2 RGBA8",);
Image::new(
Extent3d {
width: 2,
@@ -376,13 +367,17 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
})
.with_children(|hdr| {
hdr.spawn((
SplashFadable { base_color: ACCENT_PRIMARY },
SplashFadable {
base_color: ACCENT_PRIMARY,
},
Text::new("|"), // ASCII terminal cursor.
cursor_font,
TextColor(transparent(ACCENT_PRIMARY)),
));
hdr.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
SplashFadable {
base_color: TEXT_PRIMARY,
},
Text::new("Ferrous Solitaire"),
title_font,
TextColor(transparent(TEXT_PRIMARY)),
@@ -390,7 +385,9 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
// Thin horizontal divider under the wordmark — same hue as
// every other 1px chrome line in the design system.
hdr.spawn((
SplashFadableBg { base_color: BORDER_SUBTLE },
SplashFadableBg {
base_color: BORDER_SUBTLE,
},
Node {
width: Val::Px(192.0),
height: Val::Px(1.0),
@@ -399,7 +396,9 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
BackgroundColor(transparent(BORDER_SUBTLE)),
));
hdr.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new("TERMINAL EDITION"),
subtitle_font,
TextColor(transparent(TEXT_DISABLED)),
@@ -469,13 +468,17 @@ fn spawn_check_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont, labe
})
.with_children(|row| {
row.spawn((
SplashFadable { base_color: STATE_SUCCESS },
SplashFadable {
base_color: STATE_SUCCESS,
},
Text::new("\u{2713}"), // ✓
line_font.clone(),
TextColor(transparent(STATE_SUCCESS)),
));
row.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new(label.to_string()),
line_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
@@ -502,7 +505,9 @@ fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
})
.with_children(|row| {
row.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
SplashFadable {
base_color: TEXT_PRIMARY,
},
Text::new("| ready_"), // ASCII ready prompt.
line_font.clone(),
TextColor(transparent(TEXT_PRIMARY)),
@@ -513,7 +518,9 @@ fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
// 6×12 px spec literally. Pulse animation lives in
// `pulse_splash_cursor` for testability.
row.spawn((
SplashFadableBg { base_color: ACCENT_PRIMARY },
SplashFadableBg {
base_color: ACCENT_PRIMARY,
},
SplashCursorPulse,
Node {
width: Val::Px(6.0),
@@ -542,7 +549,9 @@ fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
.with_children(|bar| {
// Track.
bar.spawn((
SplashFadableBg { base_color: BORDER_SUBTLE },
SplashFadableBg {
base_color: BORDER_SUBTLE,
},
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
@@ -553,7 +562,9 @@ fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
.with_children(|track| {
// Fill — 100 % of the track width = "complete".
track.spawn((
SplashFadableBg { base_color: ACCENT_PRIMARY },
SplashFadableBg {
base_color: ACCENT_PRIMARY,
},
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
@@ -570,7 +581,9 @@ fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
})
.with_children(|caption| {
caption.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new("DONE \u{00B7} 247 ASSETS"), // DONE · 247 ASSETS
line_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
@@ -598,14 +611,18 @@ fn spawn_footer_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
})
.with_children(|footer| {
footer.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new("BASE16-EIGHTIES"),
footer_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
));
spawn_palette_swatch_row(footer);
footer.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))),
footer_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
@@ -838,9 +855,8 @@ fn dismiss_splash_on_input(
// Jump the age forward to the start of the fade-out so the
// overlay dissolves cleanly. Saturating arithmetic on Duration
// means an already-past-fade-out splash stays past fade-out.
let fade_out_start = Duration::from_secs_f32(
(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0),
);
let fade_out_start =
Duration::from_secs_f32((MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0));
for mut age in &mut roots {
if age.0 < fade_out_start {
age.0 = fade_out_start;
@@ -879,9 +895,9 @@ mod tests {
/// Tells `TimePlugin` to advance the virtual clock by `secs` on the
/// next `app.update()`. Mirrors the helper in `ui_tooltip::tests`.
fn set_manual_time_step(app: &mut App, secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// `Time<Virtual>` clamps per-tick deltas to `max_delta` (default
@@ -1056,9 +1072,8 @@ mod tests {
"alpha mid-hold must be exactly 1.0"
);
// Inside fade-out.
let mid_fade_out = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0,
);
let mid_fade_out =
Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0);
let mid_out_alpha = splash_alpha(mid_fade_out).unwrap();
assert!(
mid_out_alpha < 0.6 && mid_out_alpha > 0.4,
@@ -1097,9 +1112,8 @@ mod tests {
.next()
.expect("splash should exist after one post-dismiss tick")
.0;
let fade_out_start = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
);
let fade_out_start =
Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS);
assert!(
age >= fade_out_start,
"after a keypress dismiss the splash must be in fade-out (age >= {fade_out_start:?}); got {age:?}"
@@ -1127,9 +1141,8 @@ mod tests {
.next()
.expect("splash should exist after one post-dismiss tick")
.0;
let fade_out_start = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
);
let fade_out_start =
Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS);
assert!(
age >= fade_out_start,
"after a left-click dismiss the splash must be in fade-out; got {age:?}"
@@ -1320,11 +1333,7 @@ mod tests {
);
// Trough — sin(TAU * 0.75) = -1 → normalised = 0 → factor = min.
let trough = cursor_pulse_factor(
Duration::from_secs_f32(period * 3.0 / 4.0),
period,
min,
);
let trough = cursor_pulse_factor(Duration::from_secs_f32(period * 3.0 / 4.0), period, min);
assert!(
(trough - min).abs() < 1e-5,
"trough should fall to min ({min}); got {trough}"
+33 -42
View File
@@ -14,15 +14,15 @@
use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::Utc;
use uuid::Uuid;
use solitaire_data::{
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
};
use solitaire_sync::{merge, SyncPayload, SyncResponse};
use solitaire_sync::{SyncPayload, SyncResponse, merge};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{
@@ -32,7 +32,9 @@ use crate::events::{
use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
use crate::stats_plugin::{
LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath,
};
// ---------------------------------------------------------------------------
// Public resources
@@ -148,9 +150,7 @@ fn start_pull(
) {
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.pull())
});
let task = AsyncComputeTaskPool::get().spawn(async move { rt.block_on(provider.pull()) });
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
}
@@ -173,9 +173,7 @@ fn handle_manual_sync_request(
}
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.pull())
});
let task = AsyncComputeTaskPool::get().spawn(async move { rt.block_on(provider.pull()) });
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
}
@@ -219,17 +217,20 @@ fn poll_pull_result(
// Persist merged state atomically.
if let Some(p) = &stats_path.0
&& let Err(e) = save_stats_to(p, &merged.stats) {
warn!("sync: failed to persist stats: {e}");
}
&& let Err(e) = save_stats_to(p, &merged.stats)
{
warn!("sync: failed to persist stats: {e}");
}
if let Some(p) = &achievements_path.0
&& let Err(e) = save_achievements_to(p, &merged.achievements) {
warn!("sync: failed to persist achievements: {e}");
}
&& let Err(e) = save_achievements_to(p, &merged.achievements)
{
warn!("sync: failed to persist achievements: {e}");
}
if let Some(p) = &progress_path.0
&& let Err(e) = save_progress_to(p, &merged.progress) {
warn!("sync: failed to persist progress: {e}");
}
&& let Err(e) = save_progress_to(p, &merged.progress)
{
warn!("sync: failed to persist progress: {e}");
}
// Update in-world resources.
let now = Utc::now();
@@ -342,9 +343,8 @@ fn push_replay_on_win(
);
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.push_replay(&replay))
});
let task = AsyncComputeTaskPool::get()
.spawn(async move { rt.block_on(provider.push_replay(&replay)) });
// If a previous upload is still in flight, drop it — the most
// recent win is the one whose share link the player will care
// about. Bevy's `Task` Drop cancels cooperatively.
@@ -520,10 +520,7 @@ mod tests {
// Status is either Syncing (task still running) or LastSynced (resolved).
let status = &app.world().resource::<SyncStatusResource>().0;
assert!(
matches!(
status,
SyncStatus::Syncing | SyncStatus::LastSynced(_)
),
matches!(status, SyncStatus::Syncing | SyncStatus::LastSynced(_)),
"status should be Syncing or LastSynced, got {status:?}"
);
}
@@ -539,8 +536,7 @@ mod tests {
// mirrors the auto-save flake fix and turns this test from
// "pass on a fast machine" into "pass on any machine that
// makes meaningful progress".
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if matches!(
@@ -565,8 +561,7 @@ mod tests {
fn pull_failure_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with(FailingProvider);
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if matches!(
@@ -590,17 +585,16 @@ mod tests {
#[test]
fn build_payload_sets_nil_user_id() {
let payload = build_payload(
&StatsSnapshot::default(),
&[],
&PlayerProgress::default(),
);
let payload = build_payload(&StatsSnapshot::default(), &[], &PlayerProgress::default());
assert_eq!(payload.user_id, Uuid::nil());
}
#[test]
fn build_payload_clones_stats() {
let stats = StatsSnapshot { games_played: 42, ..Default::default() };
let stats = StatsSnapshot {
games_played: 42,
..Default::default()
};
let payload = build_payload(&stats, &[], &PlayerProgress::default());
assert_eq!(payload.stats.games_played, 42);
}
@@ -615,12 +609,11 @@ mod tests {
fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{
load_replay_history_from, save_replay_history_to, Replay, ReplayHistory,
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
};
let mut app = headless_app_with(NoOpProvider);
let path = std::env::temp_dir()
.join("solitaire_test_replay_share_url_persist.json");
let path = std::env::temp_dir().join("solitaire_test_replay_share_url_persist.json");
let _ = std::fs::remove_file(&path);
// Seed the in-memory history with a single replay carrying no
@@ -649,9 +642,7 @@ mod tests {
let url = url.clone();
async move { Ok::<String, SyncError>(url) }
});
app.world_mut()
.resource_mut::<PendingReplayUpload>()
.0 = Some(task);
app.world_mut().resource_mut::<PendingReplayUpload>().0 = Some(task);
// Pump frames until the polling system observes the task as
// ready and clears `PendingReplayUpload`.
+51 -30
View File
@@ -37,13 +37,13 @@ use std::sync::Arc;
use bevy::input::ButtonState;
use bevy::input::keyboard::KeyboardInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_data::{
auth_tokens::{delete_tokens, store_tokens},
settings::SyncBackend,
save_settings_to,
sync_client::{LocalOnlyProvider, SolitaireServerClient},
SyncError,
auth_tokens::{delete_tokens, store_tokens},
save_settings_to,
settings::SyncBackend,
sync_client::{LocalOnlyProvider, SolitaireServerClient},
};
use crate::avatar_plugin::AvatarFetchEvent;
@@ -53,15 +53,16 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{
SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath,
};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{spawn_modal, ModalScrim};
use crate::ui_modal::{ModalScrim, spawn_modal};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, Z_MODAL_PANEL,
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
STATE_DANGER, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
@@ -213,7 +214,14 @@ fn open_sync_setup_modal(
// Exclude SettingsPanel: the Connect button closes settings in the same
// frame it fires SyncConfigureRequestEvent, but Bevy despawns are deferred
// so the settings scrim still exists in the world during this system.
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>, Without<SettingsPanel>)>,
other_modal_scrims: Query<
(),
(
With<ModalScrim>,
Without<SyncSetupScreen>,
Without<SettingsPanel>,
),
>,
mut commands: Commands,
mut focused: ResMut<SyncFocusedField>,
font_res: Option<Res<FontResource>>,
@@ -237,7 +245,12 @@ fn handle_text_input(
screen: Query<(), With<SyncSetupScreen>>,
mut key_events: MessageReader<KeyboardInput>,
mut focused: ResMut<SyncFocusedField>,
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer, &mut Text, &mut TextColor)>,
mut fields: Query<(
&SyncFieldKind,
&mut SyncFieldBuffer,
&mut Text,
&mut TextColor,
)>,
pending: Res<PendingAuthTask>,
) {
if screen.is_empty() || pending.task.is_some() {
@@ -316,12 +329,8 @@ fn handle_auth_button(
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
) {
let login_clicked = login_q
.iter()
.any(|i| *i == Interaction::Pressed);
let register_clicked = register_q
.iter()
.any(|i| *i == Interaction::Pressed);
let login_clicked = login_q.iter().any(|i| *i == Interaction::Pressed);
let register_clicked = register_q.iter().any(|i| *i == Interaction::Pressed);
if !login_clicked && !register_clicked {
return;
@@ -505,8 +514,8 @@ fn handle_cancel(
screen: Query<Entity, With<SyncSetupScreen>>,
mut commands: Commands,
) {
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|| keys.just_pressed(KeyCode::Escape);
let cancelled =
cancel_q.iter().any(|i| *i == Interaction::Pressed) || keys.just_pressed(KeyCode::Escape);
if !cancelled || screen.is_empty() {
return;
}
@@ -582,8 +591,8 @@ fn handle_delete_cancel(
screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands,
) {
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|| keys.just_pressed(KeyCode::Escape);
let cancelled =
cancel_q.iter().any(|i| *i == Interaction::Pressed) || keys.just_pressed(KeyCode::Escape);
if !cancelled || screen.is_empty() {
return;
}
@@ -610,9 +619,9 @@ fn handle_delete_confirm(
}
let provider = provider.0.clone();
let rt = rt.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.delete_account())
}));
pending.0 = Some(
AsyncComputeTaskPool::get().spawn(async move { rt.block_on(provider.delete_account()) }),
);
}
/// Polls the in-flight delete-account task. On success fires `SyncLogoutRequestEvent`.
@@ -677,7 +686,7 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
SyncFieldKind::Url,
"Server URL",
"https://your-server.example.com",
true, // focused initially
true, // focused initially
font_res,
);
spawn_field(
@@ -783,7 +792,11 @@ fn spawn_field(
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(if focused { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
BorderColor::all(if focused {
ACCENT_PRIMARY
} else {
BORDER_SUBTLE
}),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|border| {
@@ -806,7 +819,11 @@ fn spawn_action_button<M: Component>(
primary: bool,
font_res: Option<&FontResource>,
) {
let bg = if primary { ACCENT_PRIMARY } else { BG_ELEVATED_HI };
let bg = if primary {
ACCENT_PRIMARY
} else {
BG_ELEVATED_HI
};
let fg = TEXT_PRIMARY;
parent
.spawn((
@@ -820,7 +837,11 @@ fn spawn_action_button<M: Component>(
..default()
},
BackgroundColor(bg),
BorderColor::all(if primary { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
BorderColor::all(if primary {
ACCENT_PRIMARY
} else {
BORDER_SUBTLE
}),
))
.with_children(|b| {
b.spawn((
+62 -30
View File
@@ -11,11 +11,11 @@ use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::ui_theme::TEXT_PRIMARY;
#[cfg(test)]
@@ -101,7 +101,9 @@ fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands:
let Some(asset_server) = asset_server else {
// AssetServer absent (e.g. MinimalPlugins in tests) — insert an
// empty set so setup_table can proceed using a default handle.
commands.insert_resource(BackgroundImageSet { handles: Vec::new() });
commands.insert_resource(BackgroundImageSet {
handles: Vec::new(),
});
return;
};
let handles = (0..5)
@@ -118,8 +120,8 @@ fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands:
fn theme_colour(theme: &Theme) -> Color {
match theme {
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
}
}
@@ -171,10 +173,12 @@ fn setup_table(
));
}
let (window_size, scale) = windows.iter().next().map_or(
(Vec2::new(1280.0, 800.0), 1.0f32),
|w| (default_window_size(w), w.scale_factor()),
);
let (window_size, scale) = windows
.iter()
.next()
.map_or((Vec2::new(1280.0, 800.0), 1.0f32), |w| {
(default_window_size(w), w.scale_factor())
});
// Safe-area insets arrive from JNI asynchronously; they are almost always
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
// arrive and issues a synthetic WindowResized to re-snap all game objects.
@@ -249,10 +253,10 @@ fn apply_theme_on_settings_change(
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
pub fn suit_symbol(suit: &Suit) -> &'static str {
match suit {
Suit::Spades => "S",
Suit::Hearts => "H",
Suit::Spades => "S",
Suit::Hearts => "H",
Suit::Diamonds => "D",
Suit::Clubs => "C",
Suit::Clubs => "C",
}
}
@@ -291,7 +295,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
TextFont { font_size, ..default() },
TextFont {
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
@@ -301,7 +308,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
entity.with_children(|b| {
b.spawn((
Text2d::new("A"),
TextFont { font_size, ..default() },
TextFont {
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
@@ -375,7 +385,9 @@ fn on_safe_area_changed(
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
let Some(safe_area) = safe_area else { return; };
let Some(safe_area) = safe_area else {
return;
};
if !safe_area.is_changed() {
return;
}
@@ -597,18 +609,18 @@ mod tests {
#[test]
fn all_three_themes_produce_distinct_colours() {
let green = theme_colour(&Theme::Green);
let blue = theme_colour(&Theme::Blue);
let dark = theme_colour(&Theme::Dark);
let blue = theme_colour(&Theme::Blue);
let dark = theme_colour(&Theme::Dark);
assert_ne!(green, blue, "Green and Blue must differ");
assert_ne!(green, dark, "Green and Dark must differ");
assert_ne!(blue, dark, "Blue and Dark must differ");
assert_ne!(blue, dark, "Blue and Dark must differ");
}
#[test]
fn effective_background_index_0_matches_theme_colour() {
for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
let expected = theme_colour(&theme);
let actual = effective_background_colour(&theme, 0);
let actual = effective_background_colour(&theme, 0);
assert_eq!(
expected, actual,
"index 0 must always return the theme colour for {:?}",
@@ -623,7 +635,10 @@ mod tests {
let theme_green = theme_colour(&Theme::Green);
for idx in 1..=3 {
let eff = effective_background_colour(&Theme::Green, idx);
assert_ne!(eff, theme_green, "index {idx} must override the theme colour");
assert_ne!(
eff, theme_green,
"index {idx} must override the theme colour"
);
}
}
@@ -643,10 +658,10 @@ mod tests {
#[test]
fn suit_symbol_returns_correct_letters() {
assert_eq!(suit_symbol(&Suit::Spades), "S");
assert_eq!(suit_symbol(&Suit::Hearts), "H");
assert_eq!(suit_symbol(&Suit::Spades), "S");
assert_eq!(suit_symbol(&Suit::Hearts), "H");
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
assert_eq!(suit_symbol(&Suit::Clubs), "C");
assert_eq!(suit_symbol(&Suit::Clubs), "C");
}
// -----------------------------------------------------------------------
@@ -730,12 +745,29 @@ mod tests {
/// `hint_pile_highlight_rgb_tracks_state_warning_token`.
#[test]
fn hint_pile_highlight_colour_is_gold() {
let Srgba { red, green, blue, .. } = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(red >= 0.7, "gold hint colour must have red ≥ 0.7, got {red}");
assert!(green >= 0.5, "gold hint colour must have green ≥ 0.5, got {green}");
assert!(blue <= 0.6, "gold hint colour must have blue ≤ 0.6, got {blue}");
assert!(red > blue, "gold hint colour must be warmer than cool, got r={red} b={blue}");
assert!(green > blue, "gold hint colour must be warmer than cool, got g={green} b={blue}");
let Srgba {
red, green, blue, ..
} = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(
red >= 0.7,
"gold hint colour must have red ≥ 0.7, got {red}"
);
assert!(
green >= 0.5,
"gold hint colour must have green ≥ 0.5, got {green}"
);
assert!(
blue <= 0.6,
"gold hint colour must have blue ≤ 0.6, got {blue}"
);
assert!(
red > blue,
"gold hint colour must be warmer than cool, got r={red} b={blue}"
);
assert!(
green > blue,
"gold hint colour must be warmer than cool, got g={green} b={blue}"
);
}
#[test]
+33 -55
View File
@@ -45,10 +45,10 @@ use thiserror::Error;
use bevy::math::UVec2;
use crate::assets::{rasterize_svg, user_theme_dir, SvgLoaderError};
use crate::assets::{SvgLoaderError, rasterize_svg, user_theme_dir};
use super::manifest::{ManifestError, ThemeManifest};
use super::ThemeMetaError;
use super::manifest::{ManifestError, ThemeManifest};
/// Hard cap on the *uncompressed* total of all archive entries. Set
/// generously high relative to a realistic 53-SVG theme (~12 MB at
@@ -100,9 +100,7 @@ pub enum ImportError {
/// The archive's declared total uncompressed size exceeds
/// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction.
#[error(
"archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit"
)]
#[error("archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit")]
Oversized { total: u64, limit: u64 },
/// No `theme.ron` at the archive root.
@@ -168,10 +166,7 @@ pub fn import_theme(zip_path: &Path) -> Result<ThemeId, ImportError> {
/// Tests use this directly with a `tempfile::TempDir` so they can
/// exercise the full extraction path without touching the global
/// [`crate::assets::user_dir::set_user_theme_dir`] override.
pub fn import_theme_into(
zip_path: &Path,
target_root: &Path,
) -> Result<ThemeId, ImportError> {
pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId, ImportError> {
let file = File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
@@ -189,11 +184,9 @@ pub fn import_theme_into(
required.push(manifest.back.clone());
for path in &required {
let bytes = read_archive_entry(&mut archive, path)?;
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| {
ImportError::InvalidSvg {
path: path.to_string_lossy().into_owned(),
source,
}
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| ImportError::InvalidSvg {
path: path.to_string_lossy().into_owned(),
source,
})?;
}
@@ -288,8 +281,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
if p.is_absolute() {
return false;
}
p.components()
.all(|c| matches!(c, Component::Normal(_)))
p.components().all(|c| matches!(c, Component::Normal(_)))
}
/// Reads `theme.ron` from the archive root and parses it.
@@ -373,11 +365,9 @@ fn write_archive_entry<R: io::Read + io::Seek>(
}
Err(e) => return Err(ImportError::OpenArchive(e)),
};
let safe = entry
.enclosed_name()
.ok_or_else(|| ImportError::ZipSlip {
path: name.to_owned(),
})?;
let safe = entry.enclosed_name().ok_or_else(|| ImportError::ZipSlip {
path: name.to_owned(),
})?;
if !is_safe_relative_path(&safe) {
return Err(ImportError::ZipSlip {
path: name.to_owned(),
@@ -457,8 +447,8 @@ mod tests {
use std::io::Write;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
use crate::theme::manifest::ThemeManifest;
use crate::theme::{CardKey, ThemeMeta};
@@ -516,11 +506,8 @@ mod tests {
/// given manifest id.
fn write_valid_zip(zip_path: &Path, id: &str) {
let manifest = full_manifest(id);
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(54);
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
@@ -530,8 +517,10 @@ mod tests {
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let entries_ref: Vec<(&str, Vec<u8>)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
write_zip(zip_path, &entries_ref);
}
@@ -570,10 +559,7 @@ mod tests {
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(
matches!(err, ImportError::MissingManifest),
"got {err:?}"
);
assert!(matches!(err, ImportError::MissingManifest), "got {err:?}");
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
@@ -588,11 +574,8 @@ mod tests {
let mut manifest = full_manifest("incomplete");
// Drop one face so validation surfaces MissingFaces.
manifest.faces.remove("hearts_ace");
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
@@ -603,8 +586,10 @@ mod tests {
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let entries_ref: Vec<(&str, Vec<u8>)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
write_zip(&zip_path, &entries_ref);
let target = TempDir::new().expect("target");
@@ -633,18 +618,12 @@ mod tests {
let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize];
write_zip(
&zip_path,
&[
(MANIFEST_NAME, b"".to_vec()),
("filler.bin", huge),
],
&[(MANIFEST_NAME, b"".to_vec()), ("filler.bin", huge)],
);
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(
matches!(err, ImportError::Oversized { .. }),
"got {err:?}"
);
assert!(matches!(err, ImportError::Oversized { .. }), "got {err:?}");
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
@@ -686,11 +665,8 @@ mod tests {
// Manifest is well-formed and validates, but we omit one of
// the SVGs from the archive to trigger the MissingFile path.
let manifest = full_manifest("missing_file_theme");
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
@@ -706,8 +682,10 @@ mod tests {
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let entries_ref: Vec<(&str, Vec<u8>)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
write_zip(&zip_path, &entries_ref);
let target = TempDir::new().expect("target");
+8 -6
View File
@@ -92,7 +92,12 @@ mod tests {
fn full_face_map() -> HashMap<String, PathBuf> {
CardKey::all()
.map(|k| (k.manifest_name(), PathBuf::from(format!("{}.svg", k.manifest_name()))))
.map(|k| {
(
k.manifest_name(),
PathBuf::from(format!("{}.svg", k.manifest_name())),
)
})
.collect()
}
@@ -167,11 +172,8 @@ mod tests {
back: PathBuf::from("back.svg"),
faces: full_face_map(),
};
let serialised = ron::ser::to_string_pretty(
&m,
ron::ser::PrettyConfig::default(),
)
.expect("serde_ron");
let serialised =
ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serde_ron");
let parsed: ThemeManifest = ron::from_str(&serialised).expect("ron parse");
assert_eq!(parsed.meta, m.meta);
assert_eq!(parsed.back, m.back);
+16 -6
View File
@@ -28,15 +28,15 @@ use thiserror::Error;
use solitaire_core::card::{Rank, Suit};
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest;
pub use plugin::{
ensure_theme_thumbnails, set_theme, ActiveTheme, ThemePlugin, ThemeThumbnailCache,
ThemeThumbnailPair, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX,
ActiveTheme, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX, ThemePlugin,
ThemeThumbnailCache, ThemeThumbnailPair, ensure_theme_thumbnails, set_theme,
};
pub use registry::{
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
ThemeEntry, ThemeRegistry, ThemeRegistryPlugin, build_registry, refresh_registry,
};
/// Hashable lookup key into [`CardTheme::faces`].
@@ -62,8 +62,18 @@ impl CardKey {
pub fn all() -> impl Iterator<Item = CardKey> {
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const RANKS: [Rank; 13] = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
SUITS
+24 -15
View File
@@ -194,8 +194,7 @@ fn sync_card_image_set_with_active_theme(
// Consume asset events — covers the normal first-load path.
for ev in events.read() {
let id = match ev {
AssetEvent::LoadedWithDependencies { id }
| AssetEvent::Modified { id } => *id,
AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => *id,
_ => continue,
};
if id == active_id {
@@ -245,9 +244,19 @@ fn sync_card_image_set_with_active_theme(
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for rank in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
] {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
@@ -348,10 +357,7 @@ fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8
/// [`Handle::default`] if rasterisation fails (malformed SVG, etc.) so
/// the picker can render a placeholder for broken themes without
/// crashing.
fn rasterize_preview_to_handle(
svg_bytes: &[u8],
images: &mut Assets<Image>,
) -> Handle<Image> {
fn rasterize_preview_to_handle(svg_bytes: &[u8], images: &mut Assets<Image>) -> Handle<Image> {
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
match rasterize_svg(svg_bytes, target) {
Ok(image) => images.add(image),
@@ -365,10 +371,7 @@ fn rasterize_preview_to_handle(
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
/// is [`Handle::default`] when the matching SVG could not be located
/// or rasterised.
fn generate_thumbnail_pair_for(
theme_id: &str,
images: &mut Assets<Image>,
) -> ThemeThumbnailPair {
fn generate_thumbnail_pair_for(theme_id: &str, images: &mut Assets<Image>) -> ThemeThumbnailPair {
let ace = read_theme_preview_svg_bytes(theme_id, PREVIEW_FACE_FILENAME)
.map(|b| rasterize_preview_to_handle(&b, images))
.unwrap_or_default();
@@ -543,8 +546,14 @@ mod tests {
);
// And the underlying images must actually exist in the assets
// collection — the handles are real, not dangling.
assert!(images.get(&pair.ace).is_some(), "ace image must be inserted");
assert!(images.get(&pair.back).is_some(), "back image must be inserted");
assert!(
images.get(&pair.ace).is_some(),
"ace image must be inserted"
);
assert!(
images.get(&pair.back).is_some(),
"back image must be inserted"
);
}
/// Test 2: when a theme is registered but its preview SVGs are not
+6 -4
View File
@@ -291,9 +291,7 @@ mod tests {
#[test]
fn nonexistent_user_dir_still_yields_bundled_entries() {
let registry = build_registry(Path::new(
"/definitely/not/a/real/path/should/not/panic",
));
let registry = build_registry(Path::new("/definitely/not/a/real/path/should/not/panic"));
assert_eq!(registry.len(), BUNDLED_COUNT);
assert!(registry.find("classic").is_some());
assert!(registry.find("dark").is_some());
@@ -353,7 +351,11 @@ mod tests {
write_manifest(&theme_dir, "../etc/passwd", "Evil");
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT, "escape attempt must not register");
assert_eq!(
registry.len(),
BUNDLED_COUNT,
"escape attempt must not register"
);
assert!(registry.find("classic").is_some());
}
+4 -1
View File
@@ -404,7 +404,10 @@ mod tests {
advance_by(&mut app, 0.4);
let session = app.world().resource::<TimeAttackResource>();
assert!(session.active, "session must stay active while a modal is open");
assert!(
session.active,
"session must stay active while a modal is open"
);
assert_eq!(session.remaining_secs, 5.0);
let events = app.world().resource::<Messages<TimeAttackEndedEvent>>();
+46 -38
View File
@@ -140,11 +140,7 @@ impl Plugin for UiFocusPlugin {
// resource.
.add_systems(
PostUpdate,
(
attach_focusable_to_modal_buttons,
auto_focus_on_modal_open,
)
.chain(),
(attach_focusable_to_modal_buttons, auto_focus_on_modal_open).chain(),
)
.add_systems(
Update,
@@ -433,11 +429,7 @@ fn handle_focus_keys(
// it matches the visual left → right layout.
let row_cycle: Vec<Entity> = siblings
.iter()
.filter(|e| {
focusables
.get(*e)
.is_ok_and(|(_, disabled)| !disabled)
})
.filter(|e| focusables.get(*e).is_ok_and(|(_, disabled)| !disabled))
.collect();
if !row_cycle.is_empty()
&& let Some(idx) = row_cycle.iter().position(|e| *e == target)
@@ -465,23 +457,23 @@ fn handle_focus_keys(
// 1. Any modal open ⇒ Modal(topmost scrim)
// 2. Any Hud-grouped focusable hovered ⇒ Hud
// 3. Otherwise ⇒ no-op
let active_group: FocusGroup = if let Some(active_scrim) = scrims.iter().max_by_key(|e| e.index()) {
// Pick the topmost modal as the active group. With multiple
// modals stacked (Pause + Forfeit confirm) the most-recently-
// spawned scrim has the highest entity index — same heuristic
// Phase 1 used.
FocusGroup::Modal(active_scrim)
} else if hud_interactions.iter().any(|(_, interaction, focusable)| {
matches!(interaction, Interaction::Hovered) && focusable.group == FocusGroup::Hud
}) {
FocusGroup::Hud
} else {
return;
};
let active_group: FocusGroup =
if let Some(active_scrim) = scrims.iter().max_by_key(|e| e.index()) {
// Pick the topmost modal as the active group. With multiple
// modals stacked (Pause + Forfeit confirm) the most-recently-
// spawned scrim has the highest entity index — same heuristic
// Phase 1 used.
FocusGroup::Modal(active_scrim)
} else if hud_interactions.iter().any(|(_, interaction, focusable)| {
matches!(interaction, Interaction::Hovered) && focusable.group == FocusGroup::Hud
}) {
FocusGroup::Hud
} else {
return;
};
let tab_pressed = keys.just_pressed(KeyCode::Tab);
let activate_pressed =
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
let activate_pressed = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
if !tab_pressed && !activate_pressed {
return;
@@ -657,28 +649,37 @@ fn update_focus_overlay(
mod tests {
use super::*;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
ButtonVariant, UiModalPlugin, spawn_modal, spawn_modal_actions, spawn_modal_button,
};
#[test]
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
let f = focus_ring_pulse_factor(0.0);
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
assert!(
(f - 0.825).abs() < 1e-5,
"factor at t=0 should be 0.825, got {f}"
);
}
#[test]
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
// sin(τ/4) = 1 → factor = 1.0.
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
assert!(
(f - 1.0).abs() < 1e-4,
"factor at peak should be 1.0, got {f}"
);
}
#[test]
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
// sin(3τ/4) = -1 → factor = 0.65.
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
assert!(
(f - 0.65).abs() < 1e-4,
"factor at trough should be 0.65, got {f}"
);
}
#[test]
@@ -755,12 +756,16 @@ mod tests {
// `auto_focus_on_modal_open` execute.
app.update();
let mut a_query = app.world_mut().query_filtered::<Entity, With<TestButtonA>>();
let mut a_query = app
.world_mut()
.query_filtered::<Entity, With<TestButtonA>>();
let a = a_query
.iter(app.world())
.next()
.expect("button A should have been spawned");
let mut b_query = app.world_mut().query_filtered::<Entity, With<TestButtonB>>();
let mut b_query = app
.world_mut()
.query_filtered::<Entity, With<TestButtonB>>();
let b = b_query
.iter(app.world())
.next()
@@ -807,11 +812,17 @@ mod tests {
};
app.update();
let mut q_a = app.world_mut().query_filtered::<Entity, With<TestButtonA>>();
let mut q_a = app
.world_mut()
.query_filtered::<Entity, With<TestButtonA>>();
let a = q_a.iter(app.world()).next().expect("A spawned");
let mut q_b = app.world_mut().query_filtered::<Entity, With<TestButtonB>>();
let mut q_b = app
.world_mut()
.query_filtered::<Entity, With<TestButtonB>>();
let b = q_b.iter(app.world()).next().expect("B spawned");
let mut q_c = app.world_mut().query_filtered::<Entity, With<TestButtonC>>();
let mut q_c = app
.world_mut()
.query_filtered::<Entity, With<TestButtonC>>();
let c = q_c.iter(app.world()).next().expect("C spawned");
(scrim, a, b, c)
}
@@ -864,10 +875,7 @@ mod tests {
/// Crucially this system has **no** ordering relationship with
/// `UiFocusPlugin`'s chain — exactly the situation that surfaces the
/// "focus arrives one frame late" bug in production.
fn spawn_modal_via_system(
mut commands: Commands,
mut trigger: ResMut<SpawnModalTrigger>,
) {
fn spawn_modal_via_system(mut commands: Commands, mut trigger: ResMut<SpawnModalTrigger>) {
if !trigger.0 {
return;
}
+18 -4
View File
@@ -294,7 +294,10 @@ impl HighContrastBackground {
/// Convenience constructor — HC colour defaults to
/// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color, hc_color: BORDER_SUBTLE_HC }
Self {
default_color,
hc_color: BORDER_SUBTLE_HC,
}
}
/// Constructor for sites whose HC colour differs from the standard
@@ -305,7 +308,10 @@ impl HighContrastBackground {
default_color: bevy::prelude::Color,
hc_color: bevy::prelude::Color,
) -> Self {
Self { default_color, hc_color }
Self {
default_color,
hc_color,
}
}
}
@@ -621,7 +627,9 @@ mod tests {
/// honest if someone tweaks values later.
#[test]
fn spacing_scale_is_a_4_multiple_geometric_progression() {
for v in [SPACE_1, SPACE_2, SPACE_3, SPACE_4, SPACE_5, SPACE_6, SPACE_7] {
for v in [
SPACE_1, SPACE_2, SPACE_3, SPACE_4, SPACE_5, SPACE_6, SPACE_7,
] {
assert!(v > 0.0, "spacing tokens must be positive");
assert!(
(v.rem_euclid(4.0)).abs() < f32::EPSILON,
@@ -633,7 +641,13 @@ mod tests {
/// Type scale is monotonically decreasing display → caption.
#[test]
fn type_scale_is_monotonically_decreasing() {
let scale = [TYPE_DISPLAY, TYPE_HEADLINE, TYPE_BODY_LG, TYPE_BODY, TYPE_CAPTION];
let scale = [
TYPE_DISPLAY,
TYPE_HEADLINE,
TYPE_BODY_LG,
TYPE_BODY,
TYPE_CAPTION,
];
for window in scale.windows(2) {
assert!(
window[0] > window[1],
+16 -24
View File
@@ -93,10 +93,7 @@ impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TooltipState>()
.add_systems(Startup, spawn_tooltip_overlay)
.add_systems(
Update,
(track_tooltip_hover, show_or_hide_tooltip).chain(),
);
.add_systems(Update, (track_tooltip_hover, show_or_hide_tooltip).chain());
}
}
@@ -222,10 +219,7 @@ fn spawn_tooltip_overlay(
/// reveal delay.
fn track_tooltip_hover(
time: Res<Time>,
interactions: Query<
(Entity, &Interaction, Option<&Tooltip>),
Changed<Interaction>,
>,
interactions: Query<(Entity, &Interaction, Option<&Tooltip>), Changed<Interaction>>,
mut state: ResMut<TooltipState>,
) {
for (entity, interaction, tooltip) in &interactions {
@@ -407,9 +401,9 @@ mod tests {
/// `app.update()`. Mirrors the helper in `ui_modal::tests` and
/// `hud_plugin::tests`.
fn set_manual_time_step(app: &mut App, secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// Reads the current overlay visibility. Panics if the singleton is
@@ -440,18 +434,12 @@ mod tests {
fn spawn_hovered_tooltip(app: &mut App, label: &'static str) -> Entity {
let id = app
.world_mut()
.spawn((
Node::default(),
Interaction::Hovered,
Tooltip::new(label),
))
.spawn((Node::default(), Interaction::Hovered, Tooltip::new(label)))
.id();
// Mark the Interaction Changed by re-inserting it. `Changed`
// requires component mutation since the previous tick; spawn
// already counts, but a follow-up insert is the explicit signal.
app.world_mut()
.entity_mut(id)
.insert(Interaction::Hovered);
app.world_mut().entity_mut(id).insert(Interaction::Hovered);
id
}
@@ -536,9 +524,7 @@ mod tests {
// Unhover. `track_tooltip_hover` clears the state on the next
// tick because the entity transitions Hovered → None.
app.world_mut()
.entity_mut(target)
.insert(Interaction::None);
app.world_mut().entity_mut(target).insert(Interaction::None);
app.update();
assert!(
@@ -590,12 +576,18 @@ mod tests {
#[test]
fn tooltip_should_show_respects_delay() {
// delay == 0 ("Instant"): any elapsed (including zero) shows.
assert!(tooltip_should_show(0.0, 0.0), "instant delay must show on first tick");
assert!(
tooltip_should_show(0.0, 0.0),
"instant delay must show on first tick"
);
assert!(tooltip_should_show(0.5, 0.0));
// Standard non-zero delay.
assert!(!tooltip_should_show(0.4, 0.5), "elapsed < delay must hide");
assert!(tooltip_should_show(0.5, 0.5), "elapsed == delay must show (boundary)");
assert!(
tooltip_should_show(0.5, 0.5),
"elapsed == delay must show (boundary)"
);
assert!(tooltip_should_show(0.6, 0.5), "elapsed > delay must show");
// Larger delay (max-end of the slider).
+26 -14
View File
@@ -5,8 +5,8 @@
use bevy::prelude::*;
use chrono::Local;
use solitaire_data::{
current_iso_week_key, save_progress_to, weekly_goal_by_id, WeeklyGoalContext, WEEKLY_GOALS,
WEEKLY_GOAL_XP,
WEEKLY_GOAL_XP, WEEKLY_GOALS, WeeklyGoalContext, current_iso_week_key, save_progress_to,
weekly_goal_by_id,
};
use crate::events::{GameWonEvent, XpAwardedEvent};
@@ -51,9 +51,10 @@ fn roll_weekly_goals_on_startup(
let week_key = current_iso_week_key(Local::now().date_naive());
if progress.0.roll_weekly_goals_if_new_week(&week_key)
&& let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after weekly reset on startup: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after weekly reset on startup: {e}");
}
}
fn evaluate_weekly_goals(
@@ -114,9 +115,10 @@ fn evaluate_weekly_goals(
if any_change
&& let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after weekly goal update: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after weekly goal update: {e}");
}
}
/// Resolve a goal id to its description (used for toasts).
@@ -196,10 +198,14 @@ mod tests {
// keeping the XP delta predictable.
{
let mut p = app.world_mut().resource_mut::<ProgressResource>();
p.0.weekly_goal_progress.insert("weekly_3_fast".to_string(), 2);
p.0.weekly_goal_progress.insert("weekly_1_under_five".to_string(), 1);
p.0.weekly_goal_progress.insert("weekly_5_wins".to_string(), 5);
p.0.weekly_goal_progress.insert("weekly_3_no_undo".to_string(), 3);
p.0.weekly_goal_progress
.insert("weekly_3_fast".to_string(), 2);
p.0.weekly_goal_progress
.insert("weekly_1_under_five".to_string(), 1);
p.0.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 5);
p.0.weekly_goal_progress
.insert("weekly_3_no_undo".to_string(), 3);
}
// Match the current ISO week key so roll_weekly_goals doesn't clear it.
let key = current_iso_week_key(Local::now().date_naive());
@@ -263,7 +269,10 @@ mod tests {
fn weekly_bonus_xp_fires_levelup_when_threshold_crossed() {
let mut app = headless_app();
// Set XP just below the first level boundary (500) so the 75-XP bonus crosses it.
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 430;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.total_xp = 430;
// Pre-set goal to 2/3 so the next fast win completes it.
app.world_mut()
.resource_mut::<ProgressResource>()
@@ -285,7 +294,10 @@ mod tests {
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert!(!fired.is_empty(), "LevelUpEvent must fire when weekly bonus pushes past a level threshold");
assert!(
!fired.is_empty(),
"LevelUpEvent must fire when weekly bonus pushes past a level threshold"
);
}
#[test]
+196 -76
View File
@@ -26,11 +26,11 @@ use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
Z_WIN_CASCADE,
Z_WIN_CASCADE, scaled_duration,
};
// ---------------------------------------------------------------------------
@@ -227,9 +227,7 @@ impl Plugin for WinSummaryPlugin {
// the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems(
Update,
cache_win_data
.after(GameMutation)
.before(StatsUpdate),
cache_win_data.after(GameMutation).before(StatsUpdate),
)
.add_systems(
Update,
@@ -351,10 +349,17 @@ impl ScoreBreakdown {
} else {
scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32
};
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
let no_undo_bonus = if undo_count == 0 {
SCORE_NO_UNDO_BONUS
} else {
0
};
let multiplier = match mode {
GameMode::Zen => 0.0,
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
GameMode::Classic
| GameMode::Challenge
| GameMode::TimeAttack
| GameMode::Difficulty(_) => 1.0,
};
Self {
base,
@@ -549,10 +554,9 @@ fn spawn_win_summary_after_delay(
// intensity is left at its design-token value because amplitude
// does not benefit from "fast" / "instant" scaling — at Instant
// speed the duration is zero anyway, suppressing the shake.
let speed = settings.as_ref().map_or(
solitaire_data::AnimSpeed::Normal,
|s| s.0.animation_speed,
);
let speed = settings
.as_ref()
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed);
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
shake.remaining = scaled;
shake.total = scaled;
@@ -632,25 +636,16 @@ fn handle_win_summary_buttons(
new_game.write(NewGameRequestEvent::default());
}
WinSummaryButton::WatchReplay => {
let latest = history
.as_ref()
.and_then(|h| h.0.replays.last())
.cloned();
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
match (latest, playback.as_mut()) {
(Some(replay), Some(pb)) => {
for entity in &overlays {
commands.entity(entity).despawn();
}
crate::replay_playback::start_replay_playback(
&mut commands,
pb,
replay,
);
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
}
(Some(_), None) => {
toast.write(InfoToastEvent(
"Replay playback not available".to_string(),
));
toast.write(InfoToastEvent("Replay playback not available".to_string()));
}
(None, _) => {
toast.write(InfoToastEvent("No replay saved yet".to_string()));
@@ -710,7 +705,11 @@ fn apply_screen_shake(
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
// duration if `total` is zero (older armings or test setups that bypass
// `spawn_win_summary_after_delay`) so we never divide by zero.
let total = if shake.total > 0.0 { shake.total } else { SHAKE_DURATION_SECS };
let total = if shake.total > 0.0 {
shake.total
} else {
SHAKE_DURATION_SECS
};
let decay = shake.remaining / total;
let elapsed = time.elapsed_secs();
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
@@ -792,7 +791,10 @@ fn spawn_overlay(
// Heading
card.spawn((
Text::new("You Won!"),
TextFont { font_size: TYPE_DISPLAY, ..default() },
TextFont {
font_size: TYPE_DISPLAY,
..default()
},
TextColor(ACCENT_PRIMARY),
));
@@ -800,7 +802,10 @@ fn spawn_overlay(
if let Some(level) = challenge_level {
card.spawn((
Text::new(format!("Challenge {level} complete!")),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(STATE_INFO),
));
}
@@ -810,7 +815,10 @@ fn spawn_overlay(
if pending.new_record {
card.spawn((
Text::new("New Record!"),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(STATE_WARNING),
));
}
@@ -822,14 +830,20 @@ fn spawn_overlay(
// Time
card.spawn((
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(TEXT_PRIMARY),
));
// XP total
card.spawn((
Text::new(format!("XP earned: +{}", pending.xp)),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(STATE_SUCCESS),
));
@@ -837,7 +851,10 @@ fn spawn_overlay(
if !pending.xp_detail.is_empty() {
card.spawn((
Text::new(pending.xp_detail.clone()),
TextFont { font_size: 15.0, ..default() },
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
@@ -874,7 +891,10 @@ fn spawn_overlay(
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
@@ -894,7 +914,10 @@ fn spawn_overlay(
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
@@ -915,7 +938,10 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
card.spawn((
Text::new("Achievements Unlocked"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
@@ -923,7 +949,10 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
for name in &names[..shown] {
card.spawn((
Text::new(format!(" {name}")),
TextFont { font_size: 16.0, ..default() },
TextFont {
font_size: 16.0,
..default()
},
TextColor(TEXT_PRIMARY),
));
}
@@ -932,7 +961,10 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
if overflow > 0 {
card.spawn((
Text::new(format!(" ...and {overflow} more")),
TextFont { font_size: 15.0, ..default() },
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
@@ -1091,13 +1123,19 @@ fn spawn_breakdown_row(
// Label — left-aligned.
row.spawn((
Text::new(label.to_string()),
TextFont { font_size: TYPE_BODY, ..default() },
TextFont {
font_size: TYPE_BODY,
..default()
},
TextColor(label_color_with_alpha),
));
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
row.spawn((
Text::new(value),
TextFont { font_size: TYPE_BODY, ..default() },
TextFont {
font_size: TYPE_BODY,
..default()
},
TextColor(value_color_with_alpha),
));
});
@@ -1170,7 +1208,10 @@ mod tests {
app.add_plugins(MinimalPlugins)
.add_plugins(WinSummaryPlugin)
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(0, solitaire_core::game_state::DrawMode::DrawOne)))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::game_state::DrawMode::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.update();
app
@@ -1282,17 +1323,18 @@ mod tests {
app.update();
// Confirm it was recorded.
assert_eq!(
app.world().resource::<SessionAchievements>().names.len(),
1
);
assert_eq!(app.world().resource::<SessionAchievements>().names.len(), 1);
// Fire NewGameRequestEvent — should clear the list.
app.world_mut().write_message(NewGameRequestEvent::default());
app.world_mut()
.write_message(NewGameRequestEvent::default());
app.update();
assert!(
app.world().resource::<SessionAchievements>().names.is_empty(),
app.world()
.resource::<SessionAchievements>()
.names
.is_empty(),
"session achievements must be cleared on NewGameRequestEvent"
);
}
@@ -1332,7 +1374,10 @@ mod tests {
app.update();
assert!(
app.world().resource::<SessionAchievements>().names.is_empty(),
app.world()
.resource::<SessionAchievements>()
.names
.is_empty(),
"session achievements must be cleared when a mode-switch NewGameRequestEvent fires"
);
}
@@ -1341,15 +1386,20 @@ mod tests {
fn cache_win_data_sets_score_and_time() {
let mut app = make_app();
app.world_mut()
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
app.world_mut().write_message(GameWonEvent {
score: 1234,
time_seconds: 90,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert_eq!(pending.score, 1234);
assert_eq!(pending.time_seconds, 90);
// 90s < 120s → speed bonus present; default game has undo_count=0 → no-undo bonus present.
assert!(!pending.xp_detail.is_empty(), "xp_detail must be populated on GameWonEvent");
assert!(
!pending.xp_detail.is_empty(),
"xp_detail must be populated on GameWonEvent"
);
assert!(pending.xp_detail.contains("+50 base"));
}
@@ -1357,7 +1407,10 @@ mod tests {
fn cache_win_data_sets_xp_from_xp_awarded_event() {
let mut app = make_app();
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
app.update();
@@ -1369,12 +1422,17 @@ mod tests {
fn game_won_event_arms_screen_shake() {
let mut app = make_app();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let shake = app.world().resource::<ScreenShakeResource>();
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
assert!(
shake.remaining > 0.0,
"shake must be armed after GameWonEvent"
);
}
// -----------------------------------------------------------------------
@@ -1387,8 +1445,10 @@ mod tests {
// Any positive-score win should be flagged as a new record.
let mut app = make_app();
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 120,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1405,12 +1465,17 @@ mod tests {
}
// Score 500 beats previous best of 400.
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert!(pending.new_record, "beating best score should set new_record");
assert!(
pending.new_record,
"beating best score should set new_record"
);
}
#[test]
@@ -1423,12 +1488,17 @@ mod tests {
}
// Score 500 does not beat 800, but time 100 < 200.
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert!(pending.new_record, "beating fastest time should set new_record");
assert!(
pending.new_record,
"beating fastest time should set new_record"
);
}
#[test]
@@ -1441,8 +1511,10 @@ mod tests {
}
// Score 500 < 800 and time 120 > 60 — neither record broken.
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 120,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1472,8 +1544,10 @@ mod tests {
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
}
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1488,8 +1562,10 @@ mod tests {
fn classic_win_leaves_challenge_level_none() {
let mut app = make_app();
// Default game mode is Classic — challenge_level should stay None.
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1519,8 +1595,10 @@ mod tests {
game.0.undo_count = 2;
}
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1552,7 +1630,10 @@ mod tests {
fn score_breakdown_zen_mode_zeros_total() {
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
assert!(
bd.shows_multiplier_row(),
"Zen ×0 must display the multiplier row"
);
assert_eq!(bd.total(), 0);
}
@@ -1616,7 +1697,10 @@ mod tests {
#[test]
fn score_breakdown_applies_time_bonus_multiplier() {
let raw = compute_time_bonus(120);
assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier");
assert_eq!(
raw, 5833,
"sanity-check raw bonus before testing the multiplier"
);
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5);
let expected = ((raw as f32) * 0.5).round() as i32;
@@ -1712,9 +1796,27 @@ mod tests {
// Frame 1: `time.delta` is 0 (first frame), so only row0
// (delay = 0) should reveal.
app.update();
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(
app.world()
.entity(row0)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
assert!(
!app.world()
.entity(row1)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
assert!(
!app.world()
.entity(row2)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
// Advance time by one stagger interval — row1 should reveal.
{
@@ -1722,8 +1824,20 @@ mod tests {
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
}
app.update();
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(
app.world()
.entity(row1)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
assert!(
!app.world()
.entity(row2)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
// Advance again — row2 should reveal.
{
@@ -1731,7 +1845,13 @@ mod tests {
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
}
app.update();
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(
app.world()
.entity(row2)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
}
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already