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
+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));
}
}