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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user