From 6e407a3ea73d2b2f2def8802594543d5582a9ca9 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 28 May 2026 13:07:22 -0700 Subject: [PATCH] fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68) - #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> --- solitaire_app/src/lib.rs | 11 +- solitaire_assetgen/src/bin/gen_art.rs | 270 +++++- .../src/bin/gen_difficulty_seeds.rs | 43 +- solitaire_assetgen/src/bin/gen_seeds.rs | 44 +- solitaire_core/src/achievement.rs | 111 ++- solitaire_core/src/card.rs | 60 +- solitaire_core/src/deck.rs | 64 +- solitaire_core/src/game_state.rs | 789 +++++++++++---- solitaire_core/src/pile.rs | 42 +- solitaire_core/src/rules.rs | 22 +- solitaire_core/src/scoring.rs | 22 +- solitaire_core/src/solver.rs | 245 +++-- solitaire_data/src/achievements.rs | 13 +- solitaire_data/src/android_keystore.rs | 43 +- solitaire_data/src/difficulty_seeds.rs | 6 +- solitaire_data/src/lib.rs | 48 +- solitaire_data/src/matomo_client.rs | 8 +- solitaire_data/src/platform.rs | 5 +- solitaire_data/src/progress.rs | 7 +- solitaire_data/src/replay.rs | 60 +- solitaire_data/src/settings.rs | 52 +- solitaire_data/src/stats.rs | 16 +- solitaire_data/src/storage.rs | 28 +- solitaire_data/src/sync_client.rs | 77 +- solitaire_data/tests/sync_round_trip.rs | 64 +- .../examples/card_face_generator.rs | 4 +- solitaire_engine/examples/card_face_poc.rs | 12 +- solitaire_engine/examples/icon_generator.rs | 2 +- solitaire_engine/src/achievement_plugin.rs | 167 ++-- solitaire_engine/src/analytics_plugin.rs | 30 +- solitaire_engine/src/android_clipboard.rs | 2 +- solitaire_engine/src/animation_plugin.rs | 128 ++- solitaire_engine/src/assets/mod.rs | 4 +- solitaire_engine/src/assets/sources.rs | 28 +- solitaire_engine/src/assets/svg_loader.rs | 11 +- solitaire_engine/src/assets/user_dir.rs | 5 +- solitaire_engine/src/audio_plugin.rs | 46 +- solitaire_engine/src/auto_complete_plugin.rs | 47 +- solitaire_engine/src/avatar_plugin.rs | 15 +- .../src/card_animation/animation.rs | 18 +- solitaire_engine/src/card_animation/curves.rs | 38 +- .../src/card_animation/diagnostics.rs | 5 +- .../src/card_animation/interaction.rs | 12 +- solitaire_engine/src/card_animation/mod.rs | 50 +- solitaire_engine/src/card_animation/timing.rs | 10 +- solitaire_engine/src/card_animation/tuning.rs | 17 +- solitaire_engine/src/card_plugin.rs | 614 +++++++++--- solitaire_engine/src/challenge_plugin.rs | 25 +- solitaire_engine/src/cursor_plugin.rs | 76 +- .../src/daily_challenge_plugin.rs | 148 +-- solitaire_engine/src/difficulty_plugin.rs | 10 +- solitaire_engine/src/feedback_anim_plugin.rs | 34 +- solitaire_engine/src/game_plugin.rs | 671 ++++++++----- solitaire_engine/src/help_plugin.rs | 226 ++++- solitaire_engine/src/home_plugin.rs | 115 ++- solitaire_engine/src/hud_plugin.rs | 268 ++++-- solitaire_engine/src/input_plugin.rs | 901 +++++++++++------- solitaire_engine/src/layout.rs | 17 +- solitaire_engine/src/leaderboard_plugin.rs | 156 ++- solitaire_engine/src/lib.rs | 159 ++-- solitaire_engine/src/onboarding_plugin.rs | 132 ++- solitaire_engine/src/pause_plugin.rs | 150 +-- solitaire_engine/src/pending_hint.rs | 50 +- solitaire_engine/src/play_by_seed_plugin.rs | 58 +- solitaire_engine/src/progress_plugin.rs | 31 +- solitaire_engine/src/radial_menu.rs | 109 ++- solitaire_engine/src/replay_overlay.rs | 135 ++- solitaire_engine/src/replay_playback.rs | 21 +- solitaire_engine/src/resources.rs | 1 - solitaire_engine/src/safe_area.rs | 73 +- solitaire_engine/src/selection_plugin.rs | 280 ++++-- solitaire_engine/src/settings_plugin.rs | 310 ++++-- solitaire_engine/src/splash_plugin.rs | 101 +- solitaire_engine/src/sync_plugin.rs | 75 +- solitaire_engine/src/sync_setup_plugin.rs | 81 +- solitaire_engine/src/table_plugin.rs | 92 +- solitaire_engine/src/theme/importer.rs | 88 +- solitaire_engine/src/theme/manifest.rs | 14 +- solitaire_engine/src/theme/mod.rs | 22 +- solitaire_engine/src/theme/plugin.rs | 39 +- solitaire_engine/src/theme/registry.rs | 10 +- solitaire_engine/src/time_attack_plugin.rs | 5 +- solitaire_engine/src/ui_focus.rs | 84 +- solitaire_engine/src/ui_theme.rs | 22 +- solitaire_engine/src/ui_tooltip.rs | 40 +- solitaire_engine/src/weekly_goals_plugin.rs | 40 +- solitaire_engine/src/win_summary_plugin.rs | 272 ++++-- solitaire_engine/tests/card_face_svg_pin.rs | 6 +- solitaire_engine/tests/icon_svg_pin.rs | 11 +- solitaire_server/src/auth.rs | 107 +-- solitaire_server/src/challenge.rs | 32 +- solitaire_server/src/error.rs | 22 +- solitaire_server/src/leaderboard.rs | 34 +- solitaire_server/src/lib.rs | 20 +- solitaire_server/src/main.rs | 10 +- solitaire_server/src/middleware.rs | 48 +- solitaire_server/src/replays.rs | 50 +- solitaire_server/src/sync.rs | 52 +- solitaire_server/tests/server_tests.rs | 241 ++--- solitaire_sync/src/achievements.rs | 6 +- solitaire_sync/src/lib.rs | 2 +- solitaire_sync/src/merge.rs | 192 +++- solitaire_sync/src/progress.rs | 37 +- solitaire_sync/src/stats.rs | 22 +- 104 files changed, 6356 insertions(+), 3092 deletions(-) diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index cb3c812..2300891 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -364,17 +364,12 @@ fn android_main(android_app: bevy::android::android_activity::AndroidApp) { /// unchanged. If the data directory is unavailable, the wrapper silently /// falls through — the default hook handles output either way. fn install_crash_log_hook() { - let crash_log_path = settings_file_path().and_then(|p| { - p.parent() - .map(|parent| parent.join("crash.log")) - }); + let crash_log_path = + settings_file_path().and_then(|p| p.parent().map(|parent| parent.join("crash.log"))); let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { if let Some(path) = crash_log_path.as_ref() - && let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open(path) + && let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { // Plain unix-seconds timestamp keeps the format trivially // parseable and avoids pulling in chrono just for this. diff --git a/solitaire_assetgen/src/bin/gen_art.rs b/solitaire_assetgen/src/bin/gen_art.rs index 1c63a00..961360e 100644 --- a/solitaire_assetgen/src/bin/gen_art.rs +++ b/solitaire_assetgen/src/bin/gen_art.rs @@ -30,7 +30,9 @@ fn suit_color(suit: u8) -> [u8; 4] { } fn rank_str(rank: u8) -> &'static str { - ["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize] + [ + "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", + ][rank as usize] } // --------------------------------------------------------------------------- @@ -86,13 +88,15 @@ impl Canvas { } fn set(&mut self, x: i32, y: i32, c: [u8; 4]) { - if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; } + if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { + return; + } let i = (y as u32 * W + x as u32) as usize * 4; let a = c[3] as f32 / 255.0; if a >= 0.99 { self.data[i..i + 4].copy_from_slice(&c); } else if a > 0.01 { - self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8; + self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8; self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8; self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8; self.data[i + 3] = 255; @@ -172,27 +176,36 @@ fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) { let oy = cy - sz * 0.04; cv.circle(cx - sz * 0.22, oy, r, c); cv.circle(cx + sz * 0.22, oy, r, c); - cv.triangle([ - (cx - sz * 0.52, oy + r * 0.4), - (cx + sz * 0.52, oy + r * 0.4), - (cx, cy + sz * 0.52), - ], c); + cv.triangle( + [ + (cx - sz * 0.52, oy + r * 0.4), + (cx + sz * 0.52, oy + r * 0.4), + (cx, cy + sz * 0.52), + ], + c, + ); } fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) { - cv.triangle([ - (cx, cy - sz * 0.52), - (cx - sz * 0.52, cy + sz * 0.1), - (cx + sz * 0.52, cy + sz * 0.1), - ], c); + cv.triangle( + [ + (cx, cy - sz * 0.52), + (cx - sz * 0.52, cy + sz * 0.1), + (cx + sz * 0.52, cy + sz * 0.1), + ], + c, + ); cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c); cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c); // stem + base - cv.triangle([ - (cx, cy + sz * 0.12), - (cx - sz * 0.13, cy + sz * 0.5), - (cx + sz * 0.13, cy + sz * 0.5), - ], c); + cv.triangle( + [ + (cx, cy + sz * 0.12), + (cx - sz * 0.13, cy + sz * 0.5), + (cx + sz * 0.13, cy + sz * 0.5), + ], + c, + ); cv.fill_rect( (cx - sz * 0.26) as i32, (cy + sz * 0.43) as i32, @@ -231,7 +244,15 @@ fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) { // Text rendering via ab_glyph // --------------------------------------------------------------------------- -fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) { +fn draw_text( + cv: &mut Canvas, + font: &FontRef<'_>, + text: &str, + px: f32, + left: f32, + top: f32, + c: [u8; 4], +) { let scale = PxScale::from(px); let baseline = top + font.as_scaled(scale).ascent(); let mut x = left; @@ -278,12 +299,63 @@ fn pip_positions(rank: u8) -> &'static [(f32, f32)] { 1 => &[(0.5, 0.2), (0.5, 0.8)], 2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)], 3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)], - 4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)], - 5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)], - 6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)], - 7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)], - 8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)], - 9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)], + 4 => &[ + (0.25, 0.18), + (0.75, 0.18), + (0.5, 0.5), + (0.25, 0.82), + (0.75, 0.82), + ], + 5 => &[ + (0.25, 0.12), + (0.75, 0.12), + (0.25, 0.5), + (0.75, 0.5), + (0.25, 0.88), + (0.75, 0.88), + ], + 6 => &[ + (0.25, 0.1), + (0.75, 0.1), + (0.5, 0.31), + (0.25, 0.5), + (0.75, 0.5), + (0.25, 0.9), + (0.75, 0.9), + ], + 7 => &[ + (0.25, 0.1), + (0.75, 0.1), + (0.5, 0.28), + (0.25, 0.48), + (0.75, 0.48), + (0.5, 0.70), + (0.25, 0.9), + (0.75, 0.9), + ], + 8 => &[ + (0.25, 0.1), + (0.75, 0.1), + (0.25, 0.35), + (0.75, 0.35), + (0.5, 0.5), + (0.25, 0.65), + (0.75, 0.65), + (0.25, 0.9), + (0.75, 0.9), + ], + 9 => &[ + (0.25, 0.09), + (0.75, 0.09), + (0.5, 0.27), + (0.25, 0.44), + (0.75, 0.44), + (0.25, 0.56), + (0.75, 0.56), + (0.5, 0.73), + (0.25, 0.91), + (0.75, 0.91), + ], _ => &[], } } @@ -327,14 +399,28 @@ fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas { let tl_x = 6.0f32; let tl_y = 5.0f32; draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc); - draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc); + draw_suit( + &mut cv, + tl_x + suit_sz * 0.62, + tl_y + rh + 2.0 + suit_sz * 0.75, + suit_sz, + suit, + sc, + ); // Bottom-right corner (right-aligned rank, suit above it) let br_rx = W as f32 - 6.0; let br_by = H as f32 - 5.0; let br_ty = br_by - corner_h; draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc); - draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc); + draw_suit( + &mut cv, + br_rx - suit_sz * 0.62, + br_ty + rh + 2.0 + suit_sz * 0.75, + suit_sz, + suit, + sc, + ); // Center content if rank >= 10 { @@ -346,7 +432,14 @@ fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas { let big_y = H as f32 * 0.28; draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc); let sym_sz = 22.0f32; - draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc); + draw_suit( + &mut cv, + W as f32 * 0.5, + big_y + big_h + sym_sz * 1.0, + sym_sz, + suit, + sc, + ); } else { // Pip cards let pip_sz = if rank == 0 { @@ -375,15 +468,17 @@ fn save_card_png(path: &Path, cv: &Canvas) { } fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) { - let file = File::create(path) - .unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display())); + let file = + File::create(path).unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display())); let mut bw = BufWriter::new(file); let mut enc = png::Encoder::new(&mut bw, w, h); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); - let mut writer = enc.write_header() + let mut writer = enc + .write_header() .unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display())); - writer.write_image_data(data) + writer + .write_image_data(data) .unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display())); } @@ -401,8 +496,18 @@ fn make_back_0() -> Canvas { // 2-pixel border let bw = 4i32; - for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, LIGHT); cv.set(x, H as i32 - 1 - t, LIGHT); } } - for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, LIGHT); cv.set(W as i32 - 1 - t, y, LIGHT); } } + for x in 0..W as i32 { + for t in 0..bw { + cv.set(x, t, LIGHT); + cv.set(x, H as i32 - 1 - t, LIGHT); + } + } + for y in 0..H as i32 { + for t in 0..bw { + cv.set(t, y, LIGHT); + cv.set(W as i32 - 1 - t, y, LIGHT); + } + } // Diamond grid: row/col spacing let gx = 18.0f32; @@ -455,8 +560,18 @@ fn make_back_1() -> Canvas { // 4-pixel border let bw = 4i32; - for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } } - for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } } + for x in 0..W as i32 { + for t in 0..bw { + cv.set(x, t, BORDER); + cv.set(x, H as i32 - 1 - t, BORDER); + } + } + for y in 0..H as i32 { + for t in 0..bw { + cv.set(t, y, BORDER); + cv.set(W as i32 - 1 - t, y, BORDER); + } + } cv } @@ -470,8 +585,18 @@ fn make_back_2() -> Canvas { // 4-pixel border let bw = 4i32; - for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } } - for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } } + for x in 0..W as i32 { + for t in 0..bw { + cv.set(x, t, BORDER); + cv.set(x, H as i32 - 1 - t, BORDER); + } + } + for y in 0..H as i32 { + for t in 0..bw { + cv.set(t, y, BORDER); + cv.set(W as i32 - 1 - t, y, BORDER); + } + } // Circle array (staggered rows) let gx = 16.0f32; @@ -513,8 +638,18 @@ fn make_back_3() -> Canvas { // 4-pixel border let bw = 4i32; - for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } } - for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } } + for x in 0..W as i32 { + for t in 0..bw { + cv.set(x, t, BORDER); + cv.set(x, H as i32 - 1 - t, BORDER); + } + } + for y in 0..H as i32 { + for t in 0..bw { + cv.set(t, y, BORDER); + cv.set(W as i32 - 1 - t, y, BORDER); + } + } cv } @@ -543,8 +678,18 @@ fn make_back_4() -> Canvas { // 4-pixel border let bw = 4i32; - for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } } - for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } } + for x in 0..W as i32 { + for t in 0..bw { + cv.set(x, t, BORDER); + cv.set(x, H as i32 - 1 - t, BORDER); + } + } + for y in 0..H as i32 { + for t in 0..bw { + cv.set(t, y, BORDER); + cv.set(W as i32 - 1 - t, y, BORDER); + } + } cv } @@ -574,7 +719,7 @@ fn make_bg_0() -> Canvas { fn make_bg_1() -> Canvas { const BASE: [u8; 4] = [0x40, 0x2D, 0x1A, 0xFF]; const PLANK_EDGE: [u8; 4] = [0x28, 0x1A, 0x0A, 0xFF]; // dark plank separator - const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak + const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak let mut cv = Canvas::new(); cv.fill_solid(BASE); // Horizontal plank edges every 24 px @@ -585,7 +730,9 @@ fn make_bg_1() -> Canvas { // Grain lines within each plank (every 3 px between plank edges) for y in (0..H as i32).step_by(3) { // Skip the plank edge rows - if y % 24 < 2 { continue; } + if y % 24 < 2 { + continue; + } cv.hline(y, 2, W as i32 - 3, GRAIN); } cv @@ -608,7 +755,11 @@ fn make_bg_2() -> Canvas { let mut cx = gx * 0.5 + offset; while cx < W as f32 { // alternate bright/dim to give depth - let c = if (row + (cx / gx) as u32).is_multiple_of(3) { STAR_A } else { STAR_B }; + let c = if (row + (cx / gx) as u32).is_multiple_of(3) { + STAR_A + } else { + STAR_B + }; cv.circle(cx, cy, 1.0, c); cx += gx; } @@ -679,12 +830,13 @@ fn main() { let font_path = root.join("assets/fonts/main.ttf"); let font_bytes = std::fs::read(&font_path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display())); - let font = FontRef::try_from_slice(&font_bytes) - .expect("failed to parse assets/fonts/main.ttf"); + let font = FontRef::try_from_slice(&font_bytes).expect("failed to parse assets/fonts/main.ttf"); // 52 card faces let suits = ["c", "d", "h", "s"]; - let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"]; + let ranks = [ + "a", "2", "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k", + ]; for suit in 0u8..4 { for rank in 0u8..13 { let cv = make_card_face(&font, rank, suit); @@ -696,14 +848,32 @@ fn main() { } // Card backs - for (i, cv) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() { + for (i, cv) in [ + make_back_0(), + make_back_1(), + make_back_2(), + make_back_3(), + make_back_4(), + ] + .iter() + .enumerate() + { let path = root.join(format!("assets/cards/backs/back_{i}.png")); save_card_png(&path, cv); println!("wrote {}", path.display()); } // Backgrounds - for (i, cv) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() { + for (i, cv) in [ + make_bg_0(), + make_bg_1(), + make_bg_2(), + make_bg_3(), + make_bg_4(), + ] + .iter() + .enumerate() + { let path = root.join(format!("assets/backgrounds/bg_{i}.png")); save_card_png(&path, cv); println!("wrote {}", path.display()); diff --git a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs index 2fabc5b..86c93f3 100644 --- a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs @@ -20,15 +20,15 @@ //! --help Print this message use solitaire_core::game_state::DrawMode; -use solitaire_core::solver::{try_solve, SolverConfig, SolverResult}; +use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; // Budget boundaries defining each tier. A seed belongs to the lowest tier // whose budget proves it Winnable. const BUDGETS: &[(&str, u64, usize)] = &[ - ("Easy", 1_000, 1_000), - ("Medium", 5_000, 5_000), - ("Hard", 25_000, 25_000), - ("Expert", 100_000, 100_000), + ("Easy", 1_000, 1_000), + ("Medium", 5_000, 5_000), + ("Hard", 25_000, 25_000), + ("Expert", 100_000, 100_000), ("Grandmaster", 200_000, 200_000), ]; @@ -86,7 +86,11 @@ fn main() { ); eprintln!( " Tiers: {}", - BUDGETS.iter().map(|(n, _, _)| *n).collect::>().join(", ") + BUDGETS + .iter() + .map(|(n, _, _)| *n) + .collect::>() + .join(", ") ); while buckets.iter().any(|b| b.len() < per_tier) { @@ -95,7 +99,10 @@ fn main() { if buckets[i].len() >= per_tier { continue; } - let cfg = SolverConfig { move_budget, state_budget }; + let cfg = SolverConfig { + move_budget, + state_budget, + }; match try_solve(seed, draw_mode, &cfg) { SolverResult::Winnable => { buckets[i].push(seed); @@ -123,7 +130,9 @@ fn main() { seed = seed.wrapping_add(1); } - eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n"); + eprintln!( + "\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n" + ); let date = current_date(); for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() { @@ -148,7 +157,10 @@ fn main() { fn parse_u64(s: &str) -> u64 { let cleaned = s.replace('_', ""); - if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) { + if let Some(hex) = cleaned + .strip_prefix("0x") + .or_else(|| cleaned.strip_prefix("0X")) + { u64::from_str_radix(hex, 16).unwrap_or_else(|_| { eprintln!("error: could not parse '{s}' as a hex u64"); std::process::exit(1); @@ -181,7 +193,18 @@ fn current_date() -> String { } let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400); let month_days: [u64; 12] = [ - 31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, ]; let mut m = 0usize; for &md in &month_days { diff --git a/solitaire_assetgen/src/bin/gen_seeds.rs b/solitaire_assetgen/src/bin/gen_seeds.rs index 111d0bf..801edb0 100644 --- a/solitaire_assetgen/src/bin/gen_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_seeds.rs @@ -18,7 +18,7 @@ //! --help Print this message use solitaire_core::game_state::DrawMode; -use solitaire_core::solver::{try_solve, SolverConfig, SolverResult}; +use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; fn main() { let mut args = std::env::args().skip(1).peekable(); @@ -45,7 +45,14 @@ fn main() { }); } "--help" | "-h" => { - eprintln!("{}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")).lines().take(20).collect::>().join("\n")); + eprintln!( + "{}", + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")) + .lines() + .take(20) + .collect::>() + .join("\n") + ); return; } other => { @@ -66,16 +73,11 @@ fn main() { let mut tried: u64 = 0; let mut seed = start; - eprintln!( - "gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …" - ); + eprintln!("gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …"); while found.len() < count { tried += 1; - if matches!( - try_solve(seed, draw_mode, &cfg), - SolverResult::Winnable - ) { + if matches!(try_solve(seed, draw_mode, &cfg), SolverResult::Winnable) { found.push(seed); eprintln!( " [{:>3}/{}] 0x{:016X} ({} tried so far)", @@ -88,7 +90,9 @@ fn main() { seed = seed.wrapping_add(1); } - eprintln!("\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n"); + eprintln!( + "\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n" + ); println!( " // Generated by solitaire_assetgen::gen_seeds \ @@ -111,7 +115,10 @@ fn main() { fn parse_u64(s: &str) -> u64 { let cleaned = s.replace('_', ""); - if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) { + if let Some(hex) = cleaned + .strip_prefix("0x") + .or_else(|| cleaned.strip_prefix("0X")) + { u64::from_str_radix(hex, 16).unwrap_or_else(|_| { eprintln!("error: could not parse '{s}' as a hex u64"); std::process::exit(1); @@ -144,7 +151,20 @@ fn current_date() -> String { y += 1; } let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400); - let month_days: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let month_days: [u64; 12] = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; let mut m = 0usize; for &md in &month_days { if d < md { diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index e96721b..02e4fe0 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -355,7 +355,11 @@ mod tests { ids.sort(); let len = ids.len(); ids.dedup(); - assert_eq!(ids.len(), len, "duplicate achievement ID in ALL_ACHIEVEMENTS"); + assert_eq!( + ids.len(), + len, + "duplicate achievement ID in ALL_ACHIEVEMENTS" + ); } #[test] @@ -422,13 +426,19 @@ mod tests { for hour in [22u32, 23, 0, 1, 2] { c.wall_clock_hour = Some(hour); let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"night_owl"), "expected night_owl at hour {hour}"); + assert!( + ids.contains(&"night_owl"), + "expected night_owl at hour {hour}" + ); } // Daytime hours must not trigger. for hour in [3u32, 7, 12, 20, 21] { c.wall_clock_hour = Some(hour); let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(!ids.contains(&"night_owl"), "unexpected night_owl at hour {hour}"); + assert!( + !ids.contains(&"night_owl"), + "unexpected night_owl at hour {hour}" + ); } } @@ -440,13 +450,19 @@ mod tests { for hour in [5u32, 6] { c.wall_clock_hour = Some(hour); let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"early_bird"), "expected early_bird at hour {hour}"); + assert!( + ids.contains(&"early_bird"), + "expected early_bird at hour {hour}" + ); } // Outside the window must not trigger. for hour in [0u32, 3, 4, 7, 12, 23] { c.wall_clock_hour = Some(hour); let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(!ids.contains(&"early_bird"), "unexpected early_bird at hour {hour}"); + assert!( + !ids.contains(&"early_bird"), + "unexpected early_bird at hour {hour}" + ); } } @@ -506,7 +522,10 @@ mod tests { #[test] fn achievement_by_id_finds_known_and_returns_none_for_unknown() { - assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win")); + assert_eq!( + achievement_by_id("first_win").map(|d| d.name), + Some("First Win") + ); assert!(achievement_by_id("nonexistent").is_none()); } @@ -538,7 +557,10 @@ mod tests { let mut c = ctx_defaults(); c.last_win_time_seconds = 179; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s"); + assert!( + ids.contains(&"speed_demon"), + "speed_demon should unlock at 179s" + ); } #[test] @@ -546,7 +568,10 @@ mod tests { let mut c = ctx_defaults(); c.last_win_time_seconds = 181; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s"); + assert!( + !ids.contains(&"speed_demon"), + "speed_demon must not unlock at 181s" + ); } #[test] @@ -562,7 +587,10 @@ mod tests { let mut c = ctx_defaults(); c.last_win_time_seconds = 90; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s"); + assert!( + !ids.contains(&"lightning"), + "lightning must not unlock at exactly 90s" + ); } #[test] @@ -570,7 +598,10 @@ mod tests { let mut c = ctx_defaults(); c.last_win_used_undo = false; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used"); + assert!( + ids.contains(&"no_undo"), + "no_undo should unlock when undo was not used" + ); } #[test] @@ -578,7 +609,10 @@ mod tests { let mut c = ctx_defaults(); c.last_win_used_undo = true; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used"); + assert!( + !ids.contains(&"no_undo"), + "no_undo must not unlock when undo was used" + ); } #[test] @@ -586,7 +620,10 @@ mod tests { let mut c = ctx_defaults(); c.best_single_score = 5_000; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000"); + assert!( + ids.contains(&"high_scorer"), + "high_scorer should unlock at best_single_score=5000" + ); } #[test] @@ -594,7 +631,10 @@ mod tests { let mut c = ctx_defaults(); c.best_single_score = 4_999; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999"); + assert!( + !ids.contains(&"high_scorer"), + "high_scorer must not unlock at best_single_score=4999" + ); } #[test] @@ -602,7 +642,10 @@ mod tests { let mut c = ctx_defaults(); c.win_streak_current = 3; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3"); + assert!( + ids.contains(&"on_a_roll"), + "on_a_roll should unlock at streak=3" + ); } #[test] @@ -610,7 +653,10 @@ mod tests { let mut c = ctx_defaults(); c.last_win_recycle_count = 3; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3"); + assert!( + ids.contains(&"comeback"), + "comeback should unlock at last_win_recycle_count=3" + ); } #[test] @@ -631,12 +677,18 @@ mod tests { c.win_streak_current = 9; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); assert!(!ids.contains(&"unstoppable")); - assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll"); + assert!( + ids.contains(&"on_a_roll"), + "streak 9 must still satisfy on_a_roll" + ); c.win_streak_current = 10; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); assert!(ids.contains(&"unstoppable")); - assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll"); + assert!( + ids.contains(&"on_a_roll"), + "streak 10 must also satisfy on_a_roll" + ); } #[test] @@ -657,12 +709,18 @@ mod tests { c.games_played = 499; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); assert!(!ids.contains(&"veteran")); - assert!(ids.contains(&"century"), "499 games must also satisfy century"); + assert!( + ids.contains(&"century"), + "499 games must also satisfy century" + ); c.games_played = 500; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); assert!(ids.contains(&"veteran")); - assert!(ids.contains(&"century"), "500 games must also satisfy century"); + assert!( + ids.contains(&"century"), + "500 games must also satisfy century" + ); } #[test] @@ -727,7 +785,10 @@ mod tests { assert!(ids.contains(&"first_win"), "first_win should unlock"); assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock"); assert!(ids.contains(&"no_undo"), "no_undo should unlock"); - assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously"); + assert!( + ids.len() >= 3, + "at least 3 achievements must fire simultaneously" + ); } #[test] @@ -742,7 +803,10 @@ mod tests { let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); assert!(ids.contains(&"perfectionist"), "perfectionist must unlock"); - assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does"); + assert!( + ids.contains(&"no_undo"), + "no_undo must also unlock when perfectionist does" + ); } #[test] @@ -778,6 +842,9 @@ mod tests { c.last_win_score = 50_000; let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); - assert!(ids.contains(&"perfectionist"), "score far above threshold must pass"); + assert!( + ids.contains(&"perfectionist"), + "score far above threshold must pass" + ); } } diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs index ad7bb75..a734d7a 100644 --- a/solitaire_core/src/card.rs +++ b/solitaire_core/src/card.rs @@ -27,27 +27,37 @@ impl Suit { /// Card rank, Ace through King. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Rank { - Ace = 1, - Two = 2, + Ace = 1, + Two = 2, Three = 3, - Four = 4, - Five = 5, - Six = 6, + Four = 4, + Five = 5, + Six = 6, Seven = 7, Eight = 8, - Nine = 9, - Ten = 10, - Jack = 11, + Nine = 9, + Ten = 10, + Jack = 11, Queen = 12, - King = 13, + King = 13, } impl Rank { /// All thirteen ranks in ascending order. pub const RANKS: [Self; 13] = [ - Self::Ace, Self::Two, Self::Three, Self::Four, Self::Five, - Self::Six, Self::Seven, Self::Eight, Self::Nine, Self::Ten, - Self::Jack, Self::Queen, Self::King, + Self::Ace, + Self::Two, + Self::Three, + Self::Four, + Self::Five, + Self::Six, + Self::Seven, + Self::Eight, + Self::Nine, + Self::Ten, + Self::Jack, + Self::Queen, + Self::King, ]; /// Numeric value: Ace = 1, King = 13. @@ -57,20 +67,20 @@ impl Rank { const fn new(n: u8) -> Option { match n { - 1 => Some(Self::Ace), - 2 => Some(Self::Two), - 3 => Some(Self::Three), - 4 => Some(Self::Four), - 5 => Some(Self::Five), - 6 => Some(Self::Six), - 7 => Some(Self::Seven), - 8 => Some(Self::Eight), - 9 => Some(Self::Nine), + 1 => Some(Self::Ace), + 2 => Some(Self::Two), + 3 => Some(Self::Three), + 4 => Some(Self::Four), + 5 => Some(Self::Five), + 6 => Some(Self::Six), + 7 => Some(Self::Seven), + 8 => Some(Self::Eight), + 9 => Some(Self::Nine), 10 => Some(Self::Ten), 11 => Some(Self::Jack), 12 => Some(Self::Queen), 13 => Some(Self::King), - _ => None, + _ => None, } } @@ -147,7 +157,11 @@ mod tests { #[test] fn suit_red_and_black_are_complementary() { for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black"); + assert_ne!( + suit.is_red(), + suit.is_black(), + "{suit:?} must be exactly one of red/black" + ); } assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red()); assert!(Suit::Clubs.is_black() && Suit::Spades.is_black()); diff --git a/solitaire_core/src/deck.rs b/solitaire_core/src/deck.rs index 84872e7..c41cd26 100644 --- a/solitaire_core/src/deck.rs +++ b/solitaire_core/src/deck.rs @@ -1,13 +1,23 @@ -use rand::{seq::SliceRandom, SeedableRng}; -use rand::rngs::StdRng; use crate::card::{Card, Rank, Suit}; use crate::pile::{Pile, PileType}; +use rand::rngs::StdRng; +use rand::{SeedableRng, seq::SliceRandom}; const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; const ALL_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::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, ]; /// A standard 52-card deck. @@ -23,7 +33,12 @@ impl Deck { let mut id = 0u32; for &suit in &ALL_SUITS { for &rank in &ALL_RANKS { - cards.push(Card { id, suit, rank, face_up: false }); + cards.push(Card { + id, + suit, + rank, + face_up: false, + }); id += 1; } } @@ -50,7 +65,11 @@ impl Default for Deck { /// Column `i` contains `i + 1` cards; only the top card is face-up. /// Stock receives the remaining 24 cards, all face-down. pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) { - debug_assert_eq!(deck.cards.len(), 52, "deal_klondike requires a full 52-card deck"); + debug_assert_eq!( + deck.cards.len(), + 52, + "deal_klondike requires a full 52-card deck" + ); let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i))); // Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded. let mut idx = 0usize; @@ -102,21 +121,26 @@ mod tests { #[test] fn same_seed_produces_same_order() { - let mut d1 = Deck::new(); d1.shuffle(42); - let mut d2 = Deck::new(); d2.shuffle(42); + let mut d1 = Deck::new(); + d1.shuffle(42); + let mut d2 = Deck::new(); + d2.shuffle(42); assert_eq!(d1.cards, d2.cards); } #[test] fn different_seeds_produce_different_orders() { - let mut d1 = Deck::new(); d1.shuffle(1); - let mut d2 = Deck::new(); d2.shuffle(2); + let mut d1 = Deck::new(); + d1.shuffle(1); + let mut d2 = Deck::new(); + d2.shuffle(2); assert_ne!(d1.cards, d2.cards); } #[test] fn deal_klondike_correct_tableau_sizes() { - let mut deck = Deck::new(); deck.shuffle(0); + let mut deck = Deck::new(); + deck.shuffle(0); let (tableau, stock) = deal_klondike(deck); for (i, pile) in tableau.iter().enumerate() { assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size"); @@ -126,7 +150,8 @@ mod tests { #[test] fn deal_klondike_top_cards_are_face_up() { - let mut deck = Deck::new(); deck.shuffle(0); + let mut deck = Deck::new(); + deck.shuffle(0); let (tableau, _) = deal_klondike(deck); for pile in &tableau { assert!(pile.cards.last().unwrap().face_up); @@ -135,7 +160,8 @@ mod tests { #[test] fn deal_klondike_non_top_cards_are_face_down() { - let mut deck = Deck::new(); deck.shuffle(0); + let mut deck = Deck::new(); + deck.shuffle(0); let (tableau, _) = deal_klondike(deck); for pile in &tableau { for card in &pile.cards[..pile.cards.len().saturating_sub(1)] { @@ -146,17 +172,21 @@ mod tests { #[test] fn deal_klondike_stock_is_face_down() { - let mut deck = Deck::new(); deck.shuffle(0); + let mut deck = Deck::new(); + deck.shuffle(0); let (_, stock) = deal_klondike(deck); assert!(stock.cards.iter().all(|c| !c.face_up)); } #[test] fn deal_klondike_all_52_cards_present() { - let mut deck = Deck::new(); deck.shuffle(99); + let mut deck = Deck::new(); + deck.shuffle(99); let (tableau, stock) = deal_klondike(deck); let mut ids: Vec = stock.cards.iter().map(|c| c.id).collect(); - for pile in &tableau { ids.extend(pile.cards.iter().map(|c| c.id)); } + for pile in &tableau { + ids.extend(pile.cards.iter().map(|c| c.id)); + } ids.sort_unstable(); assert_eq!(ids, (0u32..52).collect::>()); } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 8c41971..9be029a 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,11 +1,14 @@ -use std::collections::{HashMap, VecDeque}; -use serde::{Deserialize, Serialize}; use crate::card::Card; -use crate::deck::{deal_klondike, Deck}; +use crate::deck::{Deck, deal_klondike}; use crate::error::MoveError; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; -use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle, score_undo as scoring_undo}; +use crate::scoring::{ + compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle, + score_undo as scoring_undo, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; const MAX_UNDO_STACK: usize = 64; @@ -21,22 +24,29 @@ pub const GAME_STATE_SCHEMA_VERSION: u32 = 2; /// Default value for `GameState::schema_version` when deserialising older /// save files that pre-date the field. -fn schema_v1() -> u32 { 1 } +fn schema_v1() -> u32 { + 1 +} /// Serialize `HashMap` as a `Vec` of `(key, value)` pairs so /// that JSON (which requires string map keys) round-trips correctly. mod pile_map_serde { - use std::collections::HashMap; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::pile::{Pile, PileType}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::HashMap; - pub fn serialize(map: &HashMap, s: S) -> Result { + pub fn serialize( + map: &HashMap, + s: S, + ) -> Result { let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect(); entries.sort_by_key(|(k, _)| *k); entries.serialize(s) } - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + pub fn deserialize<'de, D: Deserializer<'de>>( + d: D, + ) -> Result, D::Error> { let entries: Vec<(PileType, Pile)> = Vec::deserialize(d)?; Ok(entries.into_iter().collect()) } @@ -175,7 +185,10 @@ impl GameState { piles.insert(PileType::Stock, stock); piles.insert(PileType::Waste, Pile::new(PileType::Waste)); for slot in 0..4_u8 { - piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot))); + piles.insert( + PileType::Foundation(slot), + Pile::new(PileType::Foundation(slot)), + ); } for (i, pile) in tableau.into_iter().enumerate() { piles.insert(PileType::Tableau(i), pile); @@ -214,7 +227,7 @@ impl GameState { fn push_snapshot(&mut self) { if self.undo_stack.len() >= MAX_UNDO_STACK { - self.undo_stack.pop_front(); // O(1) + self.undo_stack.pop_front(); // O(1) } self.undo_stack.push_back(self.take_snapshot()); } @@ -226,32 +239,44 @@ impl GameState { return Err(MoveError::GameAlreadyWon); } - let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len(); + let stock_len = self + .piles + .get(&PileType::Stock) + .ok_or(MoveError::InvalidSource)? + .cards + .len(); if stock_len == 0 { - let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len(); + let waste_len = self + .piles + .get(&PileType::Waste) + .ok_or(MoveError::InvalidSource)? + .cards + .len(); if waste_len == 0 { return Err(MoveError::StockEmpty); } // Recycle: snapshot so undo can reverse it, then move waste back to stock face-down self.push_snapshot(); - let waste_cards: Vec = self.piles + let waste_cards: Vec = self + .piles .get_mut(&PileType::Waste) .ok_or(MoveError::InvalidSource)? .cards .drain(..) .collect(); - let stock = self.piles.get_mut(&PileType::Stock).ok_or(MoveError::InvalidDestination)?; + let stock = self + .piles + .get_mut(&PileType::Stock) + .ok_or(MoveError::InvalidDestination)?; for mut card in waste_cards.into_iter().rev() { card.face_up = false; stock.cards.push(card); } self.recycle_count = self.recycle_count.saturating_add(1); if self.mode != GameMode::Zen { - let penalty = score_recycle( - self.recycle_count, - self.draw_mode == DrawMode::DrawThree, - ); + let penalty = + score_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree); self.score = (self.score + penalty).max(0); } self.move_count = self.move_count.saturating_add(1); @@ -267,14 +292,18 @@ impl GameState { let available = stock_len.min(draw_count); let drain_start = stock_len - available; - let drawn: Vec = self.piles + let drawn: Vec = self + .piles .get_mut(&PileType::Stock) .ok_or(MoveError::InvalidSource)? .cards .drain(drain_start..) .collect(); - let waste = self.piles.get_mut(&PileType::Waste).ok_or(MoveError::InvalidDestination)?; + let waste = self + .piles + .get_mut(&PileType::Waste) + .ok_or(MoveError::InvalidDestination)?; for mut card in drawn { card.face_up = true; waste.cards.push(card); @@ -288,12 +317,19 @@ impl GameState { /// /// Returns `Err(MoveError)` if the move is illegal. On success, updates score, /// flips the newly exposed source card if needed, and checks win/auto-complete. - pub fn move_cards(&mut self, from: PileType, to: PileType, count: usize) -> Result<(), MoveError> { + pub fn move_cards( + &mut self, + from: PileType, + to: PileType, + count: usize, + ) -> Result<(), MoveError> { if self.is_won { return Err(MoveError::GameAlreadyWon); } if from == to { - return Err(MoveError::RuleViolation("source and destination must differ".into())); + return Err(MoveError::RuleViolation( + "source and destination must differ".into(), + )); } // Validate via scoped immutable borrows @@ -308,7 +344,9 @@ impl GameState { let start = from_pile.cards.len() - count; for card in &from_pile.cards[start..] { if !card.face_up { - return Err(MoveError::RuleViolation("cannot move face-down card".into())); + return Err(MoveError::RuleViolation( + "cannot move face-down card".into(), + )); } } let bottom_card = from_pile.cards[start].clone(); @@ -327,7 +365,9 @@ impl GameState { } let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; if !can_place_on_foundation(&bottom_card, dest) { - return Err(MoveError::RuleViolation("invalid foundation placement".into())); + return Err(MoveError::RuleViolation( + "invalid foundation placement".into(), + )); } } PileType::Tableau(_) => { @@ -378,7 +418,8 @@ impl GameState { self.push_snapshot(); // Execute move - let mut moved: Vec = self.piles + let mut moved: Vec = self + .piles .get_mut(&from) .ok_or(MoveError::InvalidSource)? .cards @@ -386,7 +427,8 @@ impl GameState { // Flip the newly exposed top card of the source pile; award +5 per Windows scoring. let mut flipped = false; - if let Some(top) = self.piles + if let Some(top) = self + .piles .get_mut(&from) .ok_or(MoveError::InvalidSource)? .cards @@ -397,9 +439,17 @@ impl GameState { flipped = true; } - self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved); + self.piles + .get_mut(&to) + .ok_or(MoveError::InvalidDestination)? + .cards + .append(&mut moved); - let flip_bonus = if flipped && self.mode != GameMode::Zen { score_flip() } else { 0 }; + let flip_bonus = if flipped && self.mode != GameMode::Zen { + score_flip() + } else { + 0 + }; self.score = (self.score + score_delta + flip_bonus).max(0); self.move_count = self.move_count.saturating_add(1); @@ -422,7 +472,10 @@ impl GameState { "undo is disabled in Challenge mode".into(), )); } - let snapshot = self.undo_stack.pop_back().ok_or(MoveError::UndoStackEmpty)?; + let snapshot = self + .undo_stack + .pop_back() + .ok_or(MoveError::UndoStackEmpty)?; self.piles = snapshot.piles; self.score = if self.mode == GameMode::Zen { 0 @@ -453,9 +506,10 @@ impl GameState { return false; } let suit = pile.cards[0].suit; - pile.cards.iter().enumerate().all(|(i, card)| { - card.suit == suit && card.rank.value() == (i as u8 + 1) - }) + pile.cards + .iter() + .enumerate() + .all(|(i, card)| card.suit == suit && card.rank.value() == (i as u8 + 1)) } /// Returns `true` when stock and waste are empty and all tableau cards are face-up. @@ -464,10 +518,18 @@ impl GameState { // All three conditions must hold: stock empty, waste empty, and all // tableau cards face-up. Requiring waste empty avoids the deadlock // where the waste top cannot reach a foundation directly. - if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) { + if self + .piles + .get(&PileType::Stock) + .is_none_or(|p| !p.cards.is_empty()) + { return false; } - if self.piles.get(&PileType::Waste).is_none_or(|p| !p.cards.is_empty()) { + if self + .piles + .get(&PileType::Waste) + .is_none_or(|p| !p.cards.is_empty()) + { return false; } (0..7).all(|i| { @@ -489,7 +551,11 @@ impl GameState { let mut moves = Vec::new(); // Waste top card → foundation or tableau - if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) { + if let Some(waste_top) = self + .piles + .get(&PileType::Waste) + .and_then(|p| p.cards.last()) + { for slot in 0..4_u8 { if let Some(f) = self.piles.get(&PileType::Foundation(slot)) && can_place_on_foundation(waste_top, f) @@ -508,11 +574,18 @@ impl GameState { // Tableau sources for src in 0..7_usize { - let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue }; + let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { + continue; + }; if src_pile.cards.is_empty() { continue; } - let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count(); + let run_len = src_pile + .cards + .iter() + .rev() + .take_while(|c| c.face_up) + .count(); if run_len == 0 { continue; } @@ -547,7 +620,9 @@ impl GameState { // Foundation top → tableau (only when house rule is enabled) if self.take_from_foundation { for slot in 0..4_u8 { - let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue }; + let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { + continue; + }; let Some(top) = f.cards.last() else { continue }; for dst in 0..7_usize { if let Some(t) = self.piles.get(&PileType::Tableau(dst)) @@ -583,7 +658,9 @@ impl GameState { // Check waste top first — when stock is exhausted the waste may still // contain cards that can go directly to a foundation. let waste = PileType::Waste; - if let Some((card, slot)) = self.piles.get(&waste) + if let Some((card, slot)) = self + .piles + .get(&waste) .and_then(|p| p.cards.last()) .and_then(|c| self.foundation_slot_for(c).map(|s| (c, s))) { @@ -592,7 +669,9 @@ impl GameState { } for i in 0..7 { let tableau = PileType::Tableau(i); - if let Some(slot) = self.piles.get(&tableau) + if let Some(slot) = self + .piles + .get(&tableau) .and_then(|p| p.cards.last()) .and_then(|c| self.foundation_slot_for(c)) { @@ -611,7 +690,9 @@ impl GameState { let mut candidate: Option = None; let mut empty_slot: Option = None; for slot in 0..4_u8 { - let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue }; + let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { + continue; + }; if pile.cards.is_empty() { if empty_slot.is_none() { empty_slot = Some(slot); @@ -622,10 +703,15 @@ impl GameState { } } let target = candidate.or_else(|| { - if card.rank.value() == 1 { empty_slot } else { None } + if card.rank.value() == 1 { + empty_slot + } else { + None + } }); target.filter(|&slot| { - self.piles.get(&PileType::Foundation(slot)) + self.piles + .get(&PileType::Foundation(slot)) .is_some_and(|p| can_place_on_foundation(card, p)) }) } @@ -650,7 +736,9 @@ mod tests { #[test] fn new_game_has_correct_tableau_sizes() { let g = new_game(); - let total: usize = (0..7).map(|i| g.piles[&PileType::Tableau(i)].cards.len()).sum(); + let total: usize = (0..7) + .map(|i| g.piles[&PileType::Tableau(i)].cards.len()) + .sum(); assert_eq!(total, 28); for i in 0..7 { assert_eq!(g.piles[&PileType::Tableau(i)].cards.len(), i + 1); @@ -702,8 +790,16 @@ mod tests { fn different_seeds_produce_different_layouts() { let g1 = GameState::new(1, DrawMode::DrawOne); let g2 = GameState::new(2, DrawMode::DrawOne); - let t1: Vec = g1.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect(); - let t2: Vec = g2.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect(); + let t1: Vec = g1.piles[&PileType::Tableau(0)] + .cards + .iter() + .map(|c| c.id) + .collect(); + let t2: Vec = g2.piles[&PileType::Tableau(0)] + .cards + .iter() + .map(|c| c.id) + .collect(); assert_ne!(t1, t2); } @@ -743,7 +839,11 @@ mod tests { g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2"); + assert_eq!( + g.piles[&PileType::Waste].cards.len(), + 2, + "only 2 cards should move when stock has 2" + ); assert!(g.piles[&PileType::Stock].cards.is_empty()); } @@ -893,14 +993,20 @@ mod tests { let mut g = new_game(); // Inject two face-up cards into tableau(0) so count=2 is a valid count. g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }, - Card { id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true }, + Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }, + Card { + id: 2, + suit: Suit::Clubs, + rank: Rank::Two, + face_up: true, + }, ]; - let result = g.move_cards( - PileType::Tableau(0), - PileType::Foundation(0), - 2, - ); + let result = g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 2); assert!( matches!(result, Err(MoveError::RuleViolation(_))), "moving 2 cards to foundation must be rejected" @@ -921,9 +1027,24 @@ mod tests { // Clear both piles and construct a known valid sequence. let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap(); t0.cards = vec![ - Card { id: 10, suit: Suit::Spades, rank: Rank::King, face_up: true }, - Card { id: 11, suit: Suit::Hearts, rank: Rank::Queen, face_up: true }, - Card { id: 12, suit: Suit::Spades, rank: Rank::Jack, face_up: true }, + Card { + id: 10, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }, + Card { + id: 11, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }, + Card { + id: 12, + suit: Suit::Spades, + rank: Rank::Jack, + face_up: true, + }, ]; // Tableau(1) needs an Ace so we can check empty pile correctly — use a red King target. let t1 = g.piles.get_mut(&PileType::Tableau(1)).unwrap(); @@ -931,7 +1052,10 @@ mod tests { // Move the whole 3-card sequence to the empty pile. let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 3); - assert!(result.is_ok(), "valid multi-card move must succeed: {result:?}"); + assert!( + result.is_ok(), + "valid multi-card move must succeed: {result:?}" + ); assert!(g.piles[&PileType::Tableau(0)].cards.is_empty()); assert_eq!(g.piles[&PileType::Tableau(1)].cards.len(), 3); assert_eq!(g.move_count, 1); @@ -947,11 +1071,26 @@ mod tests { let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap(); f.cards.clear(); 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, ] { - f.cards.push(Card { id: 0, suit, rank, face_up: true }); + f.cards.push(Card { + id: 0, + suit, + rank, + face_up: true, + }); } } assert!(g.check_win()); @@ -1018,7 +1157,11 @@ mod tests { g.undo_count = u32::MAX; g.draw().unwrap(); g.undo().unwrap(); - assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX"); + assert_eq!( + g.undo_count, + u32::MAX, + "undo_count must saturate at u32::MAX" + ); } // --- Fields excluded from undo snapshot --- @@ -1031,7 +1174,10 @@ mod tests { g.elapsed_seconds = 120; g.draw().unwrap(); g.undo().unwrap(); - assert_eq!(g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged"); + assert_eq!( + g.elapsed_seconds, 120, + "undo must leave elapsed_seconds unchanged" + ); } #[test] @@ -1048,7 +1194,10 @@ mod tests { // Now draw one more card and undo it. g.draw().unwrap(); g.undo().unwrap(); - assert_eq!(g.recycle_count, 1, "undo must leave recycle_count unchanged"); + assert_eq!( + g.recycle_count, 1, + "undo must leave recycle_count unchanged" + ); } #[test] @@ -1128,7 +1277,10 @@ mod tests { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack); g.draw().unwrap(); // TimeAttack does not disable undo — only Challenge does. - assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode"); + assert!( + g.undo().is_ok(), + "undo must be permitted in TimeAttack mode" + ); } #[test] @@ -1171,7 +1323,13 @@ mod tests { face_up: true, }); for i in 0..7 { - for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() { + for c in g + .piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .iter_mut() + { c.face_up = true; } } @@ -1185,14 +1343,22 @@ mod tests { g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Clear all tableau and put a single face-up card — all face-up guard passes. 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: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }); assert!(g.check_auto_complete()); } @@ -1221,7 +1387,11 @@ mod tests { // contains no cards — exactly the code path that returns EmptySource. let mut g = new_game(); // Tableau(0) starts with exactly 1 card; clear it to make the pile empty. - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .clear(); let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1); assert_eq!( result, @@ -1249,14 +1419,22 @@ mod tests { // Clear all tableau piles and put a single face-up Ace of Clubs // into Tableau(0); all other piles empty. 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; let mv = g.next_auto_complete_move().expect("should find a move"); @@ -1284,21 +1462,47 @@ 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 an Ace of Clubs on tableau 0; move it to slot 0. - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { - id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, - }); - g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1) + .unwrap(); // Now place an Ace of Spades on tableau 0 and move it to slot 1. - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { - id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true, - }); - g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap(); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 2, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1) + .unwrap(); - assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs)); - assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades)); + assert_eq!( + g.piles[&PileType::Foundation(0)].claimed_suit(), + Some(Suit::Clubs) + ); + assert_eq!( + g.piles[&PileType::Foundation(1)].claimed_suit(), + Some(Suit::Spades) + ); } /// `Pile::claimed_suit` reads the bottom card's suit on a populated @@ -1309,12 +1513,24 @@ 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: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, - }); - g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap(); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 50, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1) + .unwrap(); assert_eq!( g.piles[&PileType::Foundation(2)].claimed_suit(), @@ -1330,13 +1546,28 @@ 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: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, - }); - g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); - assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts)); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 1, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1) + .unwrap(); + assert_eq!( + g.piles[&PileType::Foundation(0)].claimed_suit(), + Some(Suit::Hearts) + ); g.undo().unwrap(); assert!(g.piles[&PileType::Foundation(0)].cards.is_empty()); @@ -1345,9 +1576,18 @@ mod tests { // A different Ace can now claim slot 0. let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap(); t0.cards.clear(); - t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); - g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); - assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades)); + t0.cards.push(Card { + id: 2, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1) + .unwrap(); + assert_eq!( + g.piles[&PileType::Foundation(0)].claimed_suit(), + Some(Suit::Spades) + ); } /// Successive Aces from the waste pile distribute across slots 0..=3 in @@ -1359,7 +1599,11 @@ 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(); } let aces = [ (Suit::Clubs, 10), @@ -1369,9 +1613,13 @@ mod tests { ]; for (slot, (suit, id)) in aces.iter().enumerate() { g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { - id: *id, suit: *suit, rank: Rank::Ace, face_up: true, + id: *id, + suit: *suit, + rank: Rank::Ace, + face_up: true, }); - g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap(); + g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1) + .unwrap(); } for (slot, (suit, _)) in aces.iter().enumerate() { assert_eq!( @@ -1391,19 +1639,39 @@ 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(); } // Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts. - g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card { - id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, - }); + g.piles + .get_mut(&PileType::Foundation(1)) + .unwrap() + .cards + .push(Card { + id: 1, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }); // Tableau 0 holds the 2 of Hearts to play. - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { - id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true, - }); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 2, + suit: Suit::Hearts, + rank: Rank::Two, + face_up: true, + }); g.is_auto_completable = true; - let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1"); + let mv = g + .next_auto_complete_move() + .expect("auto-complete must find slot 1"); assert_eq!(mv.0, PileType::Tableau(0)); assert_eq!( mv.1, @@ -1418,23 +1686,47 @@ 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(); } // Foundation slot 0: A♠, 2♠ (top = 2♠) let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap(); - f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); - f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true }); - // Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1) - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { - id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true, + f.cards.push(Card { + id: 1, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: true, }); + f.cards.push(Card { + id: 2, + suit: Suit::Spades, + rank: Rank::Two, + face_up: true, + }); + // Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1) + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 3, + suit: Suit::Hearts, + rank: Rank::Three, + face_up: true, + }); g } #[test] fn take_from_foundation_enabled_by_default() { let g = setup_take_from_foundation_game(); - assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)"); + assert!( + g.take_from_foundation, + "take_from_foundation is on by default (standard Klondike rule)" + ); } #[test] @@ -1455,7 +1747,8 @@ mod tests { let mut g = setup_take_from_foundation_game(); // already true by default; explicit set confirms the behaviour holds g.take_from_foundation = true; - g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap(); + g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1) + .unwrap(); // Foundation slot 0 should now hold only the Ace. assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1); assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace); @@ -1501,11 +1794,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: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, - }); + g.piles + .get_mut(&PileType::Tableau(0)) + .unwrap() + .cards + .push(Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }); let moves = g.possible_instructions(); assert!( moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)), @@ -1548,20 +1852,47 @@ mod tests { let mut g = new_game(); 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(); } + for i in 0..7 { + g.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } // Tableau(0): hidden Ace under a face-up 5♠ g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false }, - Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true }, + Card { + id: 1, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: false, + }, + Card { + id: 2, + suit: Suit::Spades, + rank: Rank::Five, + face_up: true, + }, ]; // Tableau(1): 6♥ — 5♠ can land here - g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![ - Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true }, - ]; + g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![Card { + id: 3, + suit: Suit::Hearts, + rank: Rank::Six, + face_up: true, + }]; let score_before = g.score; - g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap(); - assert_eq!(g.score, score_before + 5, "flip bonus must be +5 when a face-down card is exposed"); - assert!(g.piles[&PileType::Tableau(0)].cards[0].face_up, "exposed card must now be face-up"); + g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1) + .unwrap(); + assert_eq!( + g.score, + score_before + 5, + "flip bonus must be +5 when a face-down card is exposed" + ); + assert!( + g.piles[&PileType::Tableau(0)].cards[0].face_up, + "exposed card must now be face-up" + ); } #[test] @@ -1569,14 +1900,27 @@ mod tests { let mut g = new_game(); 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(); } + for i in 0..7 { + g.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } // Only a King in Tableau(0); moving it leaves pile empty — nothing to flip - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true }, - ]; + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![Card { + id: 1, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }]; let score_before = g.score; - g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap(); - assert_eq!(g.score, score_before, "no flip bonus when source pile becomes empty"); + g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1) + .unwrap(); + assert_eq!( + g.score, score_before, + "no flip bonus when source pile becomes empty" + ); } #[test] @@ -1584,15 +1928,35 @@ mod tests { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); 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(); } + for i in 0..7 { + g.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false }, - Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true }, + Card { + id: 1, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: false, + }, + Card { + id: 2, + suit: Suit::Spades, + rank: Rank::Five, + face_up: true, + }, ]; - g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![ - Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true }, - ]; - g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap(); + g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![Card { + id: 3, + suit: Suit::Hearts, + rank: Rank::Six, + face_up: true, + }]; + g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1) + .unwrap(); assert_eq!(g.score, 0, "zen mode must suppress flip bonus"); } @@ -1602,7 +1966,9 @@ mod tests { fn recycle_penalty_draw1_first_pass_free() { let mut g = new_game(); // DrawOne g.score = 200; - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); // first recycle — free assert_eq!(g.recycle_count, 1); assert_eq!(g.score, 200, "first recycle in Draw-1 must be free"); @@ -1613,10 +1979,14 @@ mod tests { let mut g = new_game(); // DrawOne g.score = 200; // First recycle (free) - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); // Second recycle (-100) - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); assert_eq!(g.recycle_count, 2); assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100"); @@ -1627,7 +1997,9 @@ mod tests { let mut g = GameState::new(42, DrawMode::DrawThree); g.score = 200; for _ in 0..3 { - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); } assert_eq!(g.recycle_count, 3); @@ -1639,11 +2011,15 @@ mod tests { let mut g = GameState::new(42, DrawMode::DrawThree); g.score = 200; for _ in 0..3 { - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); } // Fourth recycle (-20) - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); assert_eq!(g.recycle_count, 4); assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20"); @@ -1654,7 +2030,9 @@ mod tests { let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); // Two recycles — second would normally cost -100 in classic mode for _ in 0..2 { - while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } g.draw().unwrap(); } assert_eq!(g.recycle_count, 2); @@ -1667,10 +2045,17 @@ mod tests { // Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear. g.piles.get_mut(&PileType::Stock).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::Waste).unwrap().cards.push(Card { - id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true, + id: 99, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, }); let moves = g.possible_instructions(); // King goes on any of the 7 empty tableau piles @@ -1698,7 +2083,9 @@ mod tests { g.take_from_foundation = false; let moves = g.possible_instructions(); assert!( - !moves.iter().any(|(from, _, _)| matches!(from, PileType::Foundation(_))), + !moves + .iter() + .any(|(from, _, _)| matches!(from, PileType::Foundation(_))), "possible_instructions must not include any Foundation source when take_from_foundation is off; got {moves:?}" ); } @@ -1709,13 +2096,31 @@ mod tests { fn waste_multi_card_move_returns_rule_violation() { let mut g = new_game(); g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }, - Card { id: 2, suit: Suit::Spades, rank: Rank::King, face_up: true }, + Card { + id: 1, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }, + Card { + id: 2, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }, ]; - for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); } + for i in 0..7 { + g.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2); - assert!(matches!(result, Err(MoveError::RuleViolation(_))), - "moving 2 cards from waste must be rejected"); + assert!( + matches!(result, Err(MoveError::RuleViolation(_))), + "moving 2 cards from waste must be rejected" + ); } // --- P3: foundation-to-foundation move must be rejected --- @@ -1725,15 +2130,26 @@ mod tests { let mut g = new_game(); 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(); } + for i in 0..7 { + g.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } // Place Ace of Clubs on Foundation(0), leave Foundation(1) empty. - g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }, - ]; + g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }]; // Attempting to move Ace from Foundation(0) to Foundation(1) must fail. let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1); - assert!(matches!(result, Err(MoveError::RuleViolation(_))), - "moving between foundation slots must be rejected"); + assert!( + matches!(result, Err(MoveError::RuleViolation(_))), + "moving between foundation slots must be rejected" + ); } // --- P4: undo must not retain points from the undone move --- @@ -1743,17 +2159,30 @@ mod tests { let mut g = new_game(); 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(); } + for i in 0..7 { + g.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } // Place an Ace on Tableau(0) — moving it to Foundation earns +10. - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ - Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }, - ]; + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: true, + }]; assert_eq!(g.score, 0); - g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1) + .unwrap(); assert_eq!(g.score, 10, "moving Ace to foundation earns +10"); // Undo must roll back to snapshot.score (0) minus the penalty, not keep the +10. g.undo().unwrap(); // snapshot.score was 0, so result is max(0, 0 - 15) = 0 - assert_eq!(g.score, 0, "undo must not retain points from the undone move"); + assert_eq!( + g.score, 0, + "undo must not retain points from the undone move" + ); } } diff --git a/solitaire_core/src/pile.rs b/solitaire_core/src/pile.rs index a95721f..5bbb266 100644 --- a/solitaire_core/src/pile.rs +++ b/solitaire_core/src/pile.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use crate::card::{Card, Suit}; +use serde::{Deserialize, Serialize}; /// Identifies which pile on the board a set of cards belongs to. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -28,7 +28,10 @@ pub struct Pile { impl Pile { /// Creates a new empty pile of the given type. pub fn new(pile_type: PileType) -> Self { - Self { pile_type, cards: Vec::new() } + Self { + pile_type, + cards: Vec::new(), + } } /// Returns a reference to the top (last) card, or `None` if empty. @@ -61,8 +64,18 @@ mod tests { #[test] fn pile_top_returns_last_card() { let mut pile = Pile::new(PileType::Waste); - pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }); - pile.cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Two, face_up: true }); + pile.cards.push(Card { + id: 0, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }); + pile.cards.push(Card { + id: 1, + suit: Suit::Clubs, + rank: Rank::Two, + face_up: true, + }); assert_eq!(pile.top().unwrap().id, 1); } @@ -91,15 +104,30 @@ mod tests { #[test] fn claimed_suit_is_none_for_non_foundation() { let mut pile = Pile::new(PileType::Tableau(0)); - pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }); + pile.cards.push(Card { + id: 0, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }); assert!(pile.claimed_suit().is_none()); } #[test] fn claimed_suit_returns_bottom_card_suit() { let mut pile = Pile::new(PileType::Foundation(2)); - pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }); - pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true }); + pile.cards.push(Card { + id: 0, + suit: Suit::Hearts, + rank: Rank::Ace, + face_up: true, + }); + pile.cards.push(Card { + id: 1, + suit: Suit::Hearts, + rank: Rank::Two, + face_up: true, + }); assert_eq!(pile.claimed_suit(), Some(Suit::Hearts)); } } diff --git a/solitaire_core/src/rules.rs b/solitaire_core/src/rules.rs index 174ac85..1a5f95b 100644 --- a/solitaire_core/src/rules.rs +++ b/solitaire_core/src/rules.rs @@ -52,7 +52,12 @@ mod tests { use crate::pile::{Pile, PileType}; fn card(suit: Suit, rank: Rank) -> Card { - Card { id: 0, suit, rank, face_up: true } + Card { + id: 0, + suit, + rank, + face_up: true, + } } fn pile_with(pile_type: PileType, cards: Vec) -> Pile { @@ -100,7 +105,10 @@ mod tests { #[test] fn foundation_skipping_rank_is_invalid() { let c = card(Suit::Diamonds, Rank::Three); - let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]); + let p = pile_with( + PileType::Foundation(0), + vec![card(Suit::Diamonds, Rank::Ace)], + ); assert!(!can_place_on_foundation(&c, &p)); } @@ -151,7 +159,10 @@ mod tests { fn foundation_king_on_queen_completes_suit() { // The last card placed to complete a foundation is always King on Queen. let c = card(Suit::Spades, Rank::King); - let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]); + let p = pile_with( + PileType::Foundation(0), + vec![card(Suit::Spades, Rank::Queen)], + ); assert!(can_place_on_foundation(&c, &p)); } @@ -159,7 +170,10 @@ mod tests { fn foundation_king_wrong_suit_is_invalid() { // King of Hearts cannot go on a Spades-claimed foundation even if rank matches. let c = card(Suit::Hearts, Rank::King); - let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]); + let p = pile_with( + PileType::Foundation(0), + vec![card(Suit::Spades, Rank::Queen)], + ); assert!(!can_place_on_foundation(&c, &p)); } diff --git a/solitaire_core/src/scoring.rs b/solitaire_core/src/scoring.rs index f3aa5f9..9d521e8 100644 --- a/solitaire_core/src/scoring.rs +++ b/solitaire_core/src/scoring.rs @@ -38,7 +38,11 @@ pub fn score_flip() -> i32 { /// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3). /// `recycle_count` is the new total count **after** this recycle. pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 { - let (free, penalty) = if is_draw_three { (3_u32, -20_i32) } else { (1_u32, -100_i32) }; + let (free, penalty) = if is_draw_three { + (3_u32, -20_i32) + } else { + (1_u32, -100_i32) + }; if recycle_count > free { penalty } else { 0 } } @@ -58,7 +62,10 @@ mod tests { #[test] fn move_to_foundation_scores_ten() { assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10); - assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10); + assert_eq!( + score_move(&PileType::Tableau(0), &PileType::Foundation(0)), + 10 + ); } #[test] @@ -94,10 +101,12 @@ mod tests { #[test] fn foundation_to_tableau_penalises_fifteen() { // Moving a card back off a foundation (take_from_foundation rule) costs -15. - assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15); + assert_eq!( + score_move(&PileType::Foundation(0), &PileType::Tableau(0)), + -15 + ); } - #[test] fn move_to_stock_or_waste_scores_zero() { // These destinations are illegal moves in practice, but the function @@ -110,7 +119,10 @@ mod tests { fn time_bonus_is_capped_at_i32_max_for_huge_values() { // Very short elapsed time would overflow without the .min() guard. let bonus = compute_time_bonus(1); - assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast"); + assert!( + bonus >= 0, + "time bonus must be non-negative after u64→i32 cast" + ); } #[test] diff --git a/solitaire_core/src/solver.rs b/solitaire_core/src/solver.rs index f55576c..5cd0e24 100644 --- a/solitaire_core/src/solver.rs +++ b/solitaire_core/src/solver.rs @@ -64,7 +64,7 @@ use std::collections::HashSet; use std::hash::{Hash, Hasher}; use crate::card::{Card, Suit}; -use crate::deck::{deal_klondike, Deck}; +use crate::deck::{Deck, deal_klondike}; use crate::game_state::{DrawMode, GameState}; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; @@ -212,7 +212,11 @@ pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOu #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InternalMove { /// Move `count` cards from a tableau column to another tableau column. - TableauToTableau { from: usize, to: usize, count: usize }, + TableauToTableau { + from: usize, + to: usize, + count: usize, + }, /// Move the top of a tableau column to a foundation slot. TableauToFoundation { from: usize, slot: u8 }, /// Move the top of the waste pile to a tableau column. @@ -303,10 +307,9 @@ impl SolverState { self.foundation.iter().all(|pile| { pile.len() == 13 && pile[0].rank == crate::card::Rank::Ace - && pile.windows(2).all(|w| { - w[0].suit == w[1].suit - && w[1].rank.value() == w[0].rank.value() + 1 - }) + && pile + .windows(2) + .all(|w| w[0].suit == w[1].suit && w[1].rank.value() == w[0].rank.value() + 1) }) } @@ -350,10 +353,8 @@ impl SolverState { && top.face_up && let Some(slot) = self.target_foundation_slot(top.suit) { - let foundation_pile = Self::pile_view( - PileType::Foundation(slot), - &self.foundation[slot as usize], - ); + let foundation_pile = + Self::pile_view(PileType::Foundation(slot), &self.foundation[slot as usize]); if can_place_on_foundation(top, &foundation_pile) { moves.push(InternalMove::TableauToFoundation { from: i, slot }); } @@ -364,10 +365,8 @@ impl SolverState { if let Some(top) = self.waste.last() && let Some(slot) = self.target_foundation_slot(top.suit) { - let foundation_pile = Self::pile_view( - PileType::Foundation(slot), - &self.foundation[slot as usize], - ); + let foundation_pile = + Self::pile_view(PileType::Foundation(slot), &self.foundation[slot as usize]); if can_place_on_foundation(top, &foundation_pile) { moves.push(InternalMove::WasteToFoundation { slot }); } @@ -401,13 +400,14 @@ impl SolverState { // column onto another empty column". let leaves_source_empty = start == 0; let dest_empty = self.tableau[dst].is_empty(); - if leaves_source_empty - && dest_empty - && bottom.rank == crate::card::Rank::King - { + if leaves_source_empty && dest_empty && bottom.rank == crate::card::Rank::King { continue; } - moves.push(InternalMove::TableauToTableau { from: src, to: dst, count }); + moves.push(InternalMove::TableauToTableau { + from: src, + to: dst, + count, + }); } } } @@ -432,8 +432,7 @@ impl SolverState { // a single full cycle requires at most `ceil(stock_cycle_len / draw_count)` // draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so // anything past that without intervening progress is wasteful. - let cycled_without_progress = - self.consecutive_draws > stock_cycle_len.saturating_add(1); + let cycled_without_progress = self.consecutive_draws > stock_cycle_len.saturating_add(1); if can_draw && !cycled_without_progress { moves.push(InternalMove::Draw); } @@ -578,9 +577,7 @@ impl SolverState { while let Some(frame) = stack.last_mut() { // Budget gates — checked before consuming the next move so // the budget exhaustion is reflected in the verdict. - if *moves_consumed >= config.move_budget - || visited.len() >= config.state_budget - { + if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget { *budget_exceeded = true; return None; } @@ -622,7 +619,12 @@ impl SolverState { let mut moves_consumed: u64 = 0; let mut budget_exceeded = false; let already_won = self.is_won(); - let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded); + let first_move = self.search( + config, + &mut visited, + &mut moves_consumed, + &mut budget_exceeded, + ); let result = if already_won || first_move.is_some() { SolverResult::Winnable } else if budget_exceeded { @@ -800,18 +802,38 @@ mod tests { } fn ace(suit: Suit, id: u32) -> Card { - Card { id, suit, rank: Rank::Ace, face_up: true } + Card { + id, + suit, + rank: Rank::Ace, + face_up: true, + } } fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card { - Card { id, suit, rank, face_up: true } + Card { + id, + suit, + rank, + face_up: true, + } } fn full_run(suit: Suit, base_id: u32) -> Vec { let ranks = [ - 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, ]; ranks .iter() @@ -846,14 +868,28 @@ mod tests { tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)]; tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)]; - let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne); + let state = synthetic( + tableau, + foundations, + Vec::new(), + Vec::new(), + DrawMode::DrawOne, + ); let mut visited: HashSet = HashSet::new(); let mut moves_consumed: u64 = 0; let mut budget_exceeded = false; let cfg = SolverConfig::default(); - let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded); + let first_move = state.search( + &cfg, + &mut visited, + &mut moves_consumed, + &mut budget_exceeded, + ); - assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable"); + assert!( + first_move.is_some(), + "obviously-winnable position must be recognised as Winnable" + ); assert!(!budget_exceeded); assert!( moves_consumed < 1000, @@ -872,8 +908,18 @@ mod tests { // Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom // card; the Two on top of it has no valid destination. tableau[0] = vec![ - Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true }, - Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true }, + Card { + id: 0, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: true, + }, + Card { + id: 1, + suit: Suit::Spades, + rank: Rank::Two, + face_up: true, + }, ]; // Other six columns isolated. Put a face-up King with no // matching Queen anywhere — it cannot move because every @@ -894,9 +940,20 @@ mod tests { let mut visited: HashSet = HashSet::new(); let mut moves_consumed: u64 = 0; let mut budget_exceeded = false; - let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded); - assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve"); - assert!(!budget_exceeded, "small synthetic state must complete within budget"); + let first_move = state.search( + &cfg, + &mut visited, + &mut moves_consumed, + &mut budget_exceeded, + ); + assert!( + first_move.is_none(), + "buried Ace under same-suit Two with no recovery must not solve" + ); + assert!( + !budget_exceeded, + "small synthetic state must complete within budget" + ); } #[test] @@ -960,9 +1017,12 @@ mod tests { #[test] fn longest_face_up_run_handles_face_down_at_top() { - let cards = vec![ - Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false }, - ]; + let cards = vec![Card { + id: 1, + suit: Suit::Spades, + rank: Rank::King, + face_up: false, + }]; assert_eq!(longest_face_up_run(&cards), 0); } @@ -970,10 +1030,30 @@ mod tests { fn longest_face_up_run_extends_through_valid_run() { let cards = vec![ // bottom: face-down filler - Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false }, - Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true }, - Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true }, - Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true }, + Card { + id: 0, + suit: Suit::Spades, + rank: Rank::Two, + face_up: false, + }, + Card { + id: 1, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }, + Card { + id: 2, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }, + Card { + id: 3, + suit: Suit::Clubs, + rank: Rank::Jack, + face_up: true, + }, ]; assert_eq!(longest_face_up_run(&cards), 3); } @@ -983,9 +1063,24 @@ mod tests { // K♠ Q♥ Q♣ — second pair fails the descending check, so the // run is just the top single card (Q♣). let cards = vec![ - Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true }, - Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true }, - Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true }, + Card { + id: 1, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }, + Card { + id: 2, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }, + Card { + id: 3, + suit: Suit::Clubs, + rank: Rank::Queen, + face_up: true, + }, ]; assert_eq!(longest_face_up_run(&cards), 1); } @@ -1082,7 +1177,9 @@ mod tests { println!( "\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}", total / samples_ms.len() as u128, - counts[0], counts[1], counts[2], + counts[0], + counts[1], + counts[2], ); } @@ -1122,9 +1219,18 @@ mod tests { // `target_foundation_slot` ordering. let suit_for_slot = [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 suit_for_slot.iter().enumerate() { let pile = game @@ -1166,7 +1272,9 @@ mod tests { SolverResult::Winnable, "near-finished state must solve as Winnable" ); - let mv = outcome.first_move.expect("Winnable must include a first_move"); + let mv = outcome + .first_move + .expect("Winnable must include a first_move"); // The first move must be a King going from a tableau column to // its matching foundation slot. Single-card move. assert_eq!(mv.count, 1); @@ -1200,15 +1308,30 @@ mod tests { // Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal // destination, so the Ace is buried forever. let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap(); - t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); - t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true }); + t0.cards.push(Card { + id: 0, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: true, + }); + t0.cards.push(Card { + id: 1, + suit: Suit::Spades, + rank: Rank::Two, + face_up: true, + }); // Tableau 1: a face-up King with nothing else — irrelevant; the // pruning check elides "King → empty" no-ops. game.piles .get_mut(&PileType::Tableau(1)) .unwrap() .cards - .push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true }); + .push(Card { + id: 2, + suit: Suit::Clubs, + rank: Rank::King, + face_up: true, + }); let cfg = SolverConfig::default(); let outcome = try_solve_from_state(&game, &cfg); @@ -1248,7 +1371,13 @@ mod tests { let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg); let game = GameState::new(7, DrawMode::DrawOne); let b = try_solve_from_state(&game, &cfg); - assert_eq!(a.result, b.result, "verdicts must match across the two entry points"); - assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points"); + assert_eq!( + a.result, b.result, + "verdicts must match across the two entry points" + ); + assert_eq!( + a.first_move, b.first_move, + "first_move must match across the two entry points" + ); } } diff --git a/solitaire_data/src/achievements.rs b/solitaire_data/src/achievements.rs index 3619795..44024aa 100644 --- a/solitaire_data/src/achievements.rs +++ b/solitaire_data/src/achievements.rs @@ -72,14 +72,11 @@ mod tests { let path = tmp_path("round_trip"); let _ = fs::remove_file(&path); - let records = vec![ - AchievementRecord::locked("first_win"), - { - let mut r = AchievementRecord::locked("century"); - r.unlock(Utc::now()); - r - }, - ]; + let records = vec![AchievementRecord::locked("first_win"), { + let mut r = AchievementRecord::locked("century"); + r.unlock(Utc::now()); + r + }]; save_achievements_to(&path, &records).expect("save"); let loaded = load_achievements_from(&path); assert_eq!(loaded.len(), 2); diff --git a/solitaire_data/src/android_keystore.rs b/solitaire_data/src/android_keystore.rs index b549205..3f16896 100644 --- a/solitaire_data/src/android_keystore.rs +++ b/solitaire_data/src/android_keystore.rs @@ -14,8 +14,8 @@ /// /// Only compiled and linked on `target_os = "android"`. use jni::{ - objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned}, JNIEnv, JavaVM, + objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned}, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -100,8 +100,7 @@ fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result Option { - crate::platform::data_dir() - .map(|d| d.join(crate::APP_DIR_NAME).join("auth_tokens.bin")) + crate::platform::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join("auth_tokens.bin")) } /// Path where the token file lived before the APP_DIR_NAME subdirectory was @@ -302,8 +296,8 @@ fn read_file_bytes_from(path: &PathBuf) -> Result, TokenError> { } fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> { - let path = token_file_path() - .ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; + let path = + token_file_path().ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| TokenError::Keyring(format!("create dir: {e}")))?; @@ -328,8 +322,8 @@ fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> { /// - Delete the legacy file (best-effort; leave it if removal fails). /// 3. If neither file exists, return an empty map. fn read_map() -> Result, TokenError> { - let new_path = token_file_path() - .ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; + let new_path = + token_file_path().ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; let legacy_path = legacy_token_file_path(); // --- 1. New path exists --- @@ -339,7 +333,9 @@ fn read_map() -> Result, TokenError> { other => other, })?; if data.len() < 12 { - return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into())); + return Err(TokenError::Keyring( + "auth_tokens.bin corrupt (too short)".into(), + )); } let plaintext = with_jvm(|env| { let key = load_or_create_key(env)?; @@ -355,7 +351,9 @@ fn read_map() -> Result, TokenError> { map.insert(blob.username.clone(), blob); return Ok(map); } - return Err(TokenError::Keyring("auth_tokens.bin unrecognised format".into())); + return Err(TokenError::Keyring( + "auth_tokens.bin unrecognised format".into(), + )); } // --- 2. Legacy path migration --- @@ -390,8 +388,8 @@ fn read_map() -> Result, TokenError> { /// Serialise and encrypt a map, then write it atomically. fn write_map_inner(map: &HashMap) -> Result<(), TokenError> { - let plaintext = serde_json::to_vec(map) - .map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?; + let plaintext = + serde_json::to_vec(map).map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?; let encrypted = with_jvm(|env| { let key = load_or_create_key(env)?; encrypt_gcm(env, &key, &plaintext) @@ -500,8 +498,13 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> { .v()?; let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?); - env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])? - .v() + env.call_method( + &ks, + "deleteEntry", + "(Ljava/lang/String;)V", + &[alias.borrow()], + )? + .v() }) } else { // Other users still exist — just rewrite the map without this user. diff --git a/solitaire_data/src/difficulty_seeds.rs b/solitaire_data/src/difficulty_seeds.rs index ac86ee9..8cb4079 100644 --- a/solitaire_data/src/difficulty_seeds.rs +++ b/solitaire_data/src/difficulty_seeds.rs @@ -294,7 +294,11 @@ mod tests { sorted.sort_unstable(); let before = sorted.len(); sorted.dedup(); - assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers"); + assert_eq!( + sorted.len(), + before, + "duplicate seeds found across difficulty tiers" + ); } #[test] diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 61eea90..88fbf9a 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -104,43 +104,43 @@ pub use stats::{StatsExt, StatsSnapshot}; pub mod storage; pub use storage::{ - cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at, - game_state_file_path, load_game_state_from, load_stats, load_stats_from, - load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to, - save_stats, save_stats_to, save_time_attack_session_to, stats_file_path, - time_attack_session_path, time_attack_session_with_now, TimeAttackSession, + TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at, + delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats, + load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at, + save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path, + time_attack_session_path, time_attack_session_with_now, }; pub mod achievements; pub use achievements::{ - achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord, + AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to, }; pub mod progress; pub use progress::{ - daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to, - xp_for_win, PlayerProgress, + PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path, + save_progress_to, xp_for_win, }; pub mod weekly; pub use weekly::{ - current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind, - WEEKLY_GOALS, WEEKLY_GOAL_XP, + WEEKLY_GOAL_XP, WEEKLY_GOALS, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind, + current_iso_week_key, weekly_goal_by_id, }; pub mod challenge; -pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS}; +pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for}; pub mod difficulty_seeds; -pub use difficulty_seeds::{seeds_for, DifficultySeeds}; +pub use difficulty_seeds::{DifficultySeeds, seeds_for}; pub mod settings; pub use settings::{ - load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, - Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS, - REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX, - TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, - TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, + AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS, + REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend, + TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, + TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, + load_settings_from, save_settings_to, settings_file_path, }; #[cfg(target_os = "android")] @@ -148,20 +148,20 @@ mod android_keystore; pub mod auth_tokens; pub use auth_tokens::{ - delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError, + TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, }; pub mod sync_client; -pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient}; +pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend}; pub mod replay; +pub use replay::{ + REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay, + ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, + migrate_legacy_latest_replay, replay_history_path, save_replay_history_to, +}; #[allow(deprecated)] pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; -pub use replay::{ - append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay, - replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove, - REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, -}; pub mod matomo_client; pub use matomo_client::MatomoClient; diff --git a/solitaire_data/src/matomo_client.rs b/solitaire_data/src/matomo_client.rs index 4868d3b..433d4c2 100644 --- a/solitaire_data/src/matomo_client.rs +++ b/solitaire_data/src/matomo_client.rs @@ -47,13 +47,7 @@ impl MatomoClient { /// /// When the buffer exceeds 100 events the oldest 50 are dropped to /// prevent unbounded memory growth during extended offline play. - pub fn event( - &self, - category: &str, - action: &str, - name: Option<&str>, - value: Option, - ) { + pub fn event(&self, category: &str, action: &str, name: Option<&str>, value: Option) { let Ok(mut guard) = self.pending.lock() else { return; }; diff --git a/solitaire_data/src/platform.rs b/solitaire_data/src/platform.rs index 47777ef..fdb69d5 100644 --- a/solitaire_data/src/platform.rs +++ b/solitaire_data/src/platform.rs @@ -87,6 +87,9 @@ mod tests { #[test] fn data_dir_returns_sandbox_path_on_android() { let dir = data_dir().expect("android must report a data dir"); - assert_eq!(dir, PathBuf::from("/data/data/com.ferrousapp.solitaire/files")); + assert_eq!( + dir, + PathBuf::from("/data/data/com.ferrousapp.solitaire/files") + ); } } diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index 137db3f..d1c60df 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -11,8 +11,8 @@ use std::path::{Path, PathBuf}; use chrono::{Datelike, NaiveDate}; -pub use solitaire_sync::progress::level_for_xp; pub use solitaire_sync::PlayerProgress; +pub use solitaire_sync::progress::level_for_xp; const FILE_NAME: &str = "progress.json"; @@ -147,7 +147,10 @@ mod tests { #[test] fn add_xp_saturates_on_overflow() { - let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() }; + let mut p = PlayerProgress { + total_xp: u64::MAX - 5, + ..Default::default() + }; p.add_xp(100); assert_eq!(p.total_xp, u64::MAX); } diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index 3514fbb..bfdde86 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -293,11 +293,9 @@ pub fn replay_history_path() -> Option { /// /// Overwrites any existing replay — only the most recent winning replay /// is retained on disk. -#[deprecated( - note = "single-slot replay storage replaced by the rolling history; \ +#[deprecated(note = "single-slot replay storage replaced by the rolling history; \ use append_replay_to_history instead. Kept for the one-shot \ - legacy migration." -)] + legacy migration.")] pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -317,11 +315,9 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> { /// "No replay recorded yet" caption rather than a half-loaded broken /// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every /// older save without further migration code. -#[deprecated( - note = "single-slot replay storage replaced by the rolling history; \ +#[deprecated(note = "single-slot replay storage replaced by the rolling history; \ use load_replay_history_from instead. Kept for the one-shot \ - legacy migration." -)] + legacy migration.")] pub fn load_latest_replay_from(path: &Path) -> Option { let data = fs::read(path).ok()?; let replay: Replay = serde_json::from_slice(&data).ok()?; @@ -383,10 +379,7 @@ pub fn load_replay_history_from(path: &Path) -> Option { /// [`ReplayHistory`] is the exact value written to disk so callers can /// update an in-memory mirror (e.g. the Stats overlay's /// `ReplayHistoryResource`) without a follow-up `load`. -pub fn append_replay_to_history( - path: &Path, - replay: Replay, -) -> io::Result { +pub fn append_replay_to_history(path: &Path, replay: Replay) -> io::Result { let mut history = load_replay_history_from(path).unwrap_or_default(); // Most recent first. Reserve the front slot; pop the oldest if we // exceed the cap so the file never grows unbounded. @@ -438,9 +431,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) { // Migration failure is non-fatal: on the next launch we'll just // try again. We log to stderr rather than panic so headless // tests stay quiet. - eprintln!( - "replay: failed to migrate legacy latest_replay.json into rolling history: {e}", - ); + eprintln!("replay: failed to migrate legacy latest_replay.json into rolling history: {e}",); } } @@ -623,8 +614,8 @@ mod tests { let mut last_returned = ReplayHistory::default(); for i in 0..10 { - last_returned = append_replay_to_history(&path, replay_with_id(i)) - .expect("append must succeed"); + last_returned = + append_replay_to_history(&path, replay_with_id(i)).expect("append must succeed"); } assert_eq!( @@ -634,7 +625,11 @@ mod tests { ); // The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2 // survive (newest first), ids 0 and 1 aged out. - let ids: Vec = last_returned.replays.iter().map(|r| r.final_score).collect(); + let ids: Vec = last_returned + .replays + .iter() + .map(|r| r.final_score) + .collect(); assert_eq!( ids, vec![9, 8, 7, 6, 5, 4, 3, 2], @@ -683,18 +678,30 @@ mod tests { // Seed the legacy file with a real replay. let legacy_replay = sample_replay(); save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy"); - assert!(!history.exists(), "history file must not exist pre-migration"); + assert!( + !history.exists(), + "history file must not exist pre-migration" + ); migrate_legacy_latest_replay(&latest, &history); assert!(history.exists(), "migration must create the history file"); - let loaded = load_replay_history_from(&history) - .expect("post-migration history must load"); - assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry"); - assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay"); + let loaded = load_replay_history_from(&history).expect("post-migration history must load"); + assert_eq!( + loaded.replays.len(), + 1, + "history must hold exactly the legacy entry" + ); + assert_eq!( + loaded.replays[0], legacy_replay, + "entry must equal the legacy replay" + ); // Legacy file is intentionally retained for one release as a // safety net — see `migrate_legacy_latest_replay` doc comment. - assert!(latest.exists(), "legacy file must NOT be deleted by migration"); + assert!( + latest.exists(), + "legacy file must NOT be deleted by migration" + ); let _ = fs::remove_file(&latest); let _ = fs::remove_file(&history); @@ -720,7 +727,10 @@ mod tests { migrate_legacy_latest_replay(&latest, &history); let loaded = load_replay_history_from(&history).expect("load"); - assert_eq!(loaded, pre_existing, "existing history must not be overwritten"); + assert_eq!( + loaded, pre_existing, + "existing history must not be overwritten" + ); let _ = fs::remove_file(&latest); let _ = fs::remove_file(&history); diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 70b4684..1909385 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -60,7 +60,6 @@ pub enum SyncBackend { avatar_url: Option, // JWT tokens are stored in the OS keychain — not here. }, - } /// Persisted window size (in logical pixels) and screen position @@ -447,8 +446,8 @@ impl Settings { /// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the /// new value. pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 { - self.tooltip_delay_secs = (self.tooltip_delay_secs + delta) - .clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS); + self.tooltip_delay_secs = + (self.tooltip_delay_secs + delta).clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS); self.tooltip_delay_secs } @@ -522,7 +521,10 @@ mod tests { #[test] fn adjust_sfx_volume_clamps() { - let mut s = Settings { sfx_volume: 0.5, ..Default::default() }; + let mut s = Settings { + sfx_volume: 0.5, + ..Default::default() + }; assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6); assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6); assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6); @@ -531,7 +533,10 @@ mod tests { #[test] fn adjust_music_volume_clamps() { - let mut s = Settings { music_volume: 0.5, ..Default::default() }; + let mut s = Settings { + music_volume: 0.5, + ..Default::default() + }; assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6); assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6); assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6); @@ -570,7 +575,10 @@ mod tests { #[test] fn adjust_tooltip_delay_clamps_to_range() { - let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() }; + let mut s = Settings { + tooltip_delay_secs: 0.5, + ..Default::default() + }; // Step up to 0.6. assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6); // Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS. @@ -583,21 +591,23 @@ mod tests { #[test] fn adjust_time_bonus_multiplier_clamps_and_rounds() { - let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() }; + let mut s = Settings { + time_bonus_multiplier: 1.0, + ..Default::default() + }; // Step up to 1.1. assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6); // Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX. - assert!( - (s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6 - ); + assert!((s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6); // Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN. - assert!( - (s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6 - ); + assert!((s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6); assert_eq!(s.time_bonus_multiplier, 0.0); // Repeated incremental adds must not drift past the 0.1 grid. - let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() }; + let mut s2 = Settings { + time_bonus_multiplier: 0.0, + ..Default::default() + }; for _ in 0..10 { s2.adjust_time_bonus_multiplier(0.1); } @@ -611,20 +621,24 @@ mod tests { #[test] fn adjust_replay_move_interval_clamps_and_rounds() { - let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() }; + let mut s = Settings { + replay_move_interval_secs: 0.45, + ..Default::default() + }; // Step down to 0.40. assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6); // Big positive jump clamps to MAX. - assert!( - (s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6 - ); + assert!((s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6); // Big negative jump clamps to MIN. assert!( (s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6 ); // Repeated 0.05 steps must not drift past the 0.05 grid. - let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() }; + let mut s2 = Settings { + replay_move_interval_secs: 0.10, + ..Default::default() + }; for _ in 0..6 { s2.adjust_replay_move_interval(0.05); } diff --git a/solitaire_data/src/stats.rs b/solitaire_data/src/stats.rs index 444a514..88d85d8 100644 --- a/solitaire_data/src/stats.rs +++ b/solitaire_data/src/stats.rs @@ -231,14 +231,24 @@ mod tests { // Win once — current becomes 1, best must remain 5. s.update_on_win(100, 60, &DrawMode::DrawOne); assert_eq!(s.win_streak_current, 1); - assert_eq!(s.win_streak_best, 5, "best must not drop to match shorter streak"); + assert_eq!( + s.win_streak_best, 5, + "best must not drop to match shorter streak" + ); } #[test] fn lifetime_score_saturates_at_u64_max() { - let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() }; + let mut s = StatsSnapshot { + lifetime_score: u64::MAX - 100, + ..Default::default() + }; s.update_on_win(200, 60, &DrawMode::DrawOne); - assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow"); + assert_eq!( + s.lifetime_score, + u64::MAX, + "lifetime_score must saturate, not overflow" + ); } // ----------------------------------------------------------------------- diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 096780b..eb8626b 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION}; +use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState}; use crate::stats::StatsSnapshot; @@ -57,9 +57,8 @@ pub fn load_stats() -> StatsSnapshot { /// Save stats to the platform default path. Returns an error if the platform /// data dir is unavailable or the write fails. pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> { - let path = stats_file_path().ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable") - })?; + let path = stats_file_path() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?; save_stats_to(&path, stats) } @@ -89,11 +88,7 @@ pub fn load_game_state_from(path: &Path) -> Option { if gs.schema_version != GAME_STATE_SCHEMA_VERSION { return None; } - if gs.is_won { - None - } else { - Some(gs) - } + if gs.is_won { None } else { Some(gs) } } /// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won` @@ -180,7 +175,10 @@ pub struct TimeAttackSession { /// Returns the platform-specific path to `time_attack_session.json`, or /// `None` if `crate::data_dir()` is unavailable. pub fn time_attack_session_path() -> Option { - crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME)) + crate::data_dir().map(|d| { + d.join(crate::APP_DIR_NAME) + .join(TIME_ATTACK_SESSION_FILE_NAME) + }) } /// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s @@ -422,7 +420,10 @@ mod tests { let mut gs = GameState::new(99, DrawMode::DrawOne); gs.is_won = true; save_game_state_to(&path, &gs).expect("save should be no-op, not error"); - assert!(!path.exists(), "should not have written a file for a won game"); + assert!( + !path.exists(), + "should not have written a file for a won game" + ); } #[test] @@ -556,7 +557,10 @@ mod tests { loaded.remaining_secs, ); assert_eq!(loaded.wins, 3, "wins must round-trip"); - assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip"); + assert_eq!( + loaded.saved_at_unix_secs, saved_at, + "timestamp must round-trip" + ); let _ = fs::remove_file(&path); } diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 71a74fd..75f4126 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -15,10 +15,10 @@ use async_trait::async_trait; use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; use crate::{ + SyncError, SyncProvider, auth_tokens::{load_access_token, load_refresh_token, store_tokens}, replay::Replay, settings::SyncBackend, - SyncError, SyncProvider, }; // --------------------------------------------------------------------------- @@ -125,10 +125,7 @@ impl SolitaireServerClient { async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> { let status = resp.status(); if !status.is_success() { - let body: serde_json::Value = resp - .json() - .await - .unwrap_or(serde_json::json!({})); + let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({})); let msg = body["error"] .as_str() .or_else(|| body["message"].as_str()) @@ -166,8 +163,8 @@ impl SolitaireServerClient { /// new refresh token that replaces the old one. Both tokens are persisted /// to the OS keychain on success. async fn refresh_token(&self) -> Result<(), SyncError> { - let old_refresh = load_refresh_token(&self.username) - .map_err(|e| SyncError::Auth(e.to_string()))?; + let old_refresh = + load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; let resp = self .client @@ -186,9 +183,9 @@ impl SolitaireServerClient { .await .map_err(|e| SyncError::Serialization(e.to_string()))?; - let new_access = body["access_token"] - .as_str() - .ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?; + let new_access = body["access_token"].as_str().ok_or_else(|| { + SyncError::Serialization("missing access_token in refresh response".into()) + })?; // Server rotates refresh tokens — store the new one. // Fall back to the old token if the field is absent (pre-rotation server). @@ -368,13 +365,19 @@ impl SyncProvider for SolitaireServerClient { .await .map_err(|e| SyncError::Network(e.to_string()))?; if !resp.status().is_success() { - return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status()))); + return Err(SyncError::Auth(format!( + "opt-out failed: {}", + resp.status() + ))); } return Ok(()); } if !resp.status().is_success() { - return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status()))); + return Err(SyncError::Auth(format!( + "opt-out failed: {}", + resp.status() + ))); } Ok(()) } @@ -402,13 +405,19 @@ impl SyncProvider for SolitaireServerClient { .await .map_err(|e| SyncError::Network(e.to_string()))?; if !resp.status().is_success() { - return Err(SyncError::Auth(format!("delete account failed: {}", resp.status()))); + return Err(SyncError::Auth(format!( + "delete account failed: {}", + resp.status() + ))); } return Ok(()); } if !resp.status().is_success() { - return Err(SyncError::Auth(format!("delete account failed: {}", resp.status()))); + return Err(SyncError::Auth(format!( + "delete account failed: {}", + resp.status() + ))); } Ok(()) } @@ -480,27 +489,26 @@ impl SyncProvider for SolitaireServerClient { impl SolitaireServerClient { /// Pulled out of `push_replay` so both the first attempt and the /// post-401-retry attempt go through the same parse path. - async fn share_url_from_response( - &self, - resp: reqwest::Response, - ) -> Result { + async fn share_url_from_response(&self, resp: reqwest::Response) -> Result { let status = resp.status(); if !status.is_success() { - return Err(if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - { - SyncError::Auth(format!("server returned {status}")) - } else { - SyncError::Network(format!("server returned {status}")) - }); + return Err( + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + SyncError::Auth(format!("server returned {status}")) + } else { + SyncError::Network(format!("server returned {status}")) + }, + ); } let body: serde_json::Value = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; - let id = body["id"].as_str().ok_or_else(|| { - SyncError::Serialization("upload response missing `id`".into()) - })?; + let id = body["id"] + .as_str() + .ok_or_else(|| SyncError::Serialization("upload response missing `id`".into()))?; Ok(format!("{}/replays/{}", self.base_url, id)) } @@ -540,7 +548,10 @@ impl SolitaireServerClient { /// Like [`fetch_me`] but uses an explicit token instead of reading from the /// OS keychain. Useful immediately after login/register when the token has /// not yet been persisted. - pub async fn fetch_me_with_token(&self, token: &str) -> Result<(String, Option), SyncError> { + pub async fn fetch_me_with_token( + &self, + token: &str, + ) -> Result<(String, Option), SyncError> { let url = format!("{}/api/me", self.base_url); let resp = self .client @@ -552,7 +563,9 @@ impl SolitaireServerClient { Self::extract_me_body(resp).await } - async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option), SyncError> { + async fn extract_me_body( + resp: reqwest::Response, + ) -> Result<(String, Option), SyncError> { let status = resp.status(); if !status.is_success() { return Err(SyncError::Network(format!("GET /api/me returned {status}"))); @@ -595,7 +608,9 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result`. -async fn extract_leaderboard_body(resp: reqwest::Response) -> Result, SyncError> { +async fn extract_leaderboard_body( + resp: reqwest::Response, +) -> Result, SyncError> { let status = resp.status(); if status.is_success() { resp.json() diff --git a/solitaire_data/tests/sync_round_trip.rs b/solitaire_data/tests/sync_round_trip.rs index cb253bd..d761c40 100644 --- a/solitaire_data/tests/sync_round_trip.rs +++ b/solitaire_data/tests/sync_round_trip.rs @@ -30,13 +30,11 @@ //! expired-on-purpose tokens for the JWT-refresh test. use chrono::Utc; -use jsonwebtoken::{encode, EncodingKey, Header}; -use solitaire_data::{ - delete_tokens, store_tokens, SolitaireServerClient, SyncError, SyncProvider, -}; +use jsonwebtoken::{EncodingKey, Header, encode}; +use solitaire_data::{SolitaireServerClient, SyncError, SyncProvider, delete_tokens, store_tokens}; use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload}; -use sqlx::sqlite::SqlitePoolOptions; use sqlx::SqlitePool; +use sqlx::sqlite::SqlitePoolOptions; use std::sync::Once; use uuid::Uuid; @@ -58,8 +56,8 @@ static MOCK_KEYRING_INIT: Once = Once::new(); /// default. Safe to call from any test — only the first call has effect. fn ensure_mock_keyring() { MOCK_KEYRING_INIT.call_once(|| { - let store = keyring_core::mock::Store::new() - .expect("failed to construct mock keyring store"); + let store = + keyring_core::mock::Store::new().expect("failed to construct mock keyring store"); keyring_core::set_default_store(store); }); } @@ -95,9 +93,7 @@ async fn spawn_test_server() -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("failed to bind test listener"); - let addr = listener - .local_addr() - .expect("listener has no local addr"); + let addr = listener.local_addr().expect("listener has no local addr"); let app = solitaire_server::build_test_router(fresh_pool().await); @@ -119,11 +115,7 @@ async fn spawn_test_server() -> String { /// Register a fresh user against `base_url` and return the access + refresh /// tokens straight from the response body. Bypasses the keyring entirely so /// the caller can store the tokens under whatever username they want. -async fn register_user_raw( - base_url: &str, - username: &str, - password: &str, -) -> (String, String) { +async fn register_user_raw(base_url: &str, username: &str, password: &str) -> (String, String) { let client = reqwest::Client::new(); let resp = client .post(format!("{base_url}/api/auth/register")) @@ -154,19 +146,15 @@ async fn register_user_raw( /// Decode a JWT's `sub` claim without validating expiry (so test crafted /// tokens still parse). Returns the user UUID as a `String`. fn decode_sub(token: &str) -> String { - use jsonwebtoken::{decode, DecodingKey, Validation}; + use jsonwebtoken::{DecodingKey, Validation, decode}; #[derive(serde::Deserialize)] struct Claims { sub: String, } let mut v = Validation::default(); v.validate_exp = false; - let data = decode::( - token, - &DecodingKey::from_secret(TEST_SECRET.as_bytes()), - &v, - ) - .expect("failed to decode JWT"); + let data = decode::(token, &DecodingKey::from_secret(TEST_SECRET.as_bytes()), &v) + .expect("failed to decode JWT"); data.claims.sub } @@ -208,8 +196,7 @@ async fn register_login_push_pull_round_trip() { let username = "rt_alice"; let (access, refresh) = register_user_raw(&base, username, "alicepass1!").await; - store_tokens(username, &access, &refresh) - .expect("storing tokens in mock keyring must succeed"); + store_tokens(username, &access, &refresh).expect("storing tokens in mock keyring must succeed"); let user_id = decode_sub(&access); let payload = make_payload(&user_id, 42); @@ -257,8 +244,7 @@ async fn pull_after_concurrent_pushes_merges_correctly() { let username = "rt_bob"; let (access, refresh) = register_user_raw(&base, username, "bobpass1!").await; - store_tokens(username, &access, &refresh) - .expect("storing tokens in mock keyring must succeed"); + store_tokens(username, &access, &refresh).expect("storing tokens in mock keyring must succeed"); let user_id = decode_sub(&access); @@ -269,11 +255,17 @@ async fn pull_after_concurrent_pushes_merges_correctly() { // Client A: low value first. let payload_a = make_payload(&user_id, 5); - client_a.push(&payload_a).await.expect("client A push must succeed"); + client_a + .push(&payload_a) + .await + .expect("client A push must succeed"); // Client B: higher value second. let payload_b = make_payload(&user_id, 99); - client_b.push(&payload_b).await.expect("client B push must succeed"); + client_b + .push(&payload_b) + .await + .expect("client B push must succeed"); // Either client should now pull max(5, 99) = 99. let pulled = client_a @@ -330,8 +322,7 @@ async fn jwt_refresh_on_401_succeeds() { let username = "rt_expiring"; // Register to get a real, valid refresh token signed with TEST_SECRET. - let (_real_access, real_refresh) = - register_user_raw(&base, username, "expirepass1!").await; + let (_real_access, real_refresh) = register_user_raw(&base, username, "expirepass1!").await; let user_id = decode_sub(&_real_access); // Craft an expired access token signed with TEST_SECRET so the server's @@ -361,9 +352,10 @@ async fn jwt_refresh_on_401_succeeds() { // Pull: server returns 401, client refreshes, retries, succeeds. let client = SolitaireServerClient::new(&base, username); - let pulled = client.pull().await.expect( - "pull must succeed after the client transparently refreshes the access token", - ); + let pulled = client + .pull() + .await + .expect("pull must succeed after the client transparently refreshes the access token"); // Default merge for a never-pushed user yields games_played = 0. assert_eq!( pulled.stats.games_played, 0, @@ -387,8 +379,7 @@ async fn pull_after_account_deletion_returns_default_or_error() { let username = "rt_deleter"; let (access, refresh) = register_user_raw(&base, username, "deletepass1!").await; - store_tokens(username, &access, &refresh) - .expect("storing tokens in mock keyring must succeed"); + store_tokens(username, &access, &refresh).expect("storing tokens in mock keyring must succeed"); let user_id = decode_sub(&access); let client = SolitaireServerClient::new(&base, username); @@ -431,8 +422,7 @@ async fn push_retries_after_401_on_expired_access_token() { let base = spawn_test_server().await; let username = "rt_push_expiring"; - let (_real_access, real_refresh) = - register_user_raw(&base, username, "pushexpirepass1!").await; + let (_real_access, real_refresh) = register_user_raw(&base, username, "pushexpirepass1!").await; let user_id = decode_sub(&_real_access); #[derive(serde::Serialize)] diff --git a/solitaire_engine/examples/card_face_generator.rs b/solitaire_engine/examples/card_face_generator.rs index 0a3842b..754b324 100644 --- a/solitaire_engine/examples/card_face_generator.rs +++ b/solitaire_engine/examples/card_face_generator.rs @@ -27,8 +27,8 @@ //! alongside the `card_plugin` constant migration. use solitaire_engine::assets::card_face_svg::{ - back_svg, face_svg, rank_filename, suit_filename, theme_rank_token, theme_suit_token, - ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET, + ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET, back_svg, face_svg, rank_filename, suit_filename, + theme_rank_token, theme_suit_token, }; use solitaire_engine::assets::rasterize_svg; use std::path::PathBuf; diff --git a/solitaire_engine/examples/card_face_poc.rs b/solitaire_engine/examples/card_face_poc.rs index a4bf87c..658930b 100644 --- a/solitaire_engine/examples/card_face_poc.rs +++ b/solitaire_engine/examples/card_face_poc.rs @@ -44,8 +44,8 @@ fn main() { // 256×384 = 2:3 aspect at half the default svg_loader resolution. // See migration plan § "Output format" for the rationale. let target = UVec2::new(256, 384); - let image = rasterize_svg(svg.as_bytes(), target) - .expect("rasterising the PoC SVG should succeed"); + let image = + rasterize_svg(svg.as_bytes(), target).expect("rasterising the PoC SVG should succeed"); let bytes = image .data @@ -61,11 +61,13 @@ fn main() { // bytes from a Pixmap inside `svg_loader`; this round-trip is // the cost of going through Bevy's `Image` shape. let size = IntSize::from_wh(target.x, target.y).expect("target size is non-zero"); - let pixmap = Pixmap::from_vec(bytes, size) - .expect("RGBA byte buffer should form a valid Pixmap"); + let pixmap = + Pixmap::from_vec(bytes, size).expect("RGBA byte buffer should form a valid Pixmap"); let out = "/tmp/ace_spades_terminal.png"; - pixmap.save_png(out).expect("writing the PNG should succeed"); + pixmap + .save_png(out) + .expect("writing the PNG should succeed"); println!( "Wrote {} ({}×{} RGBA8, {} bytes on disk)", diff --git a/solitaire_engine/examples/icon_generator.rs b/solitaire_engine/examples/icon_generator.rs index b7a0184..c8e5f68 100644 --- a/solitaire_engine/examples/icon_generator.rs +++ b/solitaire_engine/examples/icon_generator.rs @@ -18,7 +18,7 @@ //! pipeline already used by every other generated asset). use bevy::math::UVec2; -use solitaire_engine::assets::icon_svg::{icon_svg, ICON_SIZES}; +use solitaire_engine::assets::icon_svg::{ICON_SIZES, icon_svg}; use solitaire_engine::assets::rasterize_svg; use std::path::PathBuf; use tiny_skia::{IntSize, Pixmap}; diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 22f0351..cdda35a 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -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::) + .add_systems( + Update, + crate::ui_modal::touch_scroll_panel::, + ) // Event-driven unlock: observe `ReplayPlaybackState` and unlock // `cinephile` the first time playback runs to natural completion. // Reads the resource via `Option>` 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::().0.draw_three_wins = 9; + app.world_mut() + .resource_mut::() + .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::>(); @@ -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::().0.draw_three_wins = 8; + app.world_mut() + .resource_mut::() + .0 + .draw_three_wins = 8; app.world_mut() .resource_mut::() .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::>(); 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::() - .0 - .mode = solitaire_core::game_state::GameMode::Zen; + app.world_mut().resource_mut::().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::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = 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::Completed; + *app.world_mut().resource_mut::() = 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::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = 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::Inactive; + *app.world_mut().resource_mut::() = 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::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { + replay: dummy_replay(), + cursor: 0, + secs_to_next: 0.0, + paused: false, + }; app.update(); - *app.world_mut().resource_mut::() = - ReplayPlaybackState::Completed; + *app.world_mut().resource_mut::() = 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::Inactive; + *app.world_mut().resource_mut::() = ReplayPlaybackState::Inactive; app.update(); - *app.world_mut().resource_mut::() = - ReplayPlaybackState::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { + replay: dummy_replay(), + cursor: 0, + secs_to_next: 0.0, + paused: false, + }; app.update(); - *app.world_mut().resource_mut::() = - ReplayPlaybackState::Completed; + *app.world_mut().resource_mut::() = 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::Playing { - replay: dummy_replay(), - cursor: 0, - secs_to_next: 0.0, - paused: false, - }; + *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { + replay: dummy_replay(), + cursor: 0, + secs_to_next: 0.0, + paused: false, + }; app.update(); - *app.world_mut().resource_mut::() = - ReplayPlaybackState::Completed; + *app.world_mut().resource_mut::() = 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` diff --git a/solitaire_engine/src/analytics_plugin.rs b/solitaire_engine/src/analytics_plugin.rs index 23f9433..c8c3c8b 100644 --- a/solitaire_engine/src/analytics_plugin.rs +++ b/solitaire_engine/src/analytics_plugin.rs @@ -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> { 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, rt: Arc) { diff --git a/solitaire_engine/src/android_clipboard.rs b/solitaire_engine/src/android_clipboard.rs index f7c503d..eb94b87 100644 --- a/solitaire_engine/src/android_clipboard.rs +++ b/solitaire_engine/src/android_clipboard.rs @@ -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 diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 20f6bf5..438f0af 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -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, - mut queue: ResMut, -) { +fn enqueue_toasts(mut events: MessageReader, mut queue: ResMut) { 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, -) { +fn handle_warning_toast(mut commands: Commands, mut events: MessageReader) { 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::().unwrap().translation; + let pos = app + .world() + .entity(entity) + .get::() + .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::().is_none(), "CardAnim should be removed when done" ); - let pos = app.world().entity(entity).get::().unwrap().translation; + let pos = app + .world() + .entity(entity) + .get::() + .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::().unwrap().translation; + let pos = app + .world() + .entity(entity) + .get::() + .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::().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] diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index 0b7f92f..7b11934 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -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}; diff --git a/solitaire_engine/src/assets/sources.rs b/solitaire_engine/src/assets/sources.rs index e296114..61c2ab0 100644 --- a/solitaire_engine/src/assets/sources.rs +++ b/solitaire_engine/src/assets/sources.rs @@ -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::() - .is_some()); + assert!( + app.world() + .get_resource::() + .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::() - .is_some()); + assert!( + app.world() + .get_resource::() + .is_some() + ); } #[test] diff --git a/solitaire_engine/src/assets/svg_loader.rs b/solitaire_engine/src/assets/svg_loader.rs index c7a831a..cb912a3 100644 --- a/solitaire_engine/src/assets/svg_loader.rs +++ b/solitaire_engine/src/assets/svg_loader.rs @@ -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); } diff --git a/solitaire_engine/src/assets/user_dir.rs b/solitaire_engine/src/assets/user_dir.rs index 22e1a8f..e5f81d7 100644 --- a/solitaire_engine/src/assets/user_dir.rs +++ b/solitaire_engine/src/assets/user_dir.rs @@ -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: diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 7567e3c..4ed3316 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -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 { 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 { 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" + ); } // ----------------------------------------------------------------------- diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 63f864b..2c06f2e 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -39,17 +39,16 @@ pub struct AutoCompletePlugin; impl Plugin for AutoCompletePlugin { fn build(&self, app: &mut App) { - app.init_resource::() - .add_systems( - Update, - ( - detect_auto_complete, - on_auto_complete_start, - drive_auto_complete, - ) - .chain() - .after(GameMutation), - ); + app.init_resource::().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 } diff --git a/solitaire_engine/src/avatar_plugin.rs b/solitaire_engine/src/avatar_plugin.rs index 608eb46..e380c06 100644 --- a/solitaire_engine/src/avatar_plugin.rs +++ b/solitaire_engine/src/avatar_plugin.rs @@ -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()) }) })); diff --git a/solitaire_engine/src/card_animation/animation.rs b/solitaire_engine/src/card_animation/animation.rs index a01cab5..f97fc2f 100644 --- a/solitaire_engine/src/card_animation/animation.rs +++ b/solitaire_engine/src/card_animation/animation.rs @@ -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:?}" + ); } } } diff --git a/solitaire_engine/src/card_animation/curves.rs b/solitaire_engine/src/card_animation/curves.rs index fefc65d..48122c8 100644 --- a/solitaire_engine/src/card_animation/curves.rs +++ b/solitaire_engine/src/card_animation/curves.rs @@ -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", + ); } } diff --git a/solitaire_engine/src/card_animation/diagnostics.rs b/solitaire_engine/src/card_animation/diagnostics.rs index d536b03..26f16da 100644 --- a/solitaire_engine/src/card_animation/diagnostics.rs +++ b/solitaire_engine/src/card_animation/diagnostics.rs @@ -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] diff --git a/solitaire_engine/src/card_animation/interaction.rs b/solitaire_engine/src/card_animation/interaction.rs index 7067e4f..8d747c9 100644 --- a/solitaire_engine/src/card_animation/interaction.rs +++ b/solitaire_engine/src/card_animation/interaction.rs @@ -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 }; diff --git a/solitaire_engine/src/card_animation/mod.rs b/solitaire_engine/src/card_animation/mod.rs index 325cb14..f21983c 100644 --- a/solitaire_engine/src/card_animation/mod.rs +++ b/solitaire_engine/src/card_animation/mod.rs @@ -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] diff --git a/solitaire_engine/src/card_animation/timing.rs b/solitaire_engine/src/card_animation/timing.rs index 9c3f1dd..189ada2 100644 --- a/solitaire_engine/src/card_animation/timing.rs +++ b/solitaire_engine/src/card_animation/timing.rs @@ -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] diff --git a/solitaire_engine/src/card_animation/tuning.rs b/solitaire_engine/src/card_animation/tuning.rs index 45883cd..2390c5e 100644 --- a/solitaire_engine/src/card_animation/tuning.rs +++ b/solitaire_engine/src/card_animation/tuning.rs @@ -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] diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 22f9510..62b8d17 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -22,7 +22,7 @@ use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; -use crate::animation_plugin::{CardAnim, EffectiveSlideDuration, CARD_ANIM_Z_LIFT}; +use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::card_animation::CardAnimation; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::font_plugin::FontResource; @@ -32,7 +32,7 @@ use crate::pause_plugin::PausedResource; use crate::platform::USE_TOUCH_UI_LAYOUT; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; -use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR}; +use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; use crate::ui_theme::{ CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z, CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG, @@ -432,7 +432,10 @@ impl Plugin for CardPlugin { .add_message::() .add_message::() .add_systems(Startup, load_card_images) - .add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup)) + .add_systems( + PostStartup, + (sync_cards_startup, update_stock_empty_indicator_startup), + ) .add_systems( Update, ( @@ -480,25 +483,25 @@ fn card_face_asset_path(rank: Rank, suit: Suit) -> String { "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", ]; let suit_idx = match suit { - Suit::Clubs => 0, + Suit::Clubs => 0, Suit::Diamonds => 1, - Suit::Hearts => 2, - Suit::Spades => 3, + Suit::Hearts => 2, + Suit::Spades => 3, }; let rank_idx = match rank { - Rank::Ace => 0, - Rank::Two => 1, + Rank::Ace => 0, + Rank::Two => 1, Rank::Three => 2, - Rank::Four => 3, - Rank::Five => 4, - Rank::Six => 5, + Rank::Four => 3, + Rank::Five => 4, + Rank::Six => 5, Rank::Seven => 6, Rank::Eight => 7, - Rank::Nine => 8, - Rank::Ten => 9, - Rank::Jack => 10, + Rank::Nine => 8, + Rank::Ten => 9, + Rank::Jack => 10, Rank::Queen => 11, - Rank::King => 12, + Rank::King => 12, }; format!( "cards/faces/classic/{}{}.png", @@ -522,18 +525,26 @@ fn load_card_images(asset_server: Option>, mut commands: Comman 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::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, ]; let faces: [[Handle; 13]; 4] = std::array::from_fn(|si| { - std::array::from_fn(|ri| { - asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si])) - }) - }); - let backs = std::array::from_fn(|i| { - asset_server.load(format!("cards/backs/classic/back_{i}.png")) + std::array::from_fn(|ri| asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))) }); + let backs = + std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png"))); commands.insert_resource(CardImageSet { faces, backs, @@ -644,7 +655,19 @@ fn sync_cards_startup( let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); let font_handle = font_res.as_ref().map(|r| &r.0); - sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle); + sync_cards( + commands, + &game.0, + &layout.0, + slide_secs, + back_colour, + color_blind, + high_contrast, + &entities, + card_images.as_deref(), + selected_back, + font_handle, + ); } } @@ -670,7 +693,19 @@ fn sync_cards_on_change( let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); let font_handle = font_res.as_ref().map(|r| &r.0); - sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle); + sync_cards( + commands, + &game.0, + &layout.0, + slide_secs, + back_colour, + color_blind, + high_contrast, + &entities, + card_images.as_deref(), + selected_back, + font_handle, + ); } } @@ -719,7 +754,10 @@ fn sync_cards( // stale `CardAnimation` and apply the new position. let mut existing: HashMap)> = HashMap::new(); for (entity, marker, transform, anim) in entities.iter() { - existing.insert(marker.card_id, (entity, transform.translation, anim.map(|a| a.end))); + existing.insert( + marker.card_id, + (entity, transform.translation, anim.map(|a| a.end)), + ); } let live_ids: HashSet = positions.iter().map(|(c, _, _)| c.id).collect(); @@ -750,12 +788,37 @@ fn sync_cards( None => false, }; update_card_entity( - &mut commands, entity, card, position, z, layout, - slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle, + &mut commands, + entity, + card, + position, + z, + layout, + slide_secs, + back_colour, + color_blind, + high_contrast, + cur, + has_anim, + card_images, + selected_back, + font_handle, ); entity } - None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle), + None => spawn_card_entity( + &mut commands, + card, + position, + z, + layout, + back_colour, + color_blind, + high_contrast, + card_images, + selected_back, + font_handle, + ), }; let visibility = if waste_buffer_id == Some(card.id) { Visibility::Hidden @@ -793,8 +856,16 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve // the top fanned card's centre within the waste column's own horizontal // footprint instead of spilling into the adjacent gap. let waste_fan_step = { - let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default(); - let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default(); + let s = layout + .pile_positions + .get(&PileType::Stock) + .copied() + .unwrap_or_default(); + let w = layout + .pile_positions + .get(&PileType::Waste) + .copied() + .unwrap_or_default(); (w.x - s.x).abs() * 0.224 }; @@ -873,7 +944,13 @@ fn spawn_card_entity( selected_back: usize, font_handle: Option<&Handle>, ) -> Entity { - let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back); + let sprite = card_sprite( + card, + layout.card_size, + back_colour, + card_images, + selected_back, + ); let mut entity = commands.spawn(( CardEntity { card_id: card.id }, @@ -915,7 +992,14 @@ fn spawn_card_entity( } if USE_TOUCH_UI_LAYOUT && card_images.is_some() { entity.with_children(|b| { - add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle); + add_android_corner_label( + b, + card, + layout.card_size, + color_blind, + high_contrast, + font_handle, + ); }); } entity_id @@ -942,7 +1026,13 @@ fn update_card_entity( let target = Vec3::new(pos.x, pos.y, z); // Always refresh the visual appearance. - commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, card_images, selected_back)); + commands.entity(entity).insert(card_sprite( + card, + layout.card_size, + back_colour, + card_images, + selected_back, + )); // Skip the snap/slide path entirely when a curve-based `CardAnimation` // is driving this card (e.g. the drag-rejection return tween). Writing @@ -1004,7 +1094,14 @@ fn update_card_entity( } if USE_TOUCH_UI_LAYOUT && card_images.is_some() { commands.entity(entity).with_children(|b| { - add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle); + add_android_corner_label( + b, + card, + layout.card_size, + color_blind, + high_contrast, + font_handle, + ); }); } } @@ -1197,11 +1294,7 @@ fn add_android_corner_label( }, TextColor(text_col), Anchor::TOP_LEFT, - Transform::from_xyz( - -card_size.x / 2.0 + inset, - card_size.y / 2.0 - inset, - 0.02, - ), + Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02), )); } @@ -1324,7 +1417,9 @@ fn update_drag_shadow( match *shadow { Some(e) => { // Reposition the existing shadow. - commands.entity(e).insert(Transform::from_translation(shadow_pos)); + commands + .entity(e) + .insert(Transform::from_translation(shadow_pos)); } None => { // Spawn a new shadow sprite. Alpha tracks the per-card @@ -1413,11 +1508,18 @@ fn tick_hint_highlight( sprite.color = if use_images { Color::WHITE } else { - let is_face_up = game.0.piles.values() + let is_face_up = game + .0 + .piles + .values() .flat_map(|p| p.cards.iter()) .find(|c| c.id == card_entity.card_id) .is_some_and(|c| c.face_up); - if is_face_up { CARD_FACE_COLOUR } else { card_back_colour(back_idx) } + if is_face_up { + CARD_FACE_COLOUR + } else { + card_back_colour(back_idx) + } }; commands .entity(entity) @@ -1450,7 +1552,10 @@ fn tick_right_click_highlights( mut commands: Commands, time: Res