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:
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 0–6.
|
||||
/// 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
|
||||
|
||||
@@ -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 {
|
||||
b.spawn((
|
||||
Text::new(line),
|
||||
TextFont {
|
||||
font_size: size,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
// Title
|
||||
b.spawn((
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user