Compare commits

...

10 Commits

Author SHA1 Message Date
funman300 13b428b81c feat(engine): first-run onboarding banner
OnboardingPlugin spawns a centered welcome banner at PostStartup
when Settings.first_run_complete is false. Any key or mouse
press dismisses it, sets the flag, and persists settings.json
so returning players never see it again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 23:14:10 -07:00
funman300 9d0f9478b2 feat(data,engine): persistent Settings + SFX volume hotkeys
- solitaire_data::Settings { sfx_volume, first_run_complete } with
  atomic JSON persistence and clamping sanitizer.
- SettingsPlugin (engine): [ / ] adjust SFX volume by 0.1, clamped;
  persists on change; emits SettingsChangedEvent. No-op at rails.
- AudioPlugin applies sfx_volume to kira's main track at startup
  and on every change so live tweaks take effect without restart.
- Brief "SFX: N%" toast on each change. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 23:08:20 -07:00
funman300 b720588687 feat(engine): MoveRejectedEvent + PausePlugin (Esc)
- New MoveRejectedEvent fires from end_drag when the cursor is over
  a real pile but the placement is illegal. AudioPlugin plays
  card_invalid.wav on it.
- New PausePlugin + PausedResource: Esc toggles a full-window
  overlay and the flag. tick_elapsed_time and advance_time_attack
  skip work while paused. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:56:35 -07:00
funman300 adacdf533c feat(engine,assetgen): synthesized SFX + kira AudioPlugin
- New solitaire_assetgen crate with gen_sfx binary: synthesizes
  five 44.1kHz mono 16-bit PCM WAVs (flip/place/deal/invalid/fanfare)
  from an LCG noise source + sine/square synths. Output committed
  under assets/audio/.
- AudioPlugin (engine): embeds the WAVs via include_bytes!, decodes
  once with kira::StaticSoundData, plays on Draw / Move / NewGame /
  GameWon events. card_invalid is loaded but unused — wiring it
  needs a MoveRejectedEvent.
- AudioManager kept on the main thread (NonSend) since cpal is !Send
  on some platforms; degrades gracefully if no audio device present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:48:58 -07:00
funman300 7dfbff45d1 feat(engine): add HelpPlugin (H/?) and Challenge cleared toast
- HelpPlugin: full-window cheat sheet listing every keybinding,
  toggled with H or ?. Three unit tests cover open/close/slash.
- AnimationPlugin: ChallengeAdvancedEvent now surfaces as a
  3-second "Challenge N cleared!" toast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:39:08 -07:00
funman300 193410200e feat(engine,core): add Time Attack mode + unlocks panel
- Core: GameMode::TimeAttack variant (no scoring/undo changes — session marker only)
- Engine: TimeAttackPlugin with TimeAttackResource, TimeAttackEndedEvent,
  T hotkey (gated to level >= 5), auto-deal on win, summary toast
- Engine: Stats overlay (S) gains an Unlocks subsection (card backs /
  backgrounds, sorted/deduped) and a live Time Attack panel while active

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:27:53 -07:00
funman300 294f6fe9d4 docs: mark Phase 6 part 4b (Challenge mode + level-5 gate) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:18:50 -07:00
funman300 788ac9f65a feat(engine,core,data): add Challenge mode with seed list and level-5 gate
Phase 6 part 4b (partial):
- GameMode::Challenge variant in solitaire_core. undo() returns
  RuleViolation when mode is Challenge so the player commits to each
  decision.
- solitaire_data::challenge defines a stable CHALLENGE_SEEDS list with
  challenge_seed_for(index) wrapping modulo length.
- PlayerProgress.challenge_index (serde-default for older saves) tracks
  how far the player has progressed.
- ChallengePlugin advances the cursor on Challenge-mode wins, persists,
  and emits ChallengeAdvancedEvent. Pressing X starts a Challenge-mode
  game with the current seed; gated to level >= CHALLENGE_UNLOCK_LEVEL (5).
- InputPlugin's Z key (Zen mode) is now also gated to level >= 5.

Time Attack and unlock UI still deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:18:32 -07:00
funman300 09d62f4255 docs: mark Phase 6 part 4a (elapsed time + Zen mode) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:16:04 -07:00
funman300 8afb1f3fe5 feat(engine,core): add elapsed-time tick system and Zen GameMode
Phase 6 part 4 (partial):
- GameState now tracks elapsed_seconds via tick_elapsed_time in GamePlugin
  (per-second increment while not won). Pure helper advance_elapsed makes
  the tick logic directly testable without mocking Bevy Time.
- New GameMode enum (Classic / Zen) on GameState. Zen mode suppresses
  scoring in move_cards and undo. GameState::new_with_mode allows callers
  to construct non-Classic games; the existing GameState::new still
  defaults to Classic. mode is serde(default) for backwards-compatible
  persistence.
