feat(engine): playability improvements — input intelligence, audio, HUD, onboarding (#27–#30, #37, #39–#40, #44, #48–#49)

Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.

Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).

Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).

Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.

Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.

Task #39: Daily challenge HUD constraint label (time limit / target score).

Task #40: Undo-count HUD label; amber colour when undos > 0.

Task #44: Win-streak and level line on pause screen.

Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.

Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.

Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-27 19:11:47 +00:00
parent c3ee7c45a7
commit ddd7502a06
16 changed files with 1269 additions and 46 deletions
+4 -3
View File
@@ -2,9 +2,9 @@ use bevy::prelude::*;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin, InputPlugin,
LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin,
SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
ChallengePlugin, CursorPlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin,
InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
};
fn main() {
@@ -29,6 +29,7 @@ fn main() {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(AutoCompletePlugin)
+42
View File
@@ -577,4 +577,46 @@ mod tests {
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"));
}
#[test]
fn check_achievements_returns_multiple_when_conditions_met() {
// A context where first_win, on_a_roll, and no_undo all trigger at once.
let mut c = ctx();
c.games_won = 1;
c.win_streak_current = 3;
c.last_win_used_undo = false;
c.last_win_time_seconds = 999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"first_win"), "first_win should unlock");
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock");
assert!(ids.contains(&"no_undo"), "no_undo should unlock");
assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously");
}
#[test]
fn perfectionist_implies_no_undo_both_fire_together() {
// perfectionist requires !used_undo && score >= 5000, which is a strict
// superset of no_undo's condition. Both must appear in the result.
let mut c = ctx();
c.games_won = 1;
c.last_win_used_undo = false;
c.last_win_score = 5_000;
c.last_win_time_seconds = 999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "perfectionist must unlock");
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
}
#[test]
fn perfectionist_score_well_above_threshold_still_passes() {
let mut c = ctx();
c.games_won = 1;
c.last_win_used_undo = false;
c.last_win_score = 50_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "score far above threshold must pass");
}
}
+125
View File
@@ -474,6 +474,63 @@ mod tests {
assert_eq!(g.piles[&PileType::Stock].cards.len(), 21);
}
#[test]
fn draw_three_partial_draw_when_fewer_than_three_remain() {
let mut g = GameState::new(42, DrawMode::DrawThree);
// Replace the stock with exactly 2 cards so the draw is a partial batch.
let two_cards: Vec<Card> = g.piles[&PileType::Stock].cards[..2].to_vec();
g.piles.get_mut(&PileType::Stock).unwrap().cards = two_cards;
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2");
assert!(g.piles[&PileType::Stock].cards.is_empty());
}
#[test]
fn draw_three_all_drawn_cards_are_face_up() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.draw().unwrap();
assert!(
g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up),
"all drawn cards must be face-up in waste"
);
}
#[test]
fn draw_three_undo_returns_all_cards_to_stock() {
let mut g = GameState::new(42, DrawMode::DrawThree);
let stock_before = g.piles[&PileType::Stock].cards.len();
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Waste].cards.len(), 3);
g.undo().unwrap();
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before);
assert!(g.piles[&PileType::Waste].cards.is_empty());
}
#[test]
fn draw_three_recycle_restores_waste_to_stock_face_down() {
let mut g = GameState::new(42, DrawMode::DrawThree);
// Drain all 24 stock cards into waste via repeated draws.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
let waste_count = g.piles[&PileType::Waste].cards.len();
assert!(waste_count > 0);
// Recycle: drawing when stock is empty returns all waste cards to stock.
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count);
assert!(g.piles[&PileType::Waste].cards.is_empty());
assert!(
g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up),
"recycled cards must be face-down"
);
}
#[test]
fn draw_from_empty_stock_recycles_waste() {
let mut g = new_game();
@@ -691,6 +748,43 @@ mod tests {
assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX");
}
// --- Fields excluded from undo snapshot ---
#[test]
fn undo_does_not_roll_back_elapsed_seconds() {
// elapsed_seconds tracks wall time and must be monotonic; undo must never
// reduce it, otherwise the time-bonus calculation would be gamed.
let mut g = new_game();
g.elapsed_seconds = 120;
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged");
}
#[test]
fn undo_does_not_roll_back_recycle_count() {
// recycle_count is a lifetime counter used for the 'comeback' achievement;
// rolling it back on undo would make the condition unachievable after recycling.
let mut g = new_game();
// Drain stock and recycle to increment recycle_count.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // recycle
assert_eq!(g.recycle_count, 1);
// Now draw one more card and undo it.
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.recycle_count, 1, "undo must leave recycle_count unchanged");
}
#[test]
fn undo_after_win_returns_game_already_won() {
let mut g = new_game();
g.is_won = true;
assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon));
}
// --- Scoring ---
#[test]
@@ -753,6 +847,37 @@ mod tests {
// fact that move_cards' score path is identical to Classic.
}
// --- GameMode: TimeAttack ---
#[test]
fn time_attack_mode_field_persists() {
let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack);
assert_eq!(g.mode, GameMode::TimeAttack);
}
#[test]
fn time_attack_allows_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
g.draw().unwrap();
// TimeAttack does not disable undo — only Challenge does.
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
}
#[test]
fn time_attack_score_starts_at_zero() {
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
assert_eq!(g.score, 0);
}
#[test]
fn time_attack_draw_three_combination() {
// TimeAttack + DrawThree is a valid combination; verify construction.
let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack);
assert_eq!(g.mode, GameMode::TimeAttack);
assert_eq!(g.draw_mode, DrawMode::DrawThree);
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
}
// --- Auto-complete ---
#[test]
+12 -1
View File
@@ -21,7 +21,8 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank.value() == 13,
Some(top) => {
card.rank.value() + 1 == top.rank.value()
top.face_up
&& card.rank.value() + 1 == top.rank.value()
&& card.suit.is_red() != top.suit.is_red()
}
}
@@ -152,4 +153,14 @@ mod tests {
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_face_down_destination_top_is_invalid() {
// A face-down top card must never be a valid placement target.
let c = card(Suit::Hearts, Rank::Nine);
let mut top = card(Suit::Spades, Rank::Ten);
top.face_up = false;
let p = pile_with(PileType::Tableau(0), vec![top]);
assert!(!can_place_on_tableau(&c, &p));
}
}
+26
View File
@@ -152,4 +152,30 @@ mod tests {
assert_eq!(s.draw_one_wins, 1);
assert_eq!(s.draw_three_wins, 1);
}
#[test]
fn win_streak_best_never_decreases_after_shorter_subsequent_streak() {
let mut s = StatsSnapshot::default();
// Build a streak of 5.
for _ in 0..5 {
s.update_on_win(100, 60, &DrawMode::DrawOne);
}
assert_eq!(s.win_streak_best, 5);
// Lose (abandon), resetting current.
s.record_abandoned();
assert_eq!(s.win_streak_current, 0);
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
// Win once — current becomes 1, best must remain 5.
s.update_on_win(100, 60, &DrawMode::DrawOne);
assert_eq!(s.win_streak_current, 1);
assert_eq!(s.win_streak_best, 5, "best must not drop to match shorter streak");
}
#[test]
fn lifetime_score_saturates_at_u64_max() {
let mut s = StatsSnapshot::default();
s.lifetime_score = u64::MAX - 100;
s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
}
}
@@ -478,6 +478,31 @@ mod tests {
);
}
#[test]
fn no_undo_achievement_does_not_fire_when_undo_was_used() {
let mut app = headless_app();
// Simulate a win where the player used undo at least once.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
// "no_undo" awards BonusXp(25). If undo was used it must NOT fire.
let events = app.world().resource::<Events<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
assert!(
!xp_events.contains(&25),
"BonusXp(25) must not fire when undo_count > 0; got {xp_events:?}"
);
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
+120 -4
View File
@@ -27,8 +27,9 @@ use kira::tween::Tween;
use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent,
NewGameRequestEvent, UndoRequestEvent,
};
use crate::pause_plugin::PausedResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
@@ -54,6 +55,17 @@ pub struct AudioState {
music_track: Option<TrackHandle>,
}
/// Tracks which audio channels the player has silenced via the M / Shift+M shortcuts.
///
/// These booleans override the `sfx_volume` / `music_volume` settings. When
/// `true`, the corresponding track is forced to 0. When toggled back to `false`
/// the volume is restored from `SettingsResource`.
#[derive(Resource, Default)]
pub struct MuteState {
pub sfx_muted: bool,
pub music_muted: bool,
}
pub struct AudioPlugin;
impl Plugin for AudioPlugin {
@@ -72,7 +84,8 @@ impl Plugin for AudioPlugin {
None => (None, None),
};
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track });
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track })
.init_resource::<MuteState>();
let library = build_library();
if let Some(lib) = library {
@@ -87,6 +100,7 @@ impl Plugin for AudioPlugin {
.add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>()
.add_event::<CardFlippedEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems(
Startup,
@@ -101,7 +115,9 @@ impl Plugin for AudioPlugin {
play_on_new_game,
play_on_win,
play_on_card_flip,
play_on_undo,
apply_volume_on_change,
handle_mute_keys,
),
);
}
@@ -168,16 +184,62 @@ fn apply_initial_volume(
set_music_volume(&mut audio, music);
}
fn play_on_undo(
mut events: EventReader<UndoRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else { return };
for _ in events.read() {
play(&mut audio, &lib.flip);
}
}
fn apply_volume_on_change(
mut events: EventReader<SettingsChangedEvent>,
mut audio: NonSendMut<AudioState>,
mute: Option<Res<MuteState>>,
) {
for ev in events.read() {
set_sfx_volume(&mut audio, ev.0.sfx_volume);
set_music_volume(&mut audio, ev.0.music_volume);
let sfx_muted = mute.as_ref().is_some_and(|m| m.sfx_muted);
let music_muted = mute.as_ref().is_some_and(|m| m.music_muted);
set_sfx_volume(&mut audio, if sfx_muted { 0.0 } else { ev.0.sfx_volume });
set_music_volume(&mut audio, if music_muted { 0.0 } else { ev.0.music_volume });
}
}
/// `M` toggles mute for all audio; `Shift+M` toggles music only.
/// Volumes are restored from `SettingsResource` on unmute.
fn handle_mute_keys(
keys: Res<ButtonInput<KeyCode>>,
mut audio: NonSendMut<AudioState>,
mut mute: ResMut<MuteState>,
settings: Option<Res<SettingsResource>>,
paused: Option<Res<PausedResource>>,
) {
if paused.is_some_and(|p| p.0) || !keys.just_pressed(KeyCode::KeyM) {
return;
}
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let (sfx_vol, music_vol) = settings
.as_ref()
.map(|s| (s.0.sfx_volume, s.0.music_volume))
.unwrap_or((1.0, 0.5));
if shift {
// Shift+M: toggle music mute only, SFX unaffected.
mute.music_muted = !mute.music_muted;
} else {
// M: mute all if either channel is audible; unmute all otherwise.
let new_state = !(mute.sfx_muted && mute.music_muted);
mute.sfx_muted = new_state;
mute.music_muted = new_state;
}
set_sfx_volume(&mut audio, if mute.sfx_muted { 0.0 } else { sfx_vol });
set_music_volume(&mut audio, if mute.music_muted { 0.0 } else { music_vol });
}
fn play_on_draw(
mut events: EventReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>,
@@ -267,4 +329,58 @@ mod tests {
let lib = build_library();
assert!(lib.is_some(), "embedded SFX failed to decode");
}
// -----------------------------------------------------------------------
// MuteState toggle logic (pure, no AudioManager needed)
// -----------------------------------------------------------------------
/// Helper that mirrors the toggle logic inside `handle_mute_keys`
/// for M (mute-all).
fn toggle_all(mute: &mut MuteState) {
let new_state = !(mute.sfx_muted && mute.music_muted);
mute.sfx_muted = new_state;
mute.music_muted = new_state;
}
/// Helper that mirrors the toggle logic for Shift+M (music-only).
fn toggle_music(mute: &mut MuteState) {
mute.music_muted = !mute.music_muted;
}
#[test]
fn mute_all_toggles_both_channels() {
let mut m = MuteState::default();
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M should mute both channels");
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "second M should unmute both channels");
}
#[test]
fn shift_m_toggles_music_only() {
let mut m = MuteState::default();
toggle_music(&mut m);
assert!(m.music_muted, "Shift+M should mute music");
assert!(!m.sfx_muted, "Shift+M must not mute SFX");
toggle_music(&mut m);
assert!(!m.music_muted, "second Shift+M should unmute music");
}
#[test]
fn mute_all_while_music_already_muted_mutes_sfx_too() {
let mut m = MuteState::default();
// Music already muted via Shift+M.
toggle_music(&mut m);
assert!(m.music_muted && !m.sfx_muted);
// M should mute sfx (not-all-muted → mute-all).
toggle_all(&mut m);
assert!(m.sfx_muted && m.music_muted, "M unmutes neither — it mutes all when sfx was audible");
}
#[test]
fn mute_all_when_both_already_muted_unmutes_both() {
let mut m = MuteState { sfx_muted: true, music_muted: true };
toggle_all(&mut m);
assert!(!m.sfx_muted && !m.music_muted, "M should unmute both when all were muted");
}
}
+6
View File
@@ -87,3 +87,9 @@ pub struct InfoToastEvent(pub String);
pub struct XpAwardedEvent {
pub amount: u64,
}
/// Fired by `InputPlugin` when the player presses G to forfeit the current
/// game. Consumed by `StatsPlugin` which records the abandoned game,
/// persists stats, and starts a fresh deal.
#[derive(Event, Debug, Clone, Copy, Default)]
pub struct ForfeitEvent;
+238 -2
View File
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
save_game_state_to};
use crate::events::{
DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
UndoRequestEvent,
DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, NewGameRequestEvent,
StateChangedEvent, UndoRequestEvent,
};
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
@@ -64,6 +64,7 @@ impl Plugin for GamePlugin {
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
.add_event::<InfoToastEvent>()
.add_systems(
Update,
(
@@ -75,7 +76,10 @@ impl Plugin for GamePlugin {
.chain()
.in_set(GameMutation),
)
.add_systems(Update, check_no_moves.after(GameMutation))
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state)
.add_systems(Last, save_game_state_on_exit);
}
}
@@ -236,6 +240,136 @@ fn handle_undo(
}
}
// ---------------------------------------------------------------------------
// Task #29 — No-moves detection
// ---------------------------------------------------------------------------
/// Returns `true` if the current game state has at least one legal move.
///
/// Considers:
/// - Any non-empty Stock or Waste pile (draw / recycle is always available).
/// - Any face-up card on Waste or Tableau piles that can legally move to any
/// Foundation or Tableau destination.
pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
// If stock or waste is non-empty, the player can always draw.
if !game.piles.get(&PileType::Stock).is_some_and(|p| p.cards.is_empty())
|| !game.piles.get(&PileType::Waste).is_some_and(|p| p.cards.is_empty())
{
return true;
}
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
// Check each playable source pile.
let sources: Vec<PileType> = {
let mut v = vec![PileType::Waste];
for i in 0..7_usize {
v.push(PileType::Tableau(i));
}
v
};
for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundations.
for &suit in &suits {
let dest = PileType::Foundation(suit);
if let Some(dest_pile) = game.piles.get(&dest) {
if can_place_on_foundation(card, dest_pile, suit) {
return true;
}
}
}
// Check tableau piles.
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if dest == *from {
continue;
}
if let Some(dest_pile) = game.piles.get(&dest) {
if can_place_on_tableau(card, dest_pile) {
return true;
}
}
}
}
false
}
/// After each `StateChangedEvent`, check if the game has no legal moves.
/// Fires `InfoToastEvent` once per "stuck" state. Resets when any new
/// `StateChangedEvent` arrives.
fn check_no_moves(
mut events: EventReader<StateChangedEvent>,
game: Res<GameStateResource>,
mut toast: EventWriter<InfoToastEvent>,
mut already_fired: Local<bool>,
) {
// Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change.
let had_event = events.read().next().is_some();
// Drain remaining events to avoid leaking.
events.clear();
if !had_event {
return;
}
// Reset debounce whenever the state changes.
*already_fired = false;
if game.0.is_won {
return;
}
if !has_legal_moves(&game.0) && !*already_fired {
toast.send(InfoToastEvent(
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
));
*already_fired = true;
}
}
const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;
/// Accumulated real-world seconds since the last auto-save. Exposed as a
/// `Resource` so tests can pre-seed it past the threshold without needing to
/// control `Time::delta_secs()`.
#[derive(Resource, Default)]
pub struct AutoSaveTimer(pub f32);
/// Periodically saves game state every 30 real-world seconds while a game is
/// in progress. The timer uses real delta time (not game elapsed_seconds) so
/// it keeps ticking even if the game clock is paused.
fn auto_save_game_state(
time: Res<Time>,
game: Res<GameStateResource>,
path: Option<Res<GameStatePath>>,
mut timer: ResMut<AutoSaveTimer>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
// Don't save if paused, game is won, or no moves have been made yet.
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 {
return;
}
timer.0 += time.delta_secs();
if timer.0 >= AUTO_SAVE_INTERVAL_SECS {
timer.0 -= AUTO_SAVE_INTERVAL_SECS;
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else { return };
if let Err(e) = save_game_state_to(p, &game.0) {
warn!("game_state: auto-save failed: {e}");
}
}
}
/// Last-schedule system: persists the current game state on `AppExit` so the
/// player can resume where they left off. Won games are not saved (the
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
@@ -519,6 +653,49 @@ mod tests {
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
}
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
#[test]
fn auto_save_writes_after_30_seconds() {
use solitaire_data::load_game_state_from;
let path = tmp_gs_path("auto_save_30s");
let _ = std::fs::remove_file(&path);
let mut app = test_app(42);
app.insert_resource(GameStatePath(Some(path.clone())));
// Give the game one move so move_count > 0 (auto-save guard).
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
// Pre-seed the timer just past the threshold. The system will trigger
// on the very next update() without needing to control Time::delta_secs().
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
app.update();
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
let loaded = load_game_state_from(&path).expect("file must be loadable");
assert_eq!(loaded.seed, 42);
let _ = std::fs::remove_file(&path);
}
/// auto_save_game_state does NOT write to disk when no moves have been made.
#[test]
fn auto_save_skips_when_no_moves() {
let path = tmp_gs_path("auto_save_skip");
let _ = std::fs::remove_file(&path);
let mut app = test_app(99);
app.insert_resource(GameStatePath(Some(path.clone())));
// move_count stays at 0 (fresh game); timer is past threshold.
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
app.update();
assert!(!path.exists(), "auto-save must not fire when move_count == 0");
}
#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
use solitaire_core::card::{Card, Rank, Suit};
@@ -560,4 +737,63 @@ mod tests {
let fired: Vec<_> = cursor.read(events).collect();
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
}
// -----------------------------------------------------------------------
// Task #29 — has_legal_moves pure-function tests
// -----------------------------------------------------------------------
#[test]
fn has_legal_moves_returns_true_when_stock_nonempty() {
// A fresh game has 24 cards in stock — draw is always available.
let game = GameState::new(42, DrawMode::DrawOne);
assert!(has_legal_moves(&game), "draw is always available when stock is non-empty");
}
#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Empty stock and waste so draw is NOT available.
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
assert!(has_legal_moves(&game), "Ace can always go to an empty foundation");
}
#[test]
fn has_legal_moves_returns_false_when_stuck() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Empty stock and waste.
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all foundations and all tableau.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Place a Two of Clubs with no legal destination.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true,
});
assert!(!has_legal_moves(&game), "Two of Clubs with empty board has no legal move");
}
}
+368 -11
View File
@@ -1,9 +1,12 @@
//! Keyboard + mouse input for the game board.
//!
//! All systems exit immediately when `PausedResource(true)` — no moves,
//! draws, undos, or drags are processed while the pause overlay is showing.
//!
//! Keyboard:
//! - `U` → `UndoRequestEvent`
//! - `N` → `NewGameRequestEvent { seed: None }`
//! - `D` → `DrawRequestEvent`
//! - `D` / `Space` → `DrawRequestEvent`
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//!
//! Mouse:
@@ -13,23 +16,26 @@
//! On rejection, the drag cards snap back to their origin via a
//! `StateChangedEvent` re-sync.
use std::collections::HashMap;
use bevy::input::ButtonInput;
use bevy::math::{Vec2, Vec3};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::card::Suit;
use bevy::window::{MonitorSelection, PrimaryWindow, WindowMode};
use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameState;
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::card_plugin::{CardEntity, HintHighlight, TABLEAU_FAN_FRAC};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
DrawRequestEvent, ForfeitEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::progress_plugin::ProgressResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
@@ -50,17 +56,20 @@ impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>()
.add_event::<ForfeitEvent>()
.add_systems(
Update,
(
handle_keyboard,
handle_stock_click,
handle_double_click,
start_drag,
follow_drag,
end_drag.before(GameMutation),
)
.chain(),
);
)
.add_systems(Update, handle_fullscreen);
}
}
@@ -70,8 +79,9 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
#[allow(clippy::too_many_arguments)]
fn handle_keyboard(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
progress: Option<Res<ProgressResource>>,
game: Option<Res<crate::resources::GameStateResource>>,
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut confirm_countdown: Local<f32>,
mut undo: EventWriter<UndoRequestEvent>,
@@ -79,7 +89,14 @@ fn handle_keyboard(
mut confirm_event: EventWriter<NewGameConfirmEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut draw: EventWriter<DrawRequestEvent>,
mut forfeit: EventWriter<ForfeitEvent>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
layout: Option<Res<LayoutResource>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Tick down any active confirmation window.
if *confirm_countdown > 0.0 {
*confirm_countdown -= time.delta_secs();
@@ -93,8 +110,9 @@ fn handle_keyboard(
}
if keys.just_pressed(KeyCode::KeyN) {
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
if !active_game {
// No active game — start immediately.
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if shift_held || !active_game {
// Shift+N or no active game — start immediately, no confirmation.
new_game.send(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
} else if *confirm_countdown > 0.0 {
@@ -122,20 +140,78 @@ fn handle_keyboard(
)));
}
}
if keys.just_pressed(KeyCode::KeyD) {
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
draw.send(DrawRequestEvent);
}
// H — show a hint (highlight the source card of the best available move).
if keys.just_pressed(KeyCode::KeyH) {
if let Some(ref g) = game {
if !g.0.is_won {
if let Some(ref layout_res) = layout {
if let Some((from, _to, _count)) = find_hint(&g.0) {
// Find the top face-up card in the source pile.
let top_card_id = g.0.piles.get(&from)
.and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id);
if let Some(card_id) = top_card_id {
for (entity, card_entity, _sprite) in card_entities.iter() {
if card_entity.card_id == card_id {
commands.entity(entity)
.insert(HintHighlight { remaining: 1.5 })
.insert(Sprite {
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
custom_size: Some(layout_res.0.card_size),
..default()
});
break;
}
}
}
} else {
info_toast.send(InfoToastEvent("No hints available".to_string()));
}
}
}
}
}
// G — forfeit the current game (only when a game is actually in progress).
if keys.just_pressed(KeyCode::KeyG) {
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
if active_game {
forfeit.send(ForfeitEvent);
}
}
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
/// `F11` toggles between borderless-fullscreen and windowed mode.
/// Not gated by the pause flag — the player can always resize the window.
fn handle_fullscreen(
keys: Res<ButtonInput<KeyCode>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if !keys.just_pressed(KeyCode::F11) {
return;
}
let Ok(mut window) = windows.get_single_mut() else { return };
window.mode = match window.mode {
WindowMode::Windowed => WindowMode::BorderlessFullscreen(MonitorSelection::Current),
_ => WindowMode::Windowed,
};
}
fn handle_stock_click(
buttons: Res<ButtonInput<MouseButton>>,
drag: Res<DragState>,
paused: Option<Res<PausedResource>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
mut draw: EventWriter<DrawRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return;
}
@@ -154,8 +230,10 @@ fn handle_stock_click(
}
}
#[allow(clippy::too_many_arguments)]
fn start_drag(
buttons: Res<ButtonInput<MouseButton>>,
paused: Option<Res<PausedResource>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
@@ -163,6 +241,9 @@ fn start_drag(
mut drag: ResMut<DragState>,
mut card_transforms: Query<(&CardEntity, &mut Transform)>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return;
}
@@ -238,6 +319,7 @@ fn follow_drag(
#[allow(clippy::too_many_arguments)]
fn end_drag(
buttons: Res<ButtonInput<MouseButton>>,
paused: Option<Res<PausedResource>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
@@ -247,6 +329,10 @@ fn end_drag(
mut rejected: EventWriter<MoveRejectedEvent>,
mut changed: EventWriter<StateChangedEvent>,
) {
if paused.is_some_and(|p| p.0) {
drag.clear();
return;
}
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
return;
}
@@ -486,6 +572,146 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
(center, layout.card_size)
}
// ---------------------------------------------------------------------------
// Task #27 — Double-click to auto-move
// ---------------------------------------------------------------------------
/// Maximum seconds between two clicks to count as a double-click.
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
/// Find the best legal destination for `card` — Foundation first, then Tableau.
///
/// Returns `None` if no legal move exists from the card's current location.
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
// Try all four foundations first.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let dest = PileType::Foundation(suit);
if let Some(pile) = game.piles.get(&dest) {
if can_place_on_foundation(card, pile, suit) {
return Some(dest);
}
}
}
// Then try all seven tableau piles.
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if let Some(pile) = game.piles.get(&dest) {
if can_place_on_tableau(card, pile) {
return Some(dest);
}
}
}
None
}
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination (foundation before tableau).
#[allow(clippy::too_many_arguments)]
fn handle_double_click(
buttons: Res<ButtonInput<MouseButton>>,
paused: Option<Res<PausedResource>>,
time: Res<Time>,
drag: Res<DragState>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut last_click: Local<HashMap<u32, f32>>,
mut moves: EventWriter<MoveRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return;
}
let Some(layout) = layout else { return };
let Some(world) = cursor_world(&windows, &cameras) else { return };
// Identify which card was clicked (must be face-up and draggable).
let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
return;
};
// Only auto-move a single card (top card of the stack).
let Some(&top_card_id) = card_ids.last() else { return };
// The top draggable card is at `stack_index + card_ids.len() - 1`.
let top_index = stack_index + card_ids.len() - 1;
let Some(card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(top_index)) else { return };
if !card.face_up || card.id != top_card_id {
return;
}
let now = time.elapsed_secs();
let prev = last_click.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_CLICK_WINDOW {
// Double-click detected — find and fire the best move.
last_click.remove(&top_card_id);
if let Some(dest) = best_destination(card, &game.0) {
moves.send(MoveRequestEvent {
from: pile,
to: dest,
count: 1,
});
}
} else {
// Single click — record the time.
last_click.insert(top_card_id, now);
}
}
// ---------------------------------------------------------------------------
// Task #28 — Hint system helpers
// ---------------------------------------------------------------------------
/// Find one valid move in the current game state.
///
/// Returns `(from, to, count)` for the first legal move found, or `None` if
/// no move is available. Sources checked: Waste top, then Tableau 06.
/// Destinations checked: all 4 Foundations, then all 7 Tableau piles.
pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
let sources: Vec<PileType> = {
let mut s = vec![PileType::Waste];
for i in 0..7_usize {
s.push(PileType::Tableau(i));
}
s
};
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundations.
for &suit in &suits {
let dest = PileType::Foundation(suit);
if let Some(dest_pile) = game.piles.get(&dest) {
if can_place_on_foundation(card, dest_pile, suit) {
return Some((from.clone(), dest, 1));
}
}
}
// Check tableau piles (skip the source pile itself).
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if dest == *from {
continue;
}
if let Some(dest_pile) = game.piles.get(&dest) {
if can_place_on_tableau(card, dest_pile) {
return Some((from.clone(), dest, 1));
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -678,6 +904,17 @@ mod tests {
assert_eq!(ids, vec![202], "only the top card is draggable from waste");
}
#[test]
fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
// Clear tableau 0 so it's an empty slot.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let pos = layout.pile_positions[&PileType::Tableau(0)];
let result = find_draggable_at(pos, &game, &layout);
assert!(result.is_none(), "clicking an empty pile must not produce a draggable");
}
#[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
@@ -690,6 +927,126 @@ mod tests {
assert_eq!(size, layout.card_size);
}
}
// -----------------------------------------------------------------------
// Task #27 — best_destination pure-function tests
// -----------------------------------------------------------------------
#[test]
fn best_destination_prefers_foundation_over_tableau() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameMode;
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
// Put an Ace of Clubs in the waste pile.
let waste = game.piles.get_mut(&PileType::Waste).unwrap();
waste.cards.clear();
waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true });
// Foundation for Clubs is empty — Ace should go there.
let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap();
foundation.cards.clear();
let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
let dest = best_destination(&card, &game);
assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs)));
}
#[test]
fn best_destination_falls_back_to_tableau_when_no_foundation() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameMode;
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
// Clear all foundations — a Two of Clubs cannot go there.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
// Put a Two of Clubs as the card.
let card = Card { id: 300, suit: Suit::Clubs, rank: Rank::Two, face_up: true };
// Set tableau 0 to have a Three of Hearts on top so we can place clubs two there.
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 301,
suit: Suit::Hearts,
rank: Rank::Three,
face_up: true,
});
let dest = best_destination(&card, &game);
assert_eq!(dest, Some(PileType::Tableau(0)));
}
#[test]
fn best_destination_returns_none_when_no_legal_move() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear everything except one card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// A Two of Clubs with empty foundations and empty tableau has no destination.
let card = Card { id: 400, suit: Suit::Clubs, rank: Rank::Two, face_up: true };
assert!(best_destination(&card, &game).is_none());
}
// -----------------------------------------------------------------------
// Task #28 — find_hint pure-function tests
// -----------------------------------------------------------------------
#[test]
fn find_hint_finds_ace_to_foundation() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Place Ace of Clubs on top of tableau 0.
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap().cards.clear();
let hint = find_hint(&game);
assert!(hint.is_some(), "should find a hint");
let (from, to, count) = hint.unwrap();
assert_eq!(from, PileType::Tableau(0));
assert_eq!(to, PileType::Foundation(Suit::Clubs));
assert_eq!(count, 1);
}
#[test]
fn find_hint_returns_none_when_no_legal_move() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Put only a Two on tableau 0, empty everything else.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Two of Clubs has no legal destination.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 600, suit: Suit::Clubs, rank: Rank::Two, face_up: true,
});
assert!(find_hint(&game).is_none(), "no hint should exist");
}
}
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
+94 -23
View File
@@ -4,6 +4,10 @@
//! 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.
//!
//! **Key highlights** (#49): The key names **D**, **H**, and **U** inside the
//! instructional text are rendered in a bright orange colour via `TextSpan`
//! children tagged with `KeyHighlightSpan`.
use std::path::PathBuf;
@@ -16,6 +20,18 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
#[derive(Component, Debug)]
pub struct OnboardingScreen;
/// Marker on `TextSpan` entities that display a key name (D, H, U …) in the
/// onboarding banner. Colour distinct from body text; usable by tests and any
/// future flash-animation system.
#[derive(Component, Debug)]
pub struct KeyHighlightSpan;
/// Body text colour — golden yellow matching the rest of the UI.
const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
/// Bright orange used for key-name spans so they stand out from body text.
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
@@ -66,21 +82,6 @@ fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
}
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,
@@ -100,16 +101,62 @@ fn spawn_onboarding_screen(commands: &mut Commands) {
ZIndex(230),
))
.with_children(|b| {
for (line, size) in lines {
// Title
b.spawn((
Text::new(line),
TextFont {
font_size: size,
..default()
},
TextColor(Color::srgb(1.0, 0.87, 0.0)),
Text::new("Welcome to Solitaire Quest!"),
TextFont { font_size: 40.0, ..default() },
TextColor(BODY_COLOR),
));
// Spacer
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
// Instruction line: "Drag cards between piles. Press D to draw, U to undo."
// D and U rendered as KeyHighlightSpan children with KEY_COLOR.
b.spawn((
Text::new("Drag cards between piles. Press "),
TextFont { font_size: 22.0, ..default() },
TextColor(BODY_COLOR),
))
.with_children(|t| {
t.spawn((
TextSpan::new("D"),
TextColor(KEY_COLOR),
KeyHighlightSpan,
));
t.spawn((TextSpan::new(" to draw, "), TextColor(BODY_COLOR)));
t.spawn((TextSpan::new("U"), TextColor(KEY_COLOR)));
t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR)));
});
// Help line: "Press H or ? at any time to see the full controls."
// H rendered as a KeyHighlightSpan child with KEY_COLOR.
b.spawn((
Text::new("Press "),
TextFont { font_size: 22.0, ..default() },
TextColor(BODY_COLOR),
))
.with_children(|t| {
t.spawn((
TextSpan::new("H"),
TextColor(KEY_COLOR),
KeyHighlightSpan,
));
t.spawn((
TextSpan::new(" or ? at any time to see the full controls."),
TextColor(BODY_COLOR),
));
});
// Spacer
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
// Dismiss hint
b.spawn((
Text::new("Press any key to begin"),
TextFont { font_size: 20.0, ..default() },
TextColor(Color::srgb(0.8, 0.8, 0.8)),
));
}
});
}
@@ -188,4 +235,28 @@ mod tests {
assert_eq!(count_screens(&mut app), 0);
}
#[test]
fn banner_has_two_key_highlight_spans() {
// D and H must be tagged KeyHighlightSpan so their colour is distinct
// from body text and future flash-animation systems can target them.
let mut app = headless_app();
app.update();
let count = app
.world_mut()
.query::<&KeyHighlightSpan>()
.iter(app.world())
.count();
assert_eq!(count, 2, "expected KeyHighlightSpan for D and H");
}
#[test]
fn key_highlight_colour_differs_from_body_colour() {
// Regression guard: KEY_COLOR must not accidentally match BODY_COLOR.
assert_ne!(
format!("{KEY_COLOR:?}"),
format!("{BODY_COLOR:?}"),
"key highlight colour should differ from body text colour"
);
}
}
+21
View File
@@ -244,4 +244,25 @@ mod tests {
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].total_xp, total_xp);
}
#[test]
fn zen_mode_win_awards_base_xp() {
// Zen mode suppresses score display but XP is still awarded normally.
// score=0 in the event (Zen keeps score at 0), time=300 (no speed bonus),
// undo_count=0 so no-undo bonus applies: expected 50+25=75.
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().send_event(GameWonEvent {
score: 0, // Zen mode keeps score at 0
time_seconds: 300,
});
app.update();
let xp = app.world().resource::<ProgressResource>().0.total_xp;
assert_eq!(xp, 75, "Zen win: base 50 + no-undo 25 = 75");
}
}
+59
View File
@@ -231,6 +231,65 @@ pub async fn delete_account(
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, DecodingKey, Validation};
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
fn decode_token(token: &str) -> Claims {
let mut validation = Validation::default();
validation.leeway = 60;
decode::<Claims>(
token,
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
&validation,
)
.unwrap()
.claims
}
#[test]
fn make_access_token_decodes_with_correct_claims() {
let token = make_access_token("user-123", TEST_SECRET).unwrap();
let claims = decode_token(&token);
assert_eq!(claims.sub, "user-123");
assert_eq!(claims.kind, "access");
let now = Utc::now().timestamp() as usize;
// expiry should be roughly 24 hours in the future (allow ±60s for test execution)
assert!(claims.exp > now + 86_400 - 60);
assert!(claims.exp < now + 86_400 + 60);
}
#[test]
fn make_refresh_token_decodes_with_correct_claims() {
let token = make_refresh_token("user-456", TEST_SECRET).unwrap();
let claims = decode_token(&token);
assert_eq!(claims.sub, "user-456");
assert_eq!(claims.kind, "refresh");
let now = Utc::now().timestamp() as usize;
// expiry should be roughly 30 days in the future (allow ±60s for test execution)
assert!(claims.exp > now + 30 * 86_400 - 60);
assert!(claims.exp < now + 30 * 86_400 + 60);
}
#[test]
fn make_access_token_wrong_secret_fails_decode() {
let token = make_access_token("user-789", TEST_SECRET).unwrap();
let result = decode::<Claims>(
&token,
&DecodingKey::from_secret(b"wrong_secret"),
&Validation::default(),
);
assert!(result.is_err(), "decoding with wrong secret must fail");
}
#[test]
fn access_and_refresh_tokens_have_different_kinds() {
let access = make_access_token("u", TEST_SECRET).unwrap();
let refresh = make_refresh_token("u", TEST_SECRET).unwrap();
let a_claims = decode_token(&access);
let r_claims = decode_token(&refresh);
assert_ne!(a_claims.kind, r_claims.kind);
}
#[test]
fn username_chars_ok_accepts_alphanumeric_and_underscore() {
+20
View File
@@ -194,4 +194,24 @@ mod tests {
assert!(g.target_score.is_none());
assert!(g.max_time_secs.is_none());
}
#[test]
fn generate_goal_all_variants_have_sane_ranges() {
for variant_idx in 0u64..6 {
let g = generate_goal("2026-04-26", variant_idx);
assert!(!g.description.is_empty(), "variant {variant_idx}: description must not be empty");
if let Some(t) = g.max_time_secs {
assert!(
(60..=3600).contains(&t),
"variant {variant_idx}: max_time_secs {t} outside [60, 3600]"
);
}
if let Some(s) = g.target_score {
assert!(
(1_000..=10_000).contains(&s),
"variant {variant_idx}: target_score {s} outside [1000, 10000]"
);
}
}
}
}
+86 -1
View File
@@ -16,7 +16,7 @@ use axum::{
response::Response,
};
use chrono::Utc;
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::Deserialize;
use serde_json::Value;
use solitaire_server::build_test_router;
@@ -1052,3 +1052,88 @@ async fn login_trims_whitespace_from_username() {
"login with whitespace-padded username must succeed"
);
}
// ---------------------------------------------------------------------------
// Security tests
// ---------------------------------------------------------------------------
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
#[tokio::test]
async fn push_oversized_body_returns_413() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
// 1_100_000-byte string embedded in JSON comfortably exceeds the 1 MB limit.
let big_string = "x".repeat(1_100_000);
let body_bytes =
serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
let req = Request::builder()
.method("POST")
.uri("/api/sync/push")
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {access}"))
.header("x-forwarded-for", TEST_CLIENT_IP)
.body(Body::from(body_bytes))
.expect("failed to build oversized request");
let resp = app.oneshot(req).await.expect("oneshot failed");
assert_eq!(
resp.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"oversized body must be rejected with 413"
);
}
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
#[tokio::test]
async fn expired_access_token_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
#[derive(serde::Serialize)]
struct ExpiredClaims {
sub: String,
exp: usize,
kind: String,
}
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode(
&Header::default(),
&ExpiredClaims {
sub: "00000000-0000-0000-0000-000000000000".into(),
exp,
kind: "access".into(),
},
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.unwrap();
let resp = get_authed(app, "/api/sync/pull", &expired_token).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"expired JWT must be rejected with 401"
);
}
/// A refresh token must be rejected when used as a Bearer token on protected routes.
#[tokio::test]
async fn refresh_token_rejected_on_protected_routes() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
// Using the refresh token (kind = "refresh") as a Bearer on a protected route
// must return 401 because the middleware requires kind = "access".
let resp = get_authed(app, "/api/sync/pull", &refresh).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"refresh token must be rejected on protected endpoints"
);
}
+22
View File
@@ -642,4 +642,26 @@ mod tests {
assert_eq!(merged.progress.weekly_goal_week_iso, Some("2026-W17".to_string()));
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
}
#[test]
fn fastest_win_both_max_sentinel_stays_max() {
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
// not wrap or clamp to 0.
let local = default_payload();
let remote = default_payload();
assert_eq!(local.stats.fastest_win_seconds, u64::MAX);
assert_eq!(remote.stats.fastest_win_seconds, u64::MAX);
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.fastest_win_seconds, u64::MAX);
}
#[test]
fn fastest_win_one_side_max_takes_real_value() {
// Local has no wins (u64::MAX); remote has a real win. Merged must use the real time.
let local = default_payload(); // fastest_win_seconds = u64::MAX
let mut remote = default_payload();
remote.stats.fastest_win_seconds = 300;
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.fastest_win_seconds, 300);
}
}