feat(difficulty): add difficulty-tier game mode with seed catalogs and home UI
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel). Five seed catalogs (40 seeds each) are pre-verified by the new gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves). DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed → NewGameRequestEvent; Random uses a system-time seed and bypasses the winnable-only filter. The home overlay gets an expandable Difficulty section between Draw Mode and the mode grid; last-played tier persists in Settings. Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+24
-15
@@ -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
|
||||
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <seed> starting seed (hex or decimal)");
|
||||
eprintln!(" --per-tier <n> 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<u64>> = 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::<Vec<_>>().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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u64> = [
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DifficultyLevel>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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::<DifficultyIndexResource>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
handle_difficulty_request.before(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolves `StartDifficultyRequestEvent` → catalog seed → `NewGameRequestEvent`.
|
||||
fn handle_difficulty_request(
|
||||
mut requests: MessageReader<StartDifficultyRequestEvent>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut index: ResMut<DifficultyIndexResource>,
|
||||
) {
|
||||
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<NewGameRequestEvent> {
|
||||
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::<DifficultyExpanded>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<StartChallengeRequestEvent>()
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<StartPlayBySeedRequestEvent>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
@@ -253,13 +276,10 @@ impl Plugin for HomePlugin {
|
||||
// runs cleanly under MinimalPlugins headless tests too.
|
||||
.add_message::<MouseWheel>()
|
||||
// `.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<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||
) {
|
||||
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<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
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<Res<StatsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
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<HomeDifficultyToggle>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
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<Interaction>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
|
||||
mut settings: Option<ResMut<SettingsResource>>,
|
||||
storage_path: Option<Res<SettingsStoragePath>>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
) {
|
||||
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<DailyToday>,
|
||||
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<DifficultyLevel>,
|
||||
}
|
||||
|
||||
/// 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<M: Component>(
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user