diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a355d5..fd7851f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,21 +6,28 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] -**`395a322`** — double-tap auto-move (2026-05-08). -**`0cb1587`** — Play-by-Seed dialog (2026-05-08). -**`2062bd0`** — 75 new challenge seeds + gen_seeds binary (2026-05-08). -**`45436d0`** — gate handle_fullscreen to non-Android (2026-05-08). -**`2c822ba`** — JNI clipboard bridge for Android Stats share-link (2026-05-08). -**`f281425`** — Android Keystore AES-GCM token storage via JNI (2026-05-08). -See [0.21.9] for the committed detail once cut. +## [0.22.0] — 2026-05-08 -## [0.21.9] — pending cut - -Closes the "Prev/Next selector chips spawn site" punch-list item from -v0.19.0. +Adds difficulty-tier game selection, Android JNI bridges for keystore and +clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens. +Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0. ### Added +- **Difficulty-tier game mode** (this release). + `DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster / + Random`) added to `solitaire_core::game_state` alongside a new + `GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed + catalogs (40 seeds each, 200 total) are generated by the new + `gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog + contains seeds proven winnable at progressively larger solver budgets + (1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent` + → catalog seed → `NewGameRequestEvent`; the `Random` tier uses a + system-time seed and intentionally bypasses the winnable-only filter. + The home overlay gains an expandable `▶ Difficulty` section between the + Draw Mode row and the mode-card grid; the last-played tier is persisted + in `Settings::last_difficulty` and pre-expands/highlights on re-open. + Difficulty wins pool into Classic stats (no separate buckets). - **Prev/Next replay selector in the Stats overlay** (`a449f60`). `ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and `ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen` @@ -116,11 +123,13 @@ confirmed end-to-end device run. ### Stats -- Tests: **1292 passing** / 0 failing (+10 from double-tap + Play-by-Seed tests) +- Tests: **1300+ passing** / 0 failing - Clippy: clean -- Crates touched: `solitaire_engine` (input_plugin, events, home_plugin, - play_by_seed_plugin, lib), `solitaire_app` (lib.rs), `solitaire_data` - (challenge.rs), `solitaire_assetgen` (gen_seeds binary, Cargo.toml) +- Crates touched: `solitaire_core` (game_state), `solitaire_data` + (settings, stats, difficulty_seeds, challenge), `solitaire_engine` + (events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin, + input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs), + `solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries) ## [0.21.8] — 2026-05-08 diff --git a/Cargo.lock b/Cargo.lock index 32cb155..40ffd78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6988,6 +6988,7 @@ dependencies = [ "axum", "chrono", "dirs", + "jni 0.21.1", "jsonwebtoken", "keyring-core", "reqwest", @@ -7011,6 +7012,7 @@ dependencies = [ "bevy", "chrono", "dirs", + "jni 0.21.1", "kira", "resvg", "ron", diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index c355c15..56f18d1 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -27,8 +27,8 @@ use solitaire_data::{load_settings_from, provider_for_backend, settings_file_pat use solitaire_engine::{ register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, - CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin, - GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, + CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, + FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, @@ -170,6 +170,7 @@ pub fn run() { .add_plugins(WeeklyGoalsPlugin) .add_plugins(ChallengePlugin) .add_plugins(PlayBySeedPlugin) + .add_plugins(DifficultyPlugin) .add_plugins(TimeAttackPlugin) .add_plugins(HudPlugin) .add_plugins(HelpPlugin) diff --git a/solitaire_assetgen/Cargo.toml b/solitaire_assetgen/Cargo.toml index 70be544..f7c2e85 100644 --- a/solitaire_assetgen/Cargo.toml +++ b/solitaire_assetgen/Cargo.toml @@ -26,3 +26,7 @@ path = "src/bin/gen_art.rs" [[bin]] name = "gen_seeds" path = "src/bin/gen_seeds.rs" + +[[bin]] +name = "gen_difficulty_seeds" +path = "src/bin/gen_difficulty_seeds.rs" diff --git a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs new file mode 100644 index 0000000..c8dfd09 --- /dev/null +++ b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs @@ -0,0 +1,195 @@ +//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`, +//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in +//! `solitaire_data/src/difficulty_seeds.rs`. +//! +//! A seed's tier is determined by the **smallest** `SolverConfig` budget that +//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget +//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit +//! provably-winnable seeds). +//! +//! # Usage +//! +//! ```bash +//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \ +//! --start 0xD1FF0000_00000000 --per-tier 40 +//! ``` +//! +//! Flags: +//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000) +//! --per-tier Seeds to emit per tier (default 40) +//! --help Print this message + +use solitaire_core::game_state::DrawMode; +use solitaire_core::solver::{try_solve, SolverConfig, SolverResult}; + +// 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), + ("Grandmaster", 200_000, 200_000), +]; + +fn main() { + let mut args = std::env::args().skip(1).peekable(); + let mut start: u64 = 0xD1FF_0000_0000_0000; + let mut per_tier: usize = 40; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--start" => { + let val = args.next().unwrap_or_else(|| { + eprintln!("error: --start requires a value"); + std::process::exit(1); + }); + start = parse_u64(&val); + } + "--per-tier" => { + let val = args.next().unwrap_or_else(|| { + eprintln!("error: --per-tier requires a value"); + std::process::exit(1); + }); + per_tier = val.parse().unwrap_or_else(|_| { + eprintln!("error: --per-tier must be a positive integer"); + std::process::exit(1); + }); + } + "--help" | "-h" => { + eprintln!("gen_difficulty_seeds: generate tiered seed catalogs"); + eprintln!(" --start starting seed (hex or decimal)"); + eprintln!(" --per-tier seeds per tier (default 40)"); + return; + } + other => { + eprintln!("error: unknown argument: {other}"); + std::process::exit(1); + } + } + } + + if per_tier == 0 { + eprintln!("error: --per-tier must be > 0"); + std::process::exit(1); + } + + let draw_mode = DrawMode::DrawOne; + let num_tiers = BUDGETS.len(); + let mut buckets: Vec> = vec![Vec::with_capacity(per_tier); num_tiers]; + let mut tried: u64 = 0; + let mut seed = start; + + eprintln!( + "gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …", + per_tier + ); + eprintln!( + " Tiers: {}", + BUDGETS.iter().map(|(n, _, _)| *n).collect::>().join(", ") + ); + + while buckets.iter().any(|b| b.len() < per_tier) { + tried += 1; + 'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() { + if buckets[i].len() >= per_tier { + continue; + } + let cfg = SolverConfig { move_budget, state_budget }; + match try_solve(seed, draw_mode.clone(), &cfg) { + SolverResult::Winnable => { + buckets[i].push(seed); + eprintln!( + " [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})", + buckets[i].len(), + per_tier + ); + break 'tier; // assign to the cheapest tier that proves it winnable + } + SolverResult::Unwinnable => { + // Definitely unsolvable — skip all remaining tiers. + break 'tier; + } + SolverResult::Inconclusive => { + // Budget exhausted without proof — try the next larger tier. + // If this is the last tier, the seed is discarded (Inconclusive + // at max budget means "probably but not provably winnable"). + if i == num_tiers - 1 { + break 'tier; + } + } + } + } + seed = seed.wrapping_add(1); + } + + 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() { + println!( + " // Generated by solitaire_assetgen::gen_difficulty_seeds \ + (tier={tier_name}, date={date})" + ); + for chunk in buckets[i].chunks(5) { + for s in chunk { + println!( + " 0x{:04X}_{:04X}_{:04X}_{:04X},", + (s >> 48) & 0xFFFF, + (s >> 32) & 0xFFFF, + (s >> 16) & 0xFFFF, + s & 0xFFFF, + ); + } + } + println!(); + } +} + +fn parse_u64(s: &str) -> u64 { + let cleaned = s.replace('_', ""); + 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); + }) + } else { + cleaned.parse().unwrap_or_else(|_| { + eprintln!("error: could not parse '{s}' as a decimal u64"); + std::process::exit(1); + }) + } +} + +fn current_date() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = secs / 86400; + let mut y = 1970u64; + let mut d = days; + loop { + let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400); + let days_in_year = if leap { 366 } else { 365 }; + if d < days_in_year { + break; + } + d -= days_in_year; + 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 mut m = 0usize; + for &md in &month_days { + if d < md { + break; + } + d -= md; + m += 1; + } + format!("{y}-{:02}-{:02}", m + 1, d + 1) +} diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 6fb1237..16043d2 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -50,6 +50,35 @@ pub enum DrawMode { DrawThree, } +/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed +/// catalog is drawn from. `Random` skips verification entirely and uses a +/// system-time seed — deals may or may not be winnable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum DifficultyLevel { + #[default] + Easy, + Medium, + Hard, + Expert, + Grandmaster, + /// Unverified system-time seed — may or may not be winnable. + Random, +} + +impl DifficultyLevel { + /// Short human-readable label shown in the HUD and win summary. + pub fn label(self) -> &'static str { + match self { + Self::Easy => "Easy", + Self::Medium => "Medium", + Self::Hard => "Hard", + Self::Expert => "Expert", + Self::Grandmaster => "Grandmaster", + Self::Random => "Random", + } + } +} + /// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour. /// /// - `Classic`: standard Klondike scoring, undo allowed. @@ -59,6 +88,8 @@ pub enum DrawMode { /// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute /// countdown around the session and auto-deals a fresh game on every win /// (see `solitaire_engine::TimeAttackPlugin`). +/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog +/// (or system-time for `Random`). Rules identical to Classic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum GameMode { #[default] @@ -70,6 +101,8 @@ pub enum GameMode { Challenge, /// Play as many games as possible within 10 minutes. TimeAttack, + /// Seed drawn from a difficulty-tiered catalog; rules identical to Classic. + Difficulty(DifficultyLevel), } /// Snapshot of game state used for undo. diff --git a/solitaire_data/src/difficulty_seeds.rs b/solitaire_data/src/difficulty_seeds.rs new file mode 100644 index 0000000..ac86ee9 --- /dev/null +++ b/solitaire_data/src/difficulty_seeds.rs @@ -0,0 +1,320 @@ +//! Pre-verified seed catalogs for each [`DifficultyLevel`] tier. +//! +//! Each slice contains seeds that are provably winnable in Draw-One mode and +//! that required a specific solver-budget range to solve — the **smallest** +//! budget that returns `Winnable` determines the tier. See +//! `solitaire_assetgen/src/bin/gen_difficulty_seeds.rs` for the generator. +//! +//! # Tiers and budget boundaries +//! +//! | Tier | move_budget | state_budget | +//! |-------------|-------------|--------------| +//! | 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 | +//! +//! [`DifficultyLevel::Random`] has no catalog — the engine picks a system-time +//! seed and skips verification. + +use solitaire_core::game_state::DifficultyLevel; + +// --------------------------------------------------------------------------- +// Catalogs (populated by gen_difficulty_seeds) +// --------------------------------------------------------------------------- + +/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states). +pub const EASY_SEEDS: &[u64] = &[ + // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09) + 0xD1FF_0000_0000_0001, + 0xD1FF_0000_0000_0002, + 0xD1FF_0000_0000_0007, + 0xD1FF_0000_0000_0008, + 0xD1FF_0000_0000_0009, + 0xD1FF_0000_0000_000E, + 0xD1FF_0000_0000_0013, + 0xD1FF_0000_0000_0015, + 0xD1FF_0000_0000_0018, + 0xD1FF_0000_0000_001D, + 0xD1FF_0000_0000_0021, + 0xD1FF_0000_0000_0022, + 0xD1FF_0000_0000_0026, + 0xD1FF_0000_0000_002C, + 0xD1FF_0000_0000_002E, + 0xD1FF_0000_0000_002F, + 0xD1FF_0000_0000_0035, + 0xD1FF_0000_0000_0036, + 0xD1FF_0000_0000_003C, + 0xD1FF_0000_0000_0045, + 0xD1FF_0000_0000_0046, + 0xD1FF_0000_0000_0048, + 0xD1FF_0000_0000_0049, + 0xD1FF_0000_0000_004D, + 0xD1FF_0000_0000_004F, + 0xD1FF_0000_0000_0050, + 0xD1FF_0000_0000_0051, + 0xD1FF_0000_0000_0053, + 0xD1FF_0000_0000_0054, + 0xD1FF_0000_0000_0057, + 0xD1FF_0000_0000_0058, + 0xD1FF_0000_0000_005A, + 0xD1FF_0000_0000_005B, + 0xD1FF_0000_0000_005C, + 0xD1FF_0000_0000_005D, + 0xD1FF_0000_0000_005F, + 0xD1FF_0000_0000_0061, + 0xD1FF_0000_0000_0062, + 0xD1FF_0000_0000_0063, + 0xD1FF_0000_0000_0069, +]; + +/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states). +pub const MEDIUM_SEEDS: &[u64] = &[ + // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09) + 0xD1FF_0000_0000_0000, + 0xD1FF_0000_0000_0012, + 0xD1FF_0000_0000_0016, + 0xD1FF_0000_0000_001B, + 0xD1FF_0000_0000_001C, + 0xD1FF_0000_0000_0020, + 0xD1FF_0000_0000_002A, + 0xD1FF_0000_0000_0034, + 0xD1FF_0000_0000_003A, + 0xD1FF_0000_0000_0041, + 0xD1FF_0000_0000_0043, + 0xD1FF_0000_0000_0060, + 0xD1FF_0000_0000_006A, + 0xD1FF_0000_0000_006C, + 0xD1FF_0000_0000_006E, + 0xD1FF_0000_0000_006F, + 0xD1FF_0000_0000_0071, + 0xD1FF_0000_0000_0072, + 0xD1FF_0000_0000_0075, + 0xD1FF_0000_0000_0076, + 0xD1FF_0000_0000_007B, + 0xD1FF_0000_0000_007E, + 0xD1FF_0000_0000_0081, + 0xD1FF_0000_0000_0083, + 0xD1FF_0000_0000_0084, + 0xD1FF_0000_0000_0087, + 0xD1FF_0000_0000_0090, + 0xD1FF_0000_0000_0092, + 0xD1FF_0000_0000_0093, + 0xD1FF_0000_0000_0098, + 0xD1FF_0000_0000_0099, + 0xD1FF_0000_0000_009A, + 0xD1FF_0000_0000_009E, + 0xD1FF_0000_0000_00A5, + 0xD1FF_0000_0000_00A8, + 0xD1FF_0000_0000_00AA, + 0xD1FF_0000_0000_00AB, + 0xD1FF_0000_0000_00AE, + 0xD1FF_0000_0000_00AF, + 0xD1FF_0000_0000_00B0, +]; + +/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states). +pub const HARD_SEEDS: &[u64] = &[ + // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09) + 0xD1FF_0000_0000_001F, + 0xD1FF_0000_0000_0024, + 0xD1FF_0000_0000_0025, + 0xD1FF_0000_0000_0031, + 0xD1FF_0000_0000_0032, + 0xD1FF_0000_0000_003E, + 0xD1FF_0000_0000_004A, + 0xD1FF_0000_0000_006D, + 0xD1FF_0000_0000_0079, + 0xD1FF_0000_0000_007C, + 0xD1FF_0000_0000_0080, + 0xD1FF_0000_0000_008A, + 0xD1FF_0000_0000_0097, + 0xD1FF_0000_0000_00B1, + 0xD1FF_0000_0000_00B2, + 0xD1FF_0000_0000_00B3, + 0xD1FF_0000_0000_00B5, + 0xD1FF_0000_0000_00B7, + 0xD1FF_0000_0000_00B8, + 0xD1FF_0000_0000_00B9, + 0xD1FF_0000_0000_00BA, + 0xD1FF_0000_0000_00BB, + 0xD1FF_0000_0000_00BC, + 0xD1FF_0000_0000_00BD, + 0xD1FF_0000_0000_00C2, + 0xD1FF_0000_0000_00C3, + 0xD1FF_0000_0000_00C5, + 0xD1FF_0000_0000_00CC, + 0xD1FF_0000_0000_00CE, + 0xD1FF_0000_0000_00D1, + 0xD1FF_0000_0000_00D2, + 0xD1FF_0000_0000_00D6, + 0xD1FF_0000_0000_00D7, + 0xD1FF_0000_0000_00DC, + 0xD1FF_0000_0000_00DF, + 0xD1FF_0000_0000_00E0, + 0xD1FF_0000_0000_00E1, + 0xD1FF_0000_0000_00E4, + 0xD1FF_0000_0000_00E6, + 0xD1FF_0000_0000_00E7, +]; + +/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states). +pub const EXPERT_SEEDS: &[u64] = &[ + // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09) + 0xD1FF_0000_0000_0006, + 0xD1FF_0000_0000_000B, + 0xD1FF_0000_0000_0019, + 0xD1FF_0000_0000_0082, + 0xD1FF_0000_0000_00CB, + 0xD1FF_0000_0000_00D5, + 0xD1FF_0000_0000_00D8, + 0xD1FF_0000_0000_00E8, + 0xD1FF_0000_0000_00EA, + 0xD1FF_0000_0000_00EB, + 0xD1FF_0000_0000_00EC, + 0xD1FF_0000_0000_00ED, + 0xD1FF_0000_0000_00F2, + 0xD1FF_0000_0000_00F3, + 0xD1FF_0000_0000_00F4, + 0xD1FF_0000_0000_00FE, + 0xD1FF_0000_0000_00FF, + 0xD1FF_0000_0000_0102, + 0xD1FF_0000_0000_0103, + 0xD1FF_0000_0000_0104, + 0xD1FF_0000_0000_0105, + 0xD1FF_0000_0000_0106, + 0xD1FF_0000_0000_0109, + 0xD1FF_0000_0000_010B, + 0xD1FF_0000_0000_010C, + 0xD1FF_0000_0000_0110, + 0xD1FF_0000_0000_0113, + 0xD1FF_0000_0000_0114, + 0xD1FF_0000_0000_011B, + 0xD1FF_0000_0000_011C, + 0xD1FF_0000_0000_011E, + 0xD1FF_0000_0000_0120, + 0xD1FF_0000_0000_0121, + 0xD1FF_0000_0000_0122, + 0xD1FF_0000_0000_0123, + 0xD1FF_0000_0000_0124, + 0xD1FF_0000_0000_0126, + 0xD1FF_0000_0000_012B, + 0xD1FF_0000_0000_012C, + 0xD1FF_0000_0000_012E, +]; + +/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states). +pub const GRANDMASTER_SEEDS: &[u64] = &[ + // Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09) + 0xD1FF_0000_0000_0027, + 0xD1FF_0000_0000_00A0, + 0xD1FF_0000_0000_00C4, + 0xD1FF_0000_0000_00D4, + 0xD1FF_0000_0000_00DE, + 0xD1FF_0000_0000_00F9, + 0xD1FF_0000_0000_0107, + 0xD1FF_0000_0000_0108, + 0xD1FF_0000_0000_0130, + 0xD1FF_0000_0000_0132, + 0xD1FF_0000_0000_0133, + 0xD1FF_0000_0000_0134, + 0xD1FF_0000_0000_0135, + 0xD1FF_0000_0000_0137, + 0xD1FF_0000_0000_0139, + 0xD1FF_0000_0000_013A, + 0xD1FF_0000_0000_013D, + 0xD1FF_0000_0000_013F, + 0xD1FF_0000_0000_0140, + 0xD1FF_0000_0000_0141, + 0xD1FF_0000_0000_0142, + 0xD1FF_0000_0000_0143, + 0xD1FF_0000_0000_0145, + 0xD1FF_0000_0000_0146, + 0xD1FF_0000_0000_014A, + 0xD1FF_0000_0000_014B, + 0xD1FF_0000_0000_014C, + 0xD1FF_0000_0000_014D, + 0xD1FF_0000_0000_014F, + 0xD1FF_0000_0000_0150, + 0xD1FF_0000_0000_0151, + 0xD1FF_0000_0000_0152, + 0xD1FF_0000_0000_0153, + 0xD1FF_0000_0000_0157, + 0xD1FF_0000_0000_0158, + 0xD1FF_0000_0000_015B, + 0xD1FF_0000_0000_015C, + 0xD1FF_0000_0000_015E, + 0xD1FF_0000_0000_0162, + 0xD1FF_0000_0000_0164, +]; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Type alias for the catalog lookup return: a static slice or `None` for `Random`. +pub type DifficultySeeds = Option<&'static [u64]>; + +/// Return the seed catalog for `level`, or `None` for `Random` (caller must +/// use a system-time seed instead). +pub fn seeds_for(level: DifficultyLevel) -> DifficultySeeds { + match level { + DifficultyLevel::Easy => Some(EASY_SEEDS), + DifficultyLevel::Medium => Some(MEDIUM_SEEDS), + DifficultyLevel::Hard => Some(HARD_SEEDS), + DifficultyLevel::Expert => Some(EXPERT_SEEDS), + DifficultyLevel::Grandmaster => Some(GRANDMASTER_SEEDS), + DifficultyLevel::Random => None, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_difficulty_seeds_are_unique() { + let all: Vec = [ + EASY_SEEDS, + MEDIUM_SEEDS, + HARD_SEEDS, + EXPERT_SEEDS, + GRANDMASTER_SEEDS, + ] + .iter() + .flat_map(|s| s.iter().copied()) + .collect(); + + let mut sorted = all.clone(); + sorted.sort_unstable(); + let before = sorted.len(); + sorted.dedup(); + assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers"); + } + + #[test] + fn seeds_for_random_returns_none() { + assert!(seeds_for(DifficultyLevel::Random).is_none()); + } + + #[test] + fn seeds_for_non_random_returns_some() { + for level in [ + DifficultyLevel::Easy, + DifficultyLevel::Medium, + DifficultyLevel::Hard, + DifficultyLevel::Expert, + DifficultyLevel::Grandmaster, + ] { + assert!( + seeds_for(level).is_some(), + "{level:?} should return Some catalog" + ); + } + } +} diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 8d890a2..3483fa1 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -9,7 +9,7 @@ use std::io; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use solitaire_core::game_state::DrawMode; +use solitaire_core::game_state::{DifficultyLevel, DrawMode}; const APP_DIR_NAME: &str = "solitaire_quest"; const SETTINGS_FILE_NAME: &str = "settings.json"; @@ -224,6 +224,13 @@ pub struct Settings { /// `#[serde(default = "default_replay_move_interval_secs")]`. #[serde(default = "default_replay_move_interval_secs")] pub replay_move_interval_secs: f32, + /// Last difficulty tier the player selected. `None` means the player has + /// never used the difficulty picker. When `Some`, the difficulty section in + /// the home overlay opens pre-expanded and highlights this tier. Older + /// `settings.json` files written before this field existed deserialize + /// cleanly to `None` via `#[serde(default)]`. + #[serde(default)] + pub last_difficulty: Option, } fn default_draw_mode() -> DrawMode { @@ -342,6 +349,7 @@ impl Default for Settings { winnable_deals_only: false, disable_smart_default_size: false, replay_move_interval_secs: default_replay_move_interval_secs(), + last_difficulty: None, } } } diff --git a/solitaire_data/src/stats.rs b/solitaire_data/src/stats.rs index aaf7f79..444a514 100644 --- a/solitaire_data/src/stats.rs +++ b/solitaire_data/src/stats.rs @@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot { // Time Attack uses its own session-level scoring; a per-game best // wouldn't compose with the other modes' single-game numbers. GameMode::TimeAttack => {} + // Difficulty games pool into the Classic best-score/time buckets per + // the user's stats preference. + GameMode::Difficulty(_) => { + self.classic_best_score = self.classic_best_score.max(score_u32); + self.classic_fastest_win_seconds = + min_ignore_zero(self.classic_fastest_win_seconds, time_seconds); + } } self.last_modified = Utc::now(); } diff --git a/solitaire_engine/src/difficulty_plugin.rs b/solitaire_engine/src/difficulty_plugin.rs new file mode 100644 index 0000000..bb96e11 --- /dev/null +++ b/solitaire_engine/src/difficulty_plugin.rs @@ -0,0 +1,235 @@ +//! Difficulty-tier game-start plugin. +//! +//! Handles [`StartDifficultyRequestEvent`] by picking the next seed from the +//! appropriate pre-verified catalog in `solitaire_data::difficulty_seeds` and +//! writing a [`NewGameRequestEvent`]. For [`DifficultyLevel::Random`] a +//! system-time seed is used instead — the deal may or may not be winnable. +//! +//! # Catalog cycling +//! +//! Each tier maintains an independent cursor in [`DifficultyIndexResource`] +//! that advances one step each time a game is started at that tier. The cursor +//! wraps modulo the catalog length so players never run out of variety. The +//! resource is *not* persisted — it resets to 0 on every launch, which is fine +//! because the starting position is effectively random (player-chosen timing +//! determines which seed in the 40-entry catalog they start at). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use bevy::prelude::*; +use solitaire_core::game_state::{DifficultyLevel, GameMode}; +use solitaire_data::difficulty_seeds::seeds_for; + +use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent}; +use crate::game_plugin::GameMutation; + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/// Per-tier catalog cursors. Each value is the index of the **next** seed to +/// deal from that tier's catalog. Wraps modulo the catalog length. +#[derive(Resource, Default)] +pub struct DifficultyIndexResource { + easy: usize, + medium: usize, + hard: usize, + expert: usize, + grandmaster: usize, +} + +impl DifficultyIndexResource { + /// Advance the cursor for `level` and return the seed at the old position. + /// Falls back to a system-time seed if the catalog is unexpectedly empty. + pub fn next_seed(&mut self, level: DifficultyLevel) -> u64 { + let Some(catalog) = seeds_for(level) else { + return seed_from_system_time(); + }; + if catalog.is_empty() { + return seed_from_system_time(); + } + let cursor = match level { + DifficultyLevel::Easy => &mut self.easy, + DifficultyLevel::Medium => &mut self.medium, + DifficultyLevel::Hard => &mut self.hard, + DifficultyLevel::Expert => &mut self.expert, + DifficultyLevel::Grandmaster => &mut self.grandmaster, + DifficultyLevel::Random => unreachable!("Random has no catalog"), + }; + let seed = catalog[*cursor % catalog.len()]; + *cursor = cursor.wrapping_add(1); + seed + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Registers all difficulty-mode systems and resources. +pub struct DifficultyPlugin; + +impl Plugin for DifficultyPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_message::() + .add_message::() + .add_systems( + Update, + handle_difficulty_request.before(GameMutation), + ); + } +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Resolves `StartDifficultyRequestEvent` → catalog seed → `NewGameRequestEvent`. +fn handle_difficulty_request( + mut requests: MessageReader, + mut new_game: MessageWriter, + mut index: ResMut, +) { + for ev in requests.read() { + let seed = if ev.level == DifficultyLevel::Random { + seed_from_system_time() + } else { + index.next_seed(ev.level) + }; + + new_game.write(NewGameRequestEvent { + seed: Some(seed), + mode: Some(GameMode::Difficulty(ev.level)), + confirmed: false, + }); + } +} + +fn seed_from_system_time() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0xD1FF_0000_DEAD_BEEF) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::game_plugin::GamePlugin; + use crate::table_plugin::TablePlugin; + use solitaire_data::difficulty_seeds::{EASY_SEEDS, MEDIUM_SEEDS}; + + fn headless_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(DifficultyPlugin); + app.update(); + app + } + + fn fire_request(app: &mut App, level: DifficultyLevel) { + app.world_mut() + .write_message(StartDifficultyRequestEvent { level }); + app.update(); + } + + fn drain_new_game_events(app: &mut App) -> Vec { + let msgs = app.world().resource::>(); + let mut cursor = msgs.get_cursor(); + cursor.read(msgs).copied().collect() + } + + #[test] + fn easy_request_dispatches_seed_from_easy_catalog() { + let mut app = headless_app(); + fire_request(&mut app, DifficultyLevel::Easy); + + let events = drain_new_game_events(&mut app); + assert_eq!(events.len(), 1); + let ev = &events[0]; + assert!(ev.seed.is_some()); + assert_eq!(ev.mode, Some(GameMode::Difficulty(DifficultyLevel::Easy))); + assert!(!ev.confirmed); + // Seed must come from the Easy catalog (non-empty catalog is the test + // precondition — the catalog uniqueness test in difficulty_seeds.rs + // guards integrity). + if !EASY_SEEDS.is_empty() { + assert!( + EASY_SEEDS.contains(&ev.seed.unwrap()), + "seed {:?} not in EASY_SEEDS", + ev.seed + ); + } + } + + #[test] + fn successive_easy_requests_cycle_through_catalog() { + let mut app = headless_app(); + fire_request(&mut app, DifficultyLevel::Easy); + fire_request(&mut app, DifficultyLevel::Easy); + + let events = drain_new_game_events(&mut app); + assert_eq!(events.len(), 2); + // Two successive requests should return different seeds (assuming the + // catalog has at least 2 entries — it has 40). + if EASY_SEEDS.len() >= 2 { + assert_ne!( + events[0].seed, events[1].seed, + "successive Easy requests should produce different seeds" + ); + } + } + + #[test] + fn medium_request_dispatches_seed_from_medium_catalog() { + let mut app = headless_app(); + fire_request(&mut app, DifficultyLevel::Medium); + + let events = drain_new_game_events(&mut app); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].mode, + Some(GameMode::Difficulty(DifficultyLevel::Medium)) + ); + if !MEDIUM_SEEDS.is_empty() { + assert!(MEDIUM_SEEDS.contains(&events[0].seed.unwrap())); + } + } + + #[test] + fn random_request_dispatches_some_seed_with_random_mode() { + let mut app = headless_app(); + fire_request(&mut app, DifficultyLevel::Random); + + let events = drain_new_game_events(&mut app); + assert_eq!(events.len(), 1); + assert!(events[0].seed.is_some(), "Random should always produce Some(seed)"); + assert_eq!( + events[0].mode, + Some(GameMode::Difficulty(DifficultyLevel::Random)) + ); + } + + #[test] + fn different_tier_cursors_are_independent() { + let mut app = headless_app(); + fire_request(&mut app, DifficultyLevel::Easy); + fire_request(&mut app, DifficultyLevel::Medium); + + let events = drain_new_game_events(&mut app); + assert_eq!(events.len(), 2); + // Seeds from different catalogs should differ (they come from different + // address ranges by construction of gen_difficulty_seeds). + assert_ne!( + events[0].seed, events[1].seed, + "Easy and Medium should draw from independent catalogs" + ); + } +} diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index cac0434..4fd2417 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -179,6 +179,16 @@ pub struct StartDailyChallengeRequestEvent; #[derive(Message, Debug, Clone, Copy, Default)] pub struct StartPlayBySeedRequestEvent; +/// Request to start a game at a specific difficulty tier. Fired by the +/// difficulty section in the home overlay. The handler in `difficulty_plugin` +/// picks a seed from the corresponding pre-verified catalog (or generates a +/// random system-time seed for `DifficultyLevel::Random`) and writes a +/// `NewGameRequestEvent`. +#[derive(Message, Debug, Clone, Copy)] +pub struct StartDifficultyRequestEvent { + pub level: solitaire_core::game_state::DifficultyLevel, +} + /// Request to toggle the Stats overlay. Fired by the HUD Menu-popover /// "Stats" row alongside the existing `S` accelerator. #[derive(Message, Debug, Clone, Copy, Default)] diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 0546a2f..8795d93 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -16,15 +16,15 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::ButtonInput; use bevy::prelude::*; -use solitaire_core::game_state::DrawMode; +use solitaire_core::game_state::{DifficultyLevel, DrawMode}; use solitaire_data::save_settings_to; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::events::{ InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, - StartDailyChallengeRequestEvent, StartPlayBySeedRequestEvent, StartTimeAttackRequestEvent, - StartZenRequestEvent, ToggleProfileRequestEvent, + StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, + StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent, }; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; @@ -81,6 +81,27 @@ struct HomeDrawThreeButton; #[derive(Component, Debug)] struct HomeScrollable; +/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that +/// expands / collapses the difficulty tier chip row. +#[derive(Component, Debug)] +struct HomeDifficultyToggle; + +/// Marker on each difficulty tier chip inside the expanded difficulty +/// section. The wrapped `DifficultyLevel` identifies which tier was +/// clicked so the handler can fire `StartDifficultyRequestEvent`. +#[derive(Component, Debug)] +struct HomeDifficultyChip(DifficultyLevel); + +/// Whether the difficulty section is currently expanded. Toggled by +/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen` +/// to determine initial render state. +/// +/// Initialised at plugin startup; `spawn_home_on_launch` upgrades it +/// to `true` when `settings.last_difficulty` is already set so +/// returning players see their tier pre-expanded. +#[derive(Resource, Default, Debug)] +pub struct DifficultyExpanded(pub bool); + // --------------------------------------------------------------------------- // Private mode-card data shape // --------------------------------------------------------------------------- @@ -240,12 +261,14 @@ impl Plugin for HomePlugin { // Pre-mark the auto-show as already done in headless mode so the // gating system is a permanent no-op for tests. app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch)) + .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -253,13 +276,10 @@ impl Plugin for HomePlugin { // runs cleanly under MinimalPlugins headless tests too. .add_message::() // `.chain()` because several systems (M-toggle, card click, - // cancel button, digit-key shortcut) all read the - // `HomeScreen` entity and may queue a despawn on it in the - // same tick. Bevy's parallel scheduler would otherwise let - // two of them run simultaneously and double-despawn the - // entity, panicking when the second command buffer is - // applied. Chaining serialises these systems and keeps the - // despawn deterministic. + // cancel button, digit-key shortcut, difficulty handlers) + // all read the `HomeScreen` entity and may queue a despawn + // on it in the same tick. Chaining serialises these systems + // and keeps the despawn deterministic. .add_systems( Update, ( @@ -270,6 +290,8 @@ impl Plugin for HomePlugin { handle_home_cancel_button, handle_home_profile_chip, handle_home_draw_mode_buttons, + handle_home_difficulty_toggle, + handle_home_difficulty_chip_click, handle_home_digit_keys, ) .chain(), @@ -314,6 +336,7 @@ fn spawn_home_on_launch( settings: Option>, daily: Option>, font_res: Option>, + mut diff_expanded: ResMut, ) { if shown.0 || !splash.is_empty() @@ -324,6 +347,11 @@ fn spawn_home_on_launch( return; } + // Pre-expand the difficulty section when the player has a saved preference. + if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) { + diff_expanded.0 = true; + } + spawn_home_screen( &mut commands, build_home_context( @@ -332,6 +360,7 @@ fn spawn_home_on_launch( settings.as_deref(), daily.as_deref(), font_res.as_deref(), + diff_expanded.0, ), ); shown.0 = true; @@ -351,6 +380,7 @@ fn toggle_home_screen( daily: Option>, font_res: Option>, screens: Query>, + diff_expanded: Res, ) { if !keys.just_pressed(KeyCode::KeyM) { return; @@ -366,6 +396,7 @@ fn toggle_home_screen( settings.as_deref(), daily.as_deref(), font_res.as_deref(), + diff_expanded.0, ), ); } @@ -381,6 +412,7 @@ fn build_home_context<'a>( settings: Option<&SettingsResource>, daily: Option<&DailyChallengeResource>, font_res: Option<&'a FontResource>, + difficulty_expanded: bool, ) -> HomeContext<'a> { let daily_today = daily.map(|d| { let completed_today = progress @@ -406,6 +438,8 @@ fn build_home_context<'a>( .map(|s| s.0.draw_mode.clone()) .unwrap_or(DrawMode::DrawOne), font_res, + difficulty_expanded, + last_difficulty: settings.and_then(|s| s.0.last_difficulty), } } @@ -569,6 +603,7 @@ fn handle_home_draw_mode_buttons( stats: Option>, daily: Option>, font_res: Option>, + diff_expanded: Res, ) { if screens.is_empty() { return; @@ -612,10 +647,92 @@ fn handle_home_draw_mode_buttons( Some(settings), daily.as_deref(), font_res.as_deref(), + diff_expanded.0, ), ); } +// --------------------------------------------------------------------------- +// Difficulty section handlers +// --------------------------------------------------------------------------- + +/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and +/// repaint the Home modal so the chevron and chip row update. Mirrors +/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in +/// `spawn_difficulty_section` rather than scattered across mutation helpers. +#[allow(clippy::too_many_arguments)] +fn handle_home_difficulty_toggle( + mut commands: Commands, + toggles: Query<&Interaction, (With, Changed)>, + screens: Query>, + mut diff_expanded: ResMut, + progress: Option>, + stats: Option>, + settings: Option>, + daily: Option>, + font_res: Option>, +) { + if screens.is_empty() { + return; + } + if !toggles.iter().any(|i| *i == Interaction::Pressed) { + return; + } + diff_expanded.0 = !diff_expanded.0; + for entity in &screens { + commands.entity(entity).despawn(); + } + spawn_home_screen( + &mut commands, + build_home_context( + progress.as_deref(), + stats.as_deref(), + settings.as_deref(), + daily.as_deref(), + font_res.as_deref(), + diff_expanded.0, + ), + ); +} + +/// Click on a difficulty tier chip — persist `last_difficulty`, fire +/// `StartDifficultyRequestEvent`, and close the Home modal. +#[allow(clippy::too_many_arguments)] +fn handle_home_difficulty_chip_click( + mut commands: Commands, + chips: Query<(&Interaction, &HomeDifficultyChip), Changed>, + screens: Query>, + mut difficulty_ev: MessageWriter, + mut settings: Option>, + storage_path: Option>, + mut changed: MessageWriter, +) { + if screens.is_empty() { + return; + } + let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else { + return; + }; + let level = chip.0; + + if let Some(s) = settings.as_mut() { + s.0.last_difficulty = Some(level); + if let Some(p) = storage_path + && let Some(path) = p.0.as_deref() + && let Err(e) = save_settings_to(path, &s.0) + { + warn!("home: failed to persist last_difficulty: {e}"); + } + changed.write(SettingsChangedEvent(s.0.clone())); + } + + difficulty_ev.write(StartDifficultyRequestEvent { level }); + + for entity in &screens { + commands.entity(entity).despawn(); + } +} + // --------------------------------------------------------------------------- // Digit-key shortcuts (1-5) — modal-scoped // --------------------------------------------------------------------------- @@ -735,6 +852,11 @@ struct HomeContext<'a> { daily_today: Option, draw_mode: DrawMode, font_res: Option<&'a FontResource>, + /// Whether the difficulty section header is currently expanded. + difficulty_expanded: bool, + /// The last difficulty tier the player selected (persisted in Settings). + /// When `Some`, that tier's chip is highlighted. + last_difficulty: Option, } /// Today's daily-challenge metadata as the Home picker needs it. Only @@ -807,6 +929,8 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) { spawn_mode_card(grid, mode, &ctx); } }); + + spawn_difficulty_section(body, &ctx); }); spawn_modal_actions(card, |actions| { @@ -970,6 +1094,101 @@ fn spawn_draw_mode_chip( }); } +/// Collapsible difficulty-tier section injected below the mode tile grid. +/// +/// Structure: +/// ```text +/// ▶ Difficulty ← HomeDifficultyToggle (Button, row) +/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded +/// ``` +/// +/// The toggle header despawns + respawns the home screen (same pattern as +/// the draw-mode toggle) so the chevron direction and chip row visibility +/// update without Visibility component surgery. +fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { + let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() }; + let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; + + let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" }; + + // Header row — click to toggle expand/collapse. + parent + .spawn(( + HomeDifficultyToggle, + Button, + Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_2, + padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1), + ..default() + }, + )) + .with_children(|row| { + row.spawn(( + Text::new(chevron), + font_label.clone(), + TextColor(TEXT_SECONDARY), + )); + row.spawn(( + Text::new("Difficulty"), + font_label.clone(), + TextColor(TEXT_SECONDARY), + )); + }); + + // Tier chips — only rendered when expanded. + if ctx.difficulty_expanded { + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + row_gap: VAL_SPACE_2, + column_gap: VAL_SPACE_2, + width: Val::Percent(100.0), + ..default() + }) + .with_children(|row| { + for level in [ + DifficultyLevel::Easy, + DifficultyLevel::Medium, + DifficultyLevel::Hard, + DifficultyLevel::Expert, + DifficultyLevel::Grandmaster, + DifficultyLevel::Random, + ] { + let active = ctx.last_difficulty == Some(level); + let (bg, fg) = if active { + (ACCENT_PRIMARY, BG_ELEVATED) + } else { + (BG_ELEVATED_HI, TEXT_PRIMARY) + }; + row.spawn(( + HomeDifficultyChip(level), + Button, + Node { + padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(bg), + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|c| { + c.spawn(( + Text::new(level.label()), + font_chip.clone(), + TextColor(fg), + )); + }); + } + }); + } +} + /// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`, /// otherwise the raw number with thousands separators. Keeps chip text /// short enough to fit a 3-up header strip without wrapping. diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index de206c2..dfc0782 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -1741,6 +1741,7 @@ fn update_hud( GameMode::Zen => "ZEN".to_string(), GameMode::Challenge => "CHALLENGE".to_string(), GameMode::TimeAttack => "TIME ATTACK".to_string(), + GameMode::Difficulty(level) => level.label().to_uppercase(), }; } diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index baac827..6b23fa9 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -352,7 +352,7 @@ impl ScoreBreakdown { let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 }; let multiplier = match mode { GameMode::Zen => 0.0, - GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0, + GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0, }; Self { base, @@ -423,6 +423,7 @@ fn mode_display_name(mode: GameMode) -> &'static str { GameMode::Zen => "Zen", GameMode::Challenge => "Challenge", GameMode::TimeAttack => "Time Attack", + GameMode::Difficulty(level) => level.label(), } }