- NewGameRequestEvent gains an optional mode field; handle_new_game
  honours it (falling back to the current game's mode when None).
- InputPlugin: pressing Z starts a fresh Zen-mode game.

Time Attack, Challenge mode, level-5 unlock gating, and unlock UI are
still deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:14:57 -07:00
30 changed files with 2189 additions and 40 deletions
Generated
+4
View File
@@ -5672,6 +5672,10 @@ dependencies = [
"solitaire_engine",
]
[[package]]
name = "solitaire_assetgen"
version = "0.1.0"
[[package]]
name = "solitaire_core"
version = "0.1.0"
+1
View File
@@ -7,6 +7,7 @@ members = [
"solitaire_server",
"solitaire_gpgs",
"solitaire_app",
"solitaire_assetgen",
]
resolver = "2"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69 -13
View File
@@ -1,8 +1,8 @@
# Solitaire Quest — Session Handoff
> Last updated: 2026-04-24
> Last updated: 2026-04-25
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
> Test count: **196 passing** (77 core + 50 data + 69 engine), `cargo clippy --workspace -- -D warnings` clean
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
---
@@ -125,22 +125,78 @@ All sub-phases (3A3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin
- `AnimationPlugin` now surfaces `DailyChallengeCompletedEvent` (shows streak) and `WeeklyGoalCompletedEvent` (shows goal description) as 3-second toasts.
- Stats overlay (**S** key) appends a Progression section: level, total XP, daily streak, and a Weekly Goals list iterating `WEEKLY_GOALS` with `progress/target` for each.
### Phase 6 (part 4a) — Elapsed Time + Zen Mode ✅ COMPLETE
- `tick_elapsed_time` in `GamePlugin` ticks `GameState.elapsed_seconds` once per real-world second while not won; `advance_elapsed` is a pure helper for direct unit testing.
- `GameMode` enum (`Classic` / `Zen`) added to `solitaire_core::game_state`. `GameState.mode` field; `GameState::new_with_mode` ctor. Zen suppresses scoring in `move_cards` and `undo`. Field is `#[serde(default)]` for backwards-compatible saved games.
- `NewGameRequestEvent` carries an optional `mode`; `handle_new_game` falls back to the current game's mode when `None`.
- `Z` key starts a fresh Zen game.
### Phase 6 (part 4b) — Challenge Mode + Level-5 Gate ✅ COMPLETE
- `GameMode::Challenge` variant in core; `undo()` returns `RuleViolation` in Challenge.
- `solitaire_data::challenge``CHALLENGE_SEEDS` static list, `challenge_seed_for(index)` wrapping modulo length, `challenge_count()`.
- `PlayerProgress.challenge_index` (serde-default) tracks progression.
- `ChallengePlugin` advances the cursor on Challenge-mode wins, persists, fires `ChallengeAdvancedEvent`. **X** key starts a Challenge-mode game with the current seed.
- Both **Z** (Zen) and **X** (Challenge) are gated to `level >= CHALLENGE_UNLOCK_LEVEL` (5).
### Phase 6 (part 4c) — Time Attack + Unlock UI ✅ COMPLETE
- `GameMode::TimeAttack` variant added to core (no scoring/undo changes — just a session marker).
- `TimeAttackPlugin` (engine) — `TimeAttackResource { active, remaining_secs, wins }` (session-only, not persisted), `TimeAttackEndedEvent { wins }`. **T** starts a session (gated to level ≥ 5) and deals a TimeAttack-mode game; the timer (`TIME_ATTACK_DURATION_SECS = 600.0`) decrements each frame; wins during the active session bump the counter and auto-deal a fresh game.
- `AnimationPlugin` surfaces `TimeAttackEndedEvent` as a 5-second summary toast.
- `StatsPlugin` overlay (**S**) appends an "Unlocks" subsection (card backs / backgrounds, sorted/deduped, "None" when empty) and a live "Time Attack" panel showing remaining minutes/seconds + wins while a session is active.
- Helper `format_id_list` factored out + tested.
### Phase 7 (part 1) — Help Overlay + Challenge Toast ✅ COMPLETE
- `HelpPlugin`: **H** or `?` toggles a full-window cheat sheet listing all keybindings (gameplay, mode hotkeys, overlays). 3 unit tests.
- `AnimationPlugin` now surfaces `ChallengeAdvancedEvent` as a 3-second toast ("Challenge N cleared!").
### Phase 7 (part 2) — Synthesized SFX + AudioPlugin ✅ COMPLETE
- New workspace crate `solitaire_assetgen` with bin `gen_sfx`. Synthesizes five 44.1kHz mono 16-bit PCM WAVs from a deterministic LCG noise source + sine/square synths into `assets/audio/`. Run with `cargo run -p solitaire_assetgen --bin gen_sfx`. Output is committed; end users never run the generator.
- `AudioPlugin` (`solitaire_engine`): embeds the WAVs via `include_bytes!()`, decodes once via `kira::StaticSoundData::from_cursor`, plays on `DrawRequestEvent` (flip), `MoveRequestEvent` (place), `NewGameRequestEvent` (deal), `GameWonEvent` (fanfare).
- Backend handle stored as `NonSend` (cpal stream is `!Send` on some platforms). Plugin degrades gracefully if no audio device is available — logs a warning, gameplay continues silently.
- Single decode unit test (`embedded_wavs_decode_successfully`) keeps the loader and generator in sync.
### Phase 7 (part 3) — MoveRejectedEvent + Pause Menu ✅ COMPLETE
- New `MoveRejectedEvent { from, to, count }`. `end_drag` fires it when the cursor is over a real pile but `can_place_*` rejects the placement. `AudioPlugin` plays `card_invalid.wav` on it.
- New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more.
- `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour.
### Phase 7 (part 4) — Settings + SFX Volume Control ✅ COMPLETE
- New `solitaire_data::Settings { sfx_volume, first_run_complete }` with atomic JSON persistence (`save_settings_to` / `load_settings_from`). `sanitized()` clamps out-of-range volumes after deserialization. Default `sfx_volume = 0.8`.
- New `SettingsPlugin` (engine) with `SettingsResource`, `headless()` ctor, and `SettingsChangedEvent`. **\[** / **\]** adjust SFX volume by `SFX_STEP` (0.1), clamped; persists on change. No-op + no event when already at the rail.
- `AudioPlugin` applies `sfx_volume` to kira's main track at startup and on every `SettingsChangedEvent` (so changes take effect mid-game without restart).
- `AnimationPlugin` shows a brief "SFX: 70%" toast on every change so players see the new value.
- Help cheat sheet lists the **\[** / **\]** keys.
- 4 plugin tests + 6 data tests added — defaults, clamping, round-trip persistence.
### Phase 7 (part 5) — First-Run Onboarding ✅ COMPLETE
- New `OnboardingPlugin`. At `PostStartup`, if `Settings.first_run_complete == false`, spawns a centered welcome banner pointing at the **H**/`?` cheat sheet (ZIndex 230). Any key or mouse-button press dismisses it, sets the flag, and persists `settings.json` — returning players never see it again.
- 4 unit tests cover spawn-only-on-first-run, key dismiss, and click dismiss.
## What Is Next
### Phase 6 (part 4) — Special Modes + Unlock UI
Phase 7 polish slate is done. Phase 8 (sync) is next.
- Time Attack / Challenge / Zen modes (unlock at level 5). Add a `GameMode` enum in `solitaire_core::game_state`; `GameState` tracks its mode; Zen skips scoring, Challenge disables undo, Time Attack ends on timer.
- Mode selector UI + keyboard shortcut (e.g. `Z` for Zen) + extend `NewGameRequestEvent` with an optional mode.
- Card-back / background unlock UI for `unlocked_card_backs` / `unlocked_backgrounds`.
- Elapsed-time tracking — currently `GameState.elapsed_seconds` stays at 0; wire a timer system.
### Phases 78 (in order after Phase 6 part 4)
### Phase 8 — Sync
| Phase | Scope |
|---|---|
| Phase 7 | Audio (`kira`), polish, hints, onboarding, pause menu |
| Phase 8AC | Local storage + `SyncProvider` + self-hosted Axum server + client |
| Phase 8D | GPGS stub fully wired into settings UI |
| Phase 8A | Local storage scaffolding + `SyncProvider` plumbing in `solitaire_data` |
| Phase 8B | Self-hosted Axum server (auth, sync endpoints, SQLite schema) |
| Phase 8C | `SolitaireServerClient` (`SyncProvider` impl) + `SyncPlugin` lifecycle |
| Phase 8D | GPGS stub fully wired into the settings UI (Android-only `cfg`-gated) |
### Tiny optional polish (anytime)
- **Ambient loop**: optional sixth WAV — needs taste, deferred until artwork phase.
- **Block input while paused**: drag/hotkeys still work mid-pause; tightening this would make pause behave more like a true modal.
---
@@ -191,7 +247,7 @@ For Phase 3 onwards, write a new plan using the `superpowers:writing-plans` skil
# Check everything compiles
cargo check --workspace
# Run all tests (196 tests, all should pass)
# Run all tests (214 tests, all should pass)
cargo test --workspace
# Lint (must be zero warnings)
+10 -2
View File
@@ -1,7 +1,8 @@
use bevy::prelude::*;
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin,
ProgressPlugin, StatsPlugin, TablePlugin, WeeklyGoalsPlugin,
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
ProgressPlugin, SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
};
fn main() {
@@ -26,5 +27,12 @@ fn main() {
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HelpPlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.run();
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "solitaire_assetgen"
version.workspace = true
edition.workspace = true
publish = false
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`.
# Not depended on by any other workspace crate.
[[bin]]
name = "gen_sfx"
path = "src/bin/gen_sfx.rs"
+207
View File
@@ -0,0 +1,207 @@
//! Synthesize placeholder SFX into `assets/audio/`.
//!
//! Output: 44.1kHz mono 16-bit PCM WAV. Run with
//! `cargo run -p solitaire_assetgen --bin gen_sfx`. Files are committed to
//! the repo so end-users never need to run this generator.
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
const SAMPLE_RATE: u32 = 44_100;
type Generator = fn() -> Vec<i16>;
fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 5] = [
("card_flip.wav", card_flip),
("card_place.wav", card_place),
("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare),
];
for (name, gen) in &effects {
let samples = gen();
let path = out_dir.join(name);
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
println!("wrote {} ({} samples)", path.display(), samples.len());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Synth primitives
// ---------------------------------------------------------------------------
/// Simple deterministic noise source — LCG, no `rand` dep needed.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_f32(&mut self) -> f32 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 32) as i32 as f32) / (i32::MAX as f32)
}
}
fn duration_samples(seconds: f32) -> usize {
(seconds * SAMPLE_RATE as f32) as usize
}
/// Linear attack / exponential decay envelope. `attack` and length in seconds.
fn ar_envelope(t_secs: f32, attack: f32, total: f32, decay_rate: f32) -> f32 {
if t_secs < attack {
(t_secs / attack).clamp(0.0, 1.0)
} else {
(-decay_rate * (t_secs - attack)).exp() * (1.0 - (t_secs - total).max(0.0))
}
}
fn quantize(sample: f32) -> i16 {
let clipped = sample.clamp(-1.0, 1.0);
(clipped * 32_767.0) as i16
}
// ---------------------------------------------------------------------------
// Effect generators
// ---------------------------------------------------------------------------
fn card_flip() -> Vec<i16> {
let n = duration_samples(0.08);
let mut rng = Lcg::new(0x1234_5678_DEAD_BEEF);
let mut out = Vec::with_capacity(n);
let mut prev = 0.0f32;
let alpha = 0.35;
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let raw = rng.next_f32();
// High-pass-ish: subtract a low-pass-smoothed signal.
let lp = alpha * raw + (1.0 - alpha) * prev;
prev = lp;
let hp = raw - lp;
let env = ar_envelope(t, 0.005, 0.08, 60.0);
out.push(quantize(hp * env * 0.6));
}
out
}
fn card_place() -> Vec<i16> {
let n = duration_samples(0.14);
let mut rng = Lcg::new(0xCAFE_F00D_8BAD_F00D);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
// Low sine for body (~120 Hz) + filtered noise for click.
let body = (2.0 * std::f32::consts::PI * 120.0 * t).sin();
let click = rng.next_f32() * 0.5;
let env = ar_envelope(t, 0.003, 0.14, 35.0);
let sample = (body * 0.7 + click) * env * 0.55;
out.push(quantize(sample));
}
out
}
fn card_deal() -> Vec<i16> {
let n = duration_samples(0.18);
let mut rng = Lcg::new(0xFEE1_DEAD_DEAD_BEEF);
let mut out = Vec::with_capacity(n);
let mut lp = 0.0f32;
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let raw = rng.next_f32();
// Sweeping low-pass: cutoff falls over time → "whoosh".
let alpha = 0.6 - (t / 0.18) * 0.5;
lp = alpha * raw + (1.0 - alpha) * lp;
let env = ar_envelope(t, 0.01, 0.18, 18.0);
out.push(quantize(lp * env * 0.7));
}
out
}
fn card_invalid() -> Vec<i16> {
let n = duration_samples(0.18);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
// Two dissonant squarish tones — strong beat creates a buzz.
let a = (2.0 * std::f32::consts::PI * 196.0 * t).sin().signum();
let b = (2.0 * std::f32::consts::PI * 207.65 * t).sin().signum();
let env = ar_envelope(t, 0.005, 0.18, 12.0);
out.push(quantize((a + b) * env * 0.18));
}
out
}
fn win_fanfare() -> Vec<i16> {
// C major arpeggio: C5, E5, G5, C6.
let notes = [523.25_f32, 659.25, 783.99, 1046.50];
let note_dur = 0.18_f32;
let total = note_dur * notes.len() as f32 + 0.25;
let n = duration_samples(total);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let mut sample = 0.0f32;
for (idx, freq) in notes.iter().enumerate() {
let start = idx as f32 * note_dur;
let local = t - start;
if !(0.0..=0.4).contains(&local) {
continue;
}
// Layered sine + soft 2nd harmonic for warmth.
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
+ 0.3 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
let env = ar_envelope(local, 0.008, 0.4, 6.0);
sample += s * env;
}
out.push(quantize(sample * 0.22));
}
out
}
// ---------------------------------------------------------------------------
// Minimal WAV writer (mono 16-bit PCM)
// ---------------------------------------------------------------------------
fn write_wav_mono_pcm16(path: &Path, sample_rate: u32, samples: &[i16]) -> io::Result<()> {
let mut f = File::create(path)?;
let byte_rate = sample_rate * 2; // mono 16-bit
let data_bytes = samples.len() as u32 * 2;
let chunk_size = 36 + data_bytes;
f.write_all(b"RIFF")?;
f.write_all(&chunk_size.to_le_bytes())?;
f.write_all(b"WAVE")?;
f.write_all(b"fmt ")?;
f.write_all(&16u32.to_le_bytes())?; // PCM fmt chunk size
f.write_all(&1u16.to_le_bytes())?; // PCM
f.write_all(&1u16.to_le_bytes())?; // mono
f.write_all(&sample_rate.to_le_bytes())?;
f.write_all(&byte_rate.to_le_bytes())?;
f.write_all(&2u16.to_le_bytes())?; // block align
f.write_all(&16u16.to_le_bytes())?; // bits per sample
f.write_all(b"data")?;
f.write_all(&data_bytes.to_le_bytes())?;
for &s in samples {
f.write_all(&s.to_le_bytes())?;
}
Ok(())
}
fn workspace_root() -> PathBuf {
// CARGO_MANIFEST_DIR points at the assetgen crate; parent is workspace.
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir.parent().expect("workspace root").to_path_buf()
}
+95 -3
View File
@@ -16,6 +16,24 @@ pub enum DrawMode {
DrawThree,
}
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
///
/// - `Classic`: standard Klondike scoring, undo allowed.
/// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play.
/// - `Challenge`: standard scoring, **undo disabled** (returns
/// `MoveError::RuleViolation`).
/// - `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`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode {
#[default]
Classic,
Zen,
Challenge,
TimeAttack,
}
/// Snapshot of game state used for undo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct StateSnapshot {
@@ -29,6 +47,10 @@ struct StateSnapshot {
pub struct GameState {
pub piles: HashMap<PileType, Pile>,
pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
/// compatibility with older save files via `#[serde(default)]`.
#[serde(default)]
pub mode: GameMode,
pub score: i32,
pub move_count: u32,
pub elapsed_seconds: u64,
@@ -42,8 +64,13 @@ pub struct GameState {
}
impl GameState {
/// Creates a new game dealt from the given seed and draw mode.
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
}
/// Creates a new game with an explicit `GameMode`.
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
let mut deck = Deck::new();
deck.shuffle(seed);
let (tableau, stock) = deal_klondike(deck);
@@ -61,6 +88,7 @@ impl GameState {
Self {
piles,
draw_mode,
mode,
score: 0,
move_count: 0,
elapsed_seconds: 0,
@@ -200,7 +228,11 @@ impl GameState {
start
};
let score_delta = score_move(&from, &to);
let score_delta = if self.mode == GameMode::Zen {
0
} else {
score_move(&from, &to)
};
self.push_snapshot();
// Execute move
@@ -236,13 +268,23 @@ impl GameState {
}
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
/// Disabled in `GameMode::Challenge` — returns `MoveError::RuleViolation`.
pub fn undo(&mut self) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
}
if self.mode == GameMode::Challenge {
return Err(MoveError::RuleViolation(
"undo is disabled in Challenge mode".into(),
));
}
let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?;
self.piles = snapshot.piles;
self.score = (snapshot.score + scoring_undo()).max(0);
self.score = if self.mode == GameMode::Zen {
0
} else {
(snapshot.score + scoring_undo()).max(0)
};
self.move_count = snapshot.move_count;
self.is_won = false;
self.is_auto_completable = false;
@@ -508,6 +550,56 @@ mod tests {
assert!(g.score >= 0);
}
// --- GameMode: Zen ---
#[test]
fn zen_mode_score_stays_zero_after_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.score, 0);
}
#[test]
fn zen_mode_default_is_classic_via_default_trait() {
assert_eq!(GameMode::default(), GameMode::Classic);
}
#[test]
fn zen_mode_field_persists_through_construction() {
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
assert_eq!(g.mode, GameMode::Zen);
assert_eq!(g.draw_mode, DrawMode::DrawThree);
}
// --- GameMode: Challenge ---
#[test]
fn challenge_mode_disables_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
g.draw().unwrap();
let result = g.undo();
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn challenge_mode_still_allows_normal_moves() {
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
// Just verify the game initialises cleanly with Challenge mode.
assert_eq!(g.mode, GameMode::Challenge);
assert_eq!(g.score, 0);
}
#[test]
fn challenge_mode_scoring_applies_normally() {
// Challenge uses Classic scoring; only undo is disabled.
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
assert_eq!(g.score, 0);
// Note: Verifying score increases on actual moves would require
// hand-crafting a legal move from the dealt state. We rely on the
// fact that move_cards' score path is identical to Classic.
}
// --- Auto-complete ---
#[test]
+73
View File
@@ -0,0 +1,73 @@
//! Static seed list for Challenge mode + helpers.
//!
//! Challenge mode walks a fixed sequence of hard-but-winnable seeds. The
//! player advances by winning a deal in `GameMode::Challenge`. The
//! `challenge_index` cursor is stored per-player in `PlayerProgress`.
//!
//! Seeds wrap modulo `CHALLENGE_SEEDS.len()` so a sufficiently dedicated
//! player never runs out of challenges.
/// Curated Challenge-mode seeds. Order is stable across versions; add new
/// seeds at the end.
pub const CHALLENGE_SEEDS: &[u64] = &[
0xDEAD_BEEF_CAFE_F00D,
0xC0DE_FACE_8BAD_F00D,
0xFEE1_DEAD_DEAD_BEEF,
0xBAAD_F00D_BAAD_F00D,
0x1337_C0DE_4242_BABE,
];
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
/// the index exceeds the seed-list length. Returns `None` if the seed list
/// is empty (defensive — `CHALLENGE_SEEDS` is non-empty by construction).
pub fn challenge_seed_for(index: u32) -> Option<u64> {
if CHALLENGE_SEEDS.is_empty() {
return None;
}
Some(CHALLENGE_SEEDS[(index as usize) % CHALLENGE_SEEDS.len()])
}
/// Total number of currently-defined challenges. Useful for displaying
/// "Challenge {n + 1} of {total}" in UI.
pub fn challenge_count() -> u32 {
CHALLENGE_SEEDS.len() as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn challenge_seed_for_0_is_first_seed() {
assert_eq!(challenge_seed_for(0), Some(CHALLENGE_SEEDS[0]));
}
#[test]
fn challenge_seed_wraps_past_end() {
let len = CHALLENGE_SEEDS.len() as u32;
assert_eq!(
challenge_seed_for(len),
Some(CHALLENGE_SEEDS[0]),
"wraps to seed 0 when index == len"
);
assert_eq!(
challenge_seed_for(len + 2),
Some(CHALLENGE_SEEDS[2]),
"wraps modulo len"
);
}
#[test]
fn all_challenge_seeds_are_unique() {
let mut seeds: Vec<u64> = CHALLENGE_SEEDS.to_vec();
seeds.sort();
let len_before = seeds.len();
seeds.dedup();
assert_eq!(seeds.len(), len_before);
}
#[test]
fn challenge_count_matches_seed_list_length() {
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
}
}
+6
View File
@@ -57,3 +57,9 @@ pub use weekly::{
current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
WEEKLY_GOALS, WEEKLY_GOAL_XP,
};
pub mod challenge;
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings;
pub use settings::{load_settings_from, save_settings_to, settings_file_path, Settings};
+6
View File
@@ -67,6 +67,11 @@ pub struct PlayerProgress {
pub weekly_goal_week_iso: Option<String>,
pub unlocked_card_backs: Vec<usize>,
pub unlocked_backgrounds: Vec<usize>,
/// Index of the next Challenge-mode seed the player will be served.
/// Increments on each Challenge-mode win. Out-of-range values wrap modulo
/// `CHALLENGE_SEEDS.len()` at lookup time.
#[serde(default)]
pub challenge_index: u32,
pub last_modified: DateTime<Utc>,
}
@@ -81,6 +86,7 @@ impl Default for PlayerProgress {
weekly_goal_week_iso: None,
unlocked_card_backs: vec![0], // back #0 always available
unlocked_backgrounds: vec![0], // background #0 always available
challenge_index: 0,
last_modified: DateTime::UNIX_EPOCH,
}
}
+145
View File
@@ -0,0 +1,145 @@
//! User settings (persistent).
//!
//! Currently tracks SFX volume and the first-run flag. Other fields from
//! ARCHITECTURE.md §9 (`draw_mode`, `music_volume`, `theme`, `sync_backend`)
//! will land alongside the systems that need them.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const APP_DIR_NAME: &str = "solitaire_quest";
const SETTINGS_FILE_NAME: &str = "settings.json";
/// Persistent user settings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's main track gain.
pub sfx_volume: f32,
/// Set to `true` once the player has dismissed the first-run banner.
pub first_run_complete: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
sfx_volume: 0.8,
first_run_complete: false,
}
}
}
impl Settings {
/// Clamps `sfx_volume` into `[0.0, 1.0]` after deserialization or
/// hand-editing of `settings.json`.
pub fn sanitized(self) -> Self {
Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
..self
}
}
/// Adjust SFX volume by `delta`, clamped to `[0.0, 1.0]`. Returns the new value.
pub fn adjust_sfx_volume(&mut self, delta: f32) -> f32 {
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
self.sfx_volume
}
}
/// Returns the platform-specific path to `settings.json`, or `None` if
/// `dirs::data_dir()` is unavailable.
pub fn settings_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
}
/// Load settings from an explicit path. Returns `Settings::default()` if the
/// file is missing or cannot be deserialized.
pub fn load_settings_from(path: &Path) -> Settings {
let Ok(data) = fs::read(path) else {
return Settings::default();
};
serde_json::from_slice::<Settings>(&data)
.unwrap_or_default()
.sanitized()
}
/// Save settings to an explicit path using an atomic write (`.tmp` → rename).
pub fn save_settings_to(path: &Path, settings: &Settings) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(settings).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
}
#[test]
fn defaults_are_reasonable() {
let s = Settings::default();
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
assert!(!s.first_run_complete);
}
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings::default();
s.sfx_volume = 0.5;
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
}
#[test]
fn sanitized_clamps_out_of_range_volume() {
let s = Settings {
sfx_volume: 5.0,
first_run_complete: true,
}
.sanitized();
assert_eq!(s.sfx_volume, 1.0);
assert!(s.first_run_complete);
}
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
sfx_volume: 0.42,
first_run_complete: true,
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz");
let _ = fs::remove_file(&path);
let s = load_settings_from(&path);
assert_eq!(s, Settings::default());
}
#[test]
fn load_from_corrupt_file_returns_default() {
let path = tmp_path("corrupt");
fs::write(&path, b"definitely not json").expect("write");
let s = load_settings_from(&path);
assert_eq!(s, Settings::default());
}
}
+52
View File
@@ -7,11 +7,14 @@ use bevy::prelude::*;
use crate::achievement_plugin::display_name_for;
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::SettingsChangedEvent;
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
/// Duration of a card slide (move) animation in seconds.
@@ -22,6 +25,9 @@ const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
const LEVELUP_TOAST_SECS: f32 = 3.0;
const DAILY_TOAST_SECS: f32 = 3.0;
const WEEKLY_TOAST_SECS: f32 = 3.0;
const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
const CHALLENGE_TOAST_SECS: f32 = 3.0;
const VOLUME_TOAST_SECS: f32 = 1.4;
const CASCADE_STAGGER: f32 = 0.05;
const CASCADE_DURATION: f32 = 0.5;
@@ -59,6 +65,9 @@ impl Plugin for AnimationPlugin {
.add_event::<LevelUpEvent>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<ChallengeAdvancedEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems(
Update,
(
@@ -68,6 +77,9 @@ impl Plugin for AnimationPlugin {
handle_levelup_toast,
handle_daily_toast,
handle_weekly_toast,
handle_time_attack_toast,
handle_challenge_toast,
handle_settings_toast,
tick_toasts,
)
.after(GameMutation),
@@ -181,6 +193,46 @@ fn handle_weekly_toast(
}
}
fn handle_time_attack_toast(
mut commands: Commands,
mut events: EventReader<TimeAttackEndedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
TIME_ATTACK_TOAST_SECS,
);
}
}
fn handle_challenge_toast(
mut commands: Commands,
mut events: EventReader<ChallengeAdvancedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
CHALLENGE_TOAST_SECS,
);
}
}
fn handle_settings_toast(
mut commands: Commands,
mut events: EventReader<SettingsChangedEvent>,
) {
for ev in events.read() {
let pct = (ev.0.sfx_volume * 100.0).round() as i32;
spawn_toast(
&mut commands,
format!("SFX: {pct}%"),
VOLUME_TOAST_SECS,
);
}
}
fn tick_toasts(
mut commands: Commands,
time: Res<Time>,
+226
View File
@@ -0,0 +1,226 @@
//! Sound-effect playback via `kira`.
//!
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
//! response to gameplay events:
//!
//! | Event | Sound |
//! |---|---|
//! | `DrawRequestEvent` | `card_flip.wav` |
//! | `MoveRequestEvent` | `card_place.wav` |
//! | `MoveRejectedEvent` | `card_invalid.wav` |
//! | `NewGameRequestEvent` | `card_deal.wav` |
//! | `GameWonEvent` | `win_fanfare.wav` |
//!
//! If the audio device cannot be opened (e.g. a headless CI machine or a
//! Linux box without a running PulseAudio/Pipewire session), the plugin
//! logs a warning and degrades gracefully — gameplay continues, just
//! silently.
use std::io::Cursor;
use bevy::prelude::*;
use kira::manager::backend::DefaultBackend;
use kira::manager::{AudioManager, AudioManagerSettings};
use kira::sound::static_sound::StaticSoundData;
use kira::tween::Tween;
use crate::events::{
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
/// so we hand a fresh handle to `manager.play()` on every event.
#[derive(Resource, Clone)]
pub struct SoundLibrary {
pub deal: StaticSoundData,
pub flip: StaticSoundData,
pub place: StaticSoundData,
pub invalid: StaticSoundData,
pub fanfare: StaticSoundData,
}
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
/// some platforms.
pub struct AudioState {
manager: Option<AudioManager<DefaultBackend>>,
}
pub struct AudioPlugin;
impl Plugin for AudioPlugin {
fn build(&self, app: &mut App) {
let manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
if manager.is_none() {
warn!("audio device unavailable; SFX disabled");
}
app.insert_non_send_resource(AudioState { manager });
let library = build_library();
if let Some(lib) = library {
app.insert_resource(lib);
} else {
warn!("failed to decode embedded SFX assets; SFX disabled");
}
app.add_event::<DrawRequestEvent>()
.add_event::<MoveRequestEvent>()
.add_event::<MoveRejectedEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems(
Startup,
apply_initial_volume,
)
.add_systems(
Update,
(
play_on_draw,
play_on_move,
play_on_rejected,
play_on_new_game,
play_on_win,
apply_volume_on_change,
),
);
}
}
fn build_library() -> Option<SoundLibrary> {
let deal = decode(include_bytes!("../../assets/audio/card_deal.wav"))?;
let flip = decode(include_bytes!("../../assets/audio/card_flip.wav"))?;
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
Some(SoundLibrary {
deal,
flip,
place,
invalid,
fanfare,
})
}
fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
match StaticSoundData::from_cursor(Cursor::new(bytes.to_vec())) {
Ok(data) => Some(data),
Err(e) => {
warn!("failed to decode SFX: {e}");
None
}
}
}
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
let Some(manager) = audio.manager.as_mut() else {
return;
};
if let Err(e) = manager.play(sound.clone()) {
warn!("failed to play SFX: {e}");
}
}
fn set_main_track_volume(audio: &mut AudioState, volume: f32) {
let Some(manager) = audio.manager.as_mut() else {
return;
};
manager
.main_track()
.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
}
fn apply_initial_volume(
mut audio: NonSendMut<AudioState>,
settings: Option<Res<SettingsResource>>,
) {
let volume = settings.map_or(1.0, |s| s.0.sfx_volume);
set_main_track_volume(&mut audio, volume);
}
fn apply_volume_on_change(
mut events: EventReader<SettingsChangedEvent>,
mut audio: NonSendMut<AudioState>,
) {
for ev in events.read() {
set_main_track_volume(&mut audio, ev.0.sfx_volume);
}
}
fn play_on_draw(
mut events: EventReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.flip);
}
}
fn play_on_move(
mut events: EventReader<MoveRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.place);
}
}
fn play_on_rejected(
mut events: EventReader<MoveRejectedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.invalid);
}
}
fn play_on_new_game(
mut events: EventReader<NewGameRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.deal);
}
}
fn play_on_win(
mut events: EventReader<GameWonEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.fanfare);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_wavs_decode_successfully() {
// Verifies the include_bytes! paths resolve and the bytes are valid
// WAV (so the gen_sfx output stays in sync with the loader).
let lib = build_library();
assert!(lib.is_some(), "embedded SFX failed to decode");
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Challenge-mode bookkeeping: serves the current challenge seed, advances
//! `PlayerProgress::challenge_index` on a Challenge-mode win, persists.
//!
//! Pressing **X** starts a new game with the current Challenge seed in
//! `GameMode::Challenge` (gated by level ≥ `CHALLENGE_UNLOCK_LEVEL`).
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource;
/// Minimum player level required to start a Challenge run.
pub const CHALLENGE_UNLOCK_LEVEL: u32 = 5;
/// Fired when the player has just completed a Challenge-mode game and the
/// `challenge_index` cursor advances.
#[derive(Event, Debug, Clone, Copy)]
pub struct ChallengeAdvancedEvent {
pub previous_index: u32,
pub new_index: u32,
}
pub struct ChallengePlugin;
impl Plugin for ChallengePlugin {
fn build(&self, app: &mut App) {
app.add_event::<ChallengeAdvancedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
}
}
fn advance_on_challenge_win(
mut wins: EventReader<GameWonEvent>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut advanced: EventWriter<ChallengeAdvancedEvent>,
) {
for _ in wins.read() {
if game.0.mode != GameMode::Challenge {
continue;
}
let prev = progress.0.challenge_index;
progress.0.challenge_index = prev.saturating_add(1);
if let Some(target) = &path.0 {
if let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after challenge advance: {e}");
}
}
advanced.send(ChallengeAdvancedEvent {
previous_index: prev,
new_index: progress.0.challenge_index,
});
}
}
fn handle_start_challenge_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
) {
if !keys.just_pressed(KeyCode::KeyX) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info!(
"Challenge mode locked — reach level {} (currently {}).",
CHALLENGE_UNLOCK_LEVEL, progress.0.level
);
return;
}
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
warn!("challenge seed list is empty");
return;
};
new_game.send(NewGameRequestEvent {
seed: Some(seed),
mode: Some(GameMode::Challenge),
});
}
/// Convenience for stat overlays: returns the human-friendly position
/// string `"{index + 1} / {total}"`.
pub fn challenge_progress_label(index: u32) -> String {
format!("{} / {}", index.saturating_add(1), challenge_count())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(ChallengePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn challenge_win_advances_index() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.challenge_index, 1);
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].previous_index, 0);
assert_eq!(fired[0].new_index, 1);
}
#[test]
fn classic_win_does_not_advance_challenge_index() {
let mut app = headless_app();
// Default GameStateResource is Classic mode.
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.challenge_index, 0);
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
#[test]
fn pressing_x_below_unlock_level_is_ignored() {
let mut app = headless_app();
// Default level is 0; below CHALLENGE_UNLOCK_LEVEL.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
#[test]
fn pressing_x_at_unlock_level_fires_new_game_with_challenge_seed() {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.level =
CHALLENGE_UNLOCK_LEVEL;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.challenge_index = 2;
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].seed, challenge_seed_for(2));
assert_eq!(fired[0].mode, Some(GameMode::Challenge));
}
#[test]
fn challenge_progress_label_uses_human_indexing() {
let total = challenge_count();
assert_eq!(challenge_progress_label(0), format!("1 / {total}"));
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
}
}
@@ -100,6 +100,7 @@ fn handle_start_daily_request(
if keys.just_pressed(KeyCode::KeyC) {
new_game.send(NewGameRequestEvent {
seed: Some(daily.seed),
mode: None,
});
}
}
+13
View File
@@ -1,6 +1,7 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Event;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
@@ -21,9 +22,11 @@ pub struct DrawRequestEvent;
pub struct UndoRequestEvent;
/// Request to start a new game. `seed = None` uses a system-time seed.
/// `mode = None` reuses the current game's `GameMode`.
#[derive(Event, Debug, Clone, Copy, Default)]
pub struct NewGameRequestEvent {
pub seed: Option<u64>,
pub mode: Option<GameMode>,
}
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
@@ -31,6 +34,16 @@ pub struct NewGameRequestEvent {
#[derive(Event, Debug, Clone, Copy, Default)]
pub struct StateChangedEvent;
/// Fired by input/UI systems when a player attempts to drop dragged cards
/// on a real pile but the move violates the rules. Drives the
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
#[derive(Event, Debug, Clone)]
pub struct MoveRejectedEvent {
pub from: PileType,
pub to: PileType,
pub count: usize,
}
/// Fired once when the active game transitions to won.
#[derive(Event, Debug, Clone, Copy)]
pub struct GameWonEvent {
+20 -8
View File
@@ -35,6 +35,7 @@ impl Plugin for GamePlugin {
.add_event::<UndoRequestEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<StateChangedEvent>()
.add_event::<crate::events::MoveRejectedEvent>()
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
@@ -72,13 +73,18 @@ pub fn advance_elapsed(
}
/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won). Stops counting on win so the final
/// time reflects how long the player took to solve the deal.
/// the game is in progress (not won) and not paused. Stops counting on
/// win so the final time reflects how long the player took to solve the
/// deal; stops while the pause overlay is open.
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let is_won = game.0.is_won;
advance_elapsed(
&mut game.0.elapsed_seconds,
@@ -103,7 +109,8 @@ fn handle_new_game(
for ev in new_game.read() {
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
let draw_mode = game.0.draw_mode.clone();
game.0 = GameState::new(seed, draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
changed.send(StateChangedEvent);
}
}
@@ -253,7 +260,7 @@ mod tests {
.map(|c| c.id)
.collect();
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999) });
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let after: Vec<u32> = app
@@ -292,11 +299,16 @@ mod tests {
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
let mut elapsed = 0;
let mut acc = 0.0;
// 16ms × 60 frames/sec ≈ 1 second; should produce 1 tick.
for _ in 0..60 {
advance_elapsed(&mut elapsed, &mut acc, 1.0 / 60.0, false);
// 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert!(elapsed == 1, "expected 1 second, got {elapsed}");
assert_eq!(elapsed, 1);
// Repeat once more for a total of 2 seconds.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 2);
}
#[test]
+164
View File
@@ -0,0 +1,164 @@
//! Toggleable on-screen help / cheat sheet showing keyboard bindings.
//!
//! Press **H** (or `?`) to toggle. Listed shortcuts are grouped by intent —
//! gameplay, modes, and overlays.
use bevy::prelude::*;
/// Marker on the help overlay root node.
#[derive(Component, Debug)]
pub struct HelpScreen;
pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, toggle_help_screen);
}
}
fn toggle_help_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<HelpScreen>>,
) {
let pressed_help = keys.just_pressed(KeyCode::KeyH) || keys.just_pressed(KeyCode::Slash);
if !pressed_help {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
} else {
spawn_help_screen(&mut commands);
}
}
fn spawn_help_screen(commands: &mut Commands) {
let lines: Vec<String> = vec![
"=== Controls ===".to_string(),
String::new(),
"-- Gameplay --".to_string(),
" D Draw from stock".to_string(),
" U Undo last move".to_string(),
" Drag Move cards between piles".to_string(),
" Click stock Draw".to_string(),
String::new(),
"-- New Game --".to_string(),
" N New Classic game".to_string(),
" C Start today's daily challenge".to_string(),
" Z Start a Zen game (level 5+)".to_string(),
" X Start the next Challenge (level 5+)".to_string(),
" T Start a Time Attack session (level 5+)".to_string(),
String::new(),
"-- Overlays --".to_string(),
" S Toggle stats / progression".to_string(),
" H or ? Toggle this help".to_string(),
" Esc Pause / resume".to_string(),
" [ / ] SFX volume down / up".to_string(),
String::new(),
"Press H or ? to close".to_string(),
];
commands
.spawn((
HelpScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(4.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
ZIndex(210),
))
.with_children(|b| {
for line in lines {
b.spawn((
Text::new(line),
TextFont {
font_size: 22.0,
..default()
},
TextColor(Color::srgb(0.95, 0.95, 0.90)),
));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn pressing_h_spawns_help_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyH);
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
1
);
}
#[test]
fn pressing_h_twice_closes_help_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyH);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyH);
input.clear();
input.press(KeyCode::KeyH);
}
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
0
);
}
#[test]
fn pressing_slash_also_toggles_help() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
1
);
}
}
+35 -9
View File
@@ -24,10 +24,13 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
DrawRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
@@ -61,6 +64,7 @@ impl Plugin for InputPlugin {
fn handle_keyboard(
keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
mut undo: EventWriter<UndoRequestEvent>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut draw: EventWriter<DrawRequestEvent>,
@@ -69,15 +73,28 @@ fn handle_keyboard(
undo.send(UndoRequestEvent);
}
if keys.just_pressed(KeyCode::KeyN) {
new_game.send(NewGameRequestEvent { seed: None });
new_game.send(NewGameRequestEvent::default());
}
if keys.just_pressed(KeyCode::KeyZ) {
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
// X is gated separately by ChallengePlugin.
let level = progress.as_ref().map_or(0, |p| p.0.level);
if level >= CHALLENGE_UNLOCK_LEVEL {
new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(solitaire_core::game_state::GameMode::Zen),
});
} else {
info!(
"Zen mode locked — reach level {} (currently {}).",
CHALLENGE_UNLOCK_LEVEL, level
);
}
}
if keys.just_pressed(KeyCode::KeyD) {
draw.send(DrawRequestEvent);
}
if keys.just_pressed(KeyCode::Escape) {
// Pause placeholder — the pause screen hooks this up in a later phase.
info!("pause requested (not yet wired)");
}
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
fn handle_stock_click(
@@ -196,6 +213,7 @@ fn end_drag(
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
mut moves: EventWriter<MoveRequestEvent>,
mut rejected: EventWriter<MoveRejectedEvent>,
mut changed: EventWriter<StateChangedEvent>,
) {
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
@@ -215,7 +233,9 @@ fn end_drag(
// Whether we fire a MoveRequestEvent or not, always trigger a resync so
// the dragged cards snap back to their resting positions if the move is
// rejected (or never fired).
// rejected (or never fired). When the cursor was over a real pile but
// the placement is illegal, fire MoveRejectedEvent so AudioPlugin can
// play card_invalid.wav.
let mut fired = false;
if let Some(target) = target {
if target != origin {
@@ -237,11 +257,17 @@ fn end_drag(
};
if ok {
moves.send(MoveRequestEvent {
from: origin,
to: target,
from: origin.clone(),
to: target.clone(),
count,
});
fired = true;
} else {
rejected.send(MoveRejectedEvent {
from: origin.clone(),
to: target.clone(),
count,
});
}
}
}
+22 -2
View File
@@ -2,33 +2,53 @@
pub mod achievement_plugin;
pub mod animation_plugin;
pub mod audio_plugin;
pub mod card_plugin;
pub mod challenge_plugin;
pub mod daily_challenge_plugin;
pub mod events;
pub mod game_plugin;
pub mod help_plugin;
pub mod input_plugin;
pub mod layout;
pub mod onboarding_plugin;
pub mod pause_plugin;
pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
pub mod stats_plugin;
pub mod table_plugin;
pub mod time_attack_plugin;
pub mod weekly_goals_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
};
pub use daily_challenge_plugin::{
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
};
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
pub use game_plugin::{GameMutation, GamePlugin};
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use input_plugin::InputPlugin;
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
pub use settings_plugin::{
SettingsChangedEvent, SettingsPlugin, SettingsResource, SFX_STEP,
};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
pub use time_attack_plugin::{
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
};
+191
View File
@@ -0,0 +1,191 @@
//! First-run onboarding banner.
//!
//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered
//! welcome banner pointing at the **H**/`?` cheat sheet. The first key or
//! mouse-button press dismisses it, sets the flag, and persists settings —
//! so returning players never see it again.
use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_data::{save_settings_to, Settings};
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
/// Marker on the onboarding overlay root node.
#[derive(Component, Debug)]
pub struct OnboardingScreen;
pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) {
app.add_systems(PostStartup, spawn_if_first_run)
.add_systems(Update, dismiss_on_any_input);
}
}
fn spawn_if_first_run(mut commands: Commands, settings: Option<Res<SettingsResource>>) {
let Some(s) = settings else {
return;
};
if s.0.first_run_complete {
return;
}
spawn_onboarding_screen(&mut commands);
}
fn dismiss_on_any_input(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
mut settings: ResMut<SettingsResource>,
path: Option<Res<SettingsStoragePath>>,
screens: Query<Entity, With<OnboardingScreen>>,
) {
let Ok(entity) = screens.get_single() else {
return;
};
let pressed = keys.get_just_pressed().next().is_some()
|| mouse.get_just_pressed().next().is_some();
if !pressed {
return;
}
commands.entity(entity).despawn_recursive();
settings.0.first_run_complete = true;
persist(path.as_deref().map(|p| &p.0), &settings.0);
}
fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
let Some(Some(target)) = path else {
return;
};
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings (onboarding): {e}");
}
}
fn spawn_onboarding_screen(commands: &mut Commands) {
let lines: Vec<(String, f32)> = vec![
("Welcome to Solitaire Quest!".to_string(), 40.0),
(String::new(), 20.0),
(
"Drag cards between piles. Press D to draw, U to undo.".to_string(),
22.0,
),
(
"Press H or ? at any time to see the full controls.".to_string(),
22.0,
),
(String::new(), 20.0),
("Press any key to begin".to_string(), 20.0),
];
commands
.spawn((
OnboardingScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(8.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.92)),
ZIndex(230),
))
.with_children(|b| {
for (line, size) in lines {
b.spawn((
Text::new(line),
TextFont {
font_size: size,
..default()
},
TextColor(Color::srgb(1.0, 0.87, 0.0)),
));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings_plugin::SettingsPlugin;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless())
.add_plugins(OnboardingPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app
}
fn count_screens(app: &mut App) -> usize {
app.world_mut()
.query::<&OnboardingScreen>()
.iter(app.world())
.count()
}
#[test]
fn first_run_spawns_banner() {
let mut app = headless_app();
app.update(); // PostStartup runs
assert_eq!(count_screens(&mut app), 1);
}
#[test]
fn returning_player_does_not_see_banner() {
let mut app = headless_app();
// Mark already-completed before PostStartup runs.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.first_run_complete = true;
app.update();
assert_eq!(count_screens(&mut app), 0);
}
#[test]
fn keypress_dismisses_and_sets_flag() {
let mut app = headless_app();
app.update();
assert_eq!(count_screens(&mut app), 1);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Space);
app.update();
assert_eq!(count_screens(&mut app), 0);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"first_run_complete should flip to true"
);
}
#[test]
fn mouseclick_dismisses_banner() {
let mut app = headless_app();
app.update();
assert_eq!(count_screens(&mut app), 1);
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Left);
app.update();
assert_eq!(count_screens(&mut app), 0);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Pause overlay (Esc).
//!
//! While paused:
//! - The `PausedResource` flag is true.
//! - Elapsed-time and Time Attack tickers stop counting (they read this
//! resource and bail out early).
//!
//! Pressing Esc again dismisses the overlay and resumes ticking. Other
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
//! "stop the clock" screen for now. A future polish slice can layer
//! input-blocking on top if desired.
use bevy::prelude::*;
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
#[derive(Resource, Debug, Default)]
pub struct PausedResource(pub bool);
/// Marker on the pause overlay root node.
#[derive(Component, Debug)]
pub struct PauseScreen;
pub struct PausePlugin;
impl Plugin for PausePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PausedResource>()
.add_systems(Update, toggle_pause);
}
}
fn toggle_pause(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut paused: ResMut<PausedResource>,
screens: Query<Entity, With<PauseScreen>>,
) {
if !keys.just_pressed(KeyCode::Escape) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
paused.0 = false;
} else {
spawn_pause_screen(&mut commands);
paused.0 = true;
}
}
fn spawn_pause_screen(commands: &mut Commands) {
commands
.spawn((
PauseScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(8.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
ZIndex(220),
))
.with_children(|b| {
b.spawn((
Text::new("Paused"),
TextFont {
font_size: 48.0,
..default()
},
TextColor(Color::srgb(1.0, 0.87, 0.0)),
));
b.spawn((
Text::new("Press Esc to resume"),
TextFont {
font_size: 22.0,
..default()
},
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
});
}
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press_esc(app: &mut App) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::Escape);
input.clear();
input.press(KeyCode::Escape);
}
#[test]
fn pressing_esc_pauses() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
1
);
}
#[test]
fn pressing_esc_twice_resumes() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
press_esc(&mut app);
app.update();
assert!(!app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut()
.query::<&PauseScreen>()
.iter(app.world())
.count(),
0
);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Persists `solitaire_data::Settings` and exposes hotkeys for live tuning.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` decrease SFX volume by `SFX_STEP`
//! - `]` increase SFX volume by `SFX_STEP`
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings};
/// Volume adjustment step.
pub const SFX_STEP: f32 = 0.1;
/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);
/// Persistence path for `SettingsResource`. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);
/// Fired any time settings change so consumers (audio, UI) can react.
#[derive(Event, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
pub struct SettingsPlugin {
pub storage_path: Option<PathBuf>,
}
impl Default for SettingsPlugin {
fn default() -> Self {
Self {
storage_path: settings_file_path(),
}
}
}
impl SettingsPlugin {
/// Plugin configured with no persistence — for tests and headless apps.
pub fn headless() -> Self {
Self { storage_path: None }
}
}
impl Plugin for SettingsPlugin {
fn build(&self, app: &mut App) {
let loaded = match &self.storage_path {
Some(path) => load_settings_from(path),
None => Settings::default(),
};
app.insert_resource(SettingsResource(loaded))
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.add_event::<SettingsChangedEvent>()
.add_systems(Update, handle_volume_keys);
}
}
fn persist(path: &SettingsStoragePath, settings: &Settings) {
let Some(target) = &path.0 else {
return;
};
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings: {e}");
}
}
fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
) {
let mut delta = 0.0;
if keys.just_pressed(KeyCode::BracketLeft) {
delta -= SFX_STEP;
}
if keys.just_pressed(KeyCode::BracketRight) {
delta += SFX_STEP;
}
if delta == 0.0 {
return;
}
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(delta);
if (before - after).abs() < f32::EPSILON {
// Already at the rail — no point persisting or notifying.
return;
}
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
#[test]
fn defaults_are_loaded() {
let app = headless_app();
assert_eq!(
app.world().resource::<SettingsResource>().0,
Settings::default()
);
}
#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
let mut app = headless_app();
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
// Drop volume first so there's headroom to grow.
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!((after - 0.6).abs() < 1e-3);
}
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
// Already at max — pressing right bracket should be a no-op.
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
}
+58 -3
View File
@@ -18,6 +18,7 @@ use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
/// Bevy resource wrapping the current stats.
#[derive(Resource, Debug, Clone)]
@@ -127,6 +128,7 @@ fn toggle_stats_screen(
keys: Res<ButtonInput<KeyCode>>,
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !keys.just_pressed(KeyCode::KeyS) {
@@ -135,7 +137,12 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
} else {
spawn_stats_screen(&mut commands, &stats.0, progress.as_deref().map(|p| &p.0));
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
);
}
}
@@ -143,6 +150,7 @@ fn spawn_stats_screen(
commands: &mut Commands,
stats: &StatsSnapshot,
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
) {
let win_rate = stats
.win_rate()
@@ -194,6 +202,27 @@ fn spawn_stats_screen(
goal.description, progress_value, goal.target
));
}
lines.push(String::new());
lines.push("-- Unlocks --".to_string());
lines.push(format!(
" Card Backs: {}",
format_id_list(&p.unlocked_card_backs)
));
lines.push(format!(
" Backgrounds: {}",
format_id_list(&p.unlocked_backgrounds)
));
}
if let Some(ta) = time_attack {
if ta.active {
let mins = (ta.remaining_secs / 60.0).floor() as u64;
let secs = (ta.remaining_secs % 60.0).floor() as u64;
lines.push(String::new());
lines.push("=== Time Attack ===".to_string());
lines.push(format!("Remaining: {mins}m {secs:02}s"));
lines.push(format!("Wins: {}", ta.wins));
}
}
lines.push(String::new());
@@ -237,6 +266,22 @@ fn format_duration(secs: u64) -> String {
format!("{m}m {s:02}s")
}
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
/// Empty list shows as "None".
fn format_id_list(ids: &[usize]) -> String {
if ids.is_empty() {
return "None".to_string();
}
let mut sorted: Vec<usize> = ids.to_vec();
sorted.sort_unstable();
sorted.dedup();
sorted
.iter()
.map(|i| format!("#{i}"))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -296,7 +341,7 @@ mod tests {
.move_count = 3;
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(999) });
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -309,7 +354,7 @@ mod tests {
fn new_game_without_moves_does_not_record_abandoned() {
let mut app = headless_app();
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(42) });
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -369,4 +414,14 @@ mod tests {
0
);
}
#[test]
fn format_id_list_renders_empty_as_none() {
assert_eq!(format_id_list(&[]), "None");
}
#[test]
fn format_id_list_sorts_dedups_and_prefixes() {
assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Time Attack mode runtime: 10-minute countdown wrapped around back-to-back
//! `GameMode::TimeAttack` games. Pressing **T** starts a session (gated by
//! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the
//! counter and auto-deals a fresh game. When the timer expires the session
//! ends and `TimeAttackEndedEvent` fires.
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
/// Length of a Time Attack session in real-world seconds (10 minutes).
pub const TIME_ATTACK_DURATION_SECS: f32 = 600.0;
/// Session state for an in-progress Time Attack run. Not persisted.
#[derive(Resource, Debug, Clone, Default)]
pub struct TimeAttackResource {
pub active: bool,
pub remaining_secs: f32,
pub wins: u32,
}
/// Fired when the Time Attack timer expires. The summary toast in
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
#[derive(Event, Debug, Clone, Copy)]
pub struct TimeAttackEndedEvent {
pub wins: u32,
}
pub struct TimeAttackPlugin;
impl Plugin for TimeAttackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TimeAttackResource>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_systems(
Update,
handle_start_time_attack_request.before(GameMutation),
)
.add_systems(Update, advance_time_attack)
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation));
}
}
fn handle_start_time_attack_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
) {
if !keys.just_pressed(KeyCode::KeyT) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info!(
"Time Attack locked — reach level {} (currently {}).",
CHALLENGE_UNLOCK_LEVEL, progress.0.level
);
return;
}
*session = TimeAttackResource {
active: true,
remaining_secs: TIME_ATTACK_DURATION_SECS,
wins: 0,
};
new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
}
fn advance_time_attack(
time: Res<Time>,
mut session: ResMut<TimeAttackResource>,
mut ended: EventWriter<TimeAttackEndedEvent>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
if !session.active {
return;
}
if paused.is_some_and(|p| p.0) {
return;
}
session.remaining_secs -= time.delta_secs();
if session.remaining_secs <= 0.0 {
let wins = session.wins;
session.active = false;
session.remaining_secs = 0.0;
ended.send(TimeAttackEndedEvent { wins });
}
}
fn auto_deal_on_time_attack_win(
mut wins: EventReader<GameWonEvent>,
game: Res<GameStateResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
) {
for _ in wins.read() {
if !session.active || game.0.mode != GameMode::TimeAttack {
continue;
}
session.wins = session.wins.saturating_add(1);
new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(TimeAttackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press_t(app: &mut App) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyT);
input.clear();
input.press(KeyCode::KeyT);
}
#[test]
fn pressing_t_below_unlock_level_is_ignored() {
let mut app = headless_app();
press_t(&mut app);
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert!(!session.active);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
#[test]
fn pressing_t_at_unlock_level_starts_session_and_deals_time_attack_game() {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
press_t(&mut app);
app.update();
let session = app.world().resource::<TimeAttackResource>().clone();
assert!(session.active);
assert_eq!(session.wins, 0);
assert!((session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.0);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].mode, Some(GameMode::TimeAttack));
}
#[test]
fn timer_expiry_fires_ended_event_and_clears_active() {
let mut app = headless_app();
// Manually start a near-expired session.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: 0.001,
wins: 5,
};
app.update();
// First update advances time slightly; force the timer past zero.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: -1.0,
wins: 5,
};
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert!(!session.active);
assert_eq!(session.remaining_secs, 0.0);
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].wins, 5);
}
#[test]
fn win_during_session_increments_wins_and_auto_deals() {
let mut app = headless_app();
// Start a session manually.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: 100.0,
wins: 0,
};
// The current game must be in TimeAttack mode for auto-deal to fire.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 1);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].mode, Some(GameMode::TimeAttack));
assert!(fired[0].seed.is_none());
}
#[test]
fn win_when_session_inactive_does_not_increment() {
let mut app = headless_app();
// Default session is inactive. Game is TimeAttack mode — still no count.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 0);
}
#[test]
fn classic_win_during_session_does_not_increment() {
let mut app = headless_app();
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: 100.0,
wins: 0,
};
// GameStateResource defaults to Classic mode.
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 0);
}
}