ddd7502a06
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>
167 lines
5.8 KiB
Rust
167 lines
5.8 KiB
Rust
use crate::card::{Card, Suit};
|
|
use crate::pile::Pile;
|
|
|
|
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`.
|
|
///
|
|
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher.
|
|
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
|
|
if card.suit != suit {
|
|
return false;
|
|
}
|
|
match pile.cards.last() {
|
|
None => card.rank.value() == 1,
|
|
Some(top) => card.rank.value() == top.rank.value() + 1,
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
|
///
|
|
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
|
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
|
match pile.cards.last() {
|
|
None => card.rank.value() == 13,
|
|
Some(top) => {
|
|
top.face_up
|
|
&& card.rank.value() + 1 == top.rank.value()
|
|
&& card.suit.is_red() != top.suit.is_red()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::card::{Card, Rank, Suit};
|
|
use crate::pile::{Pile, PileType};
|
|
|
|
fn card(suit: Suit, rank: Rank) -> Card {
|
|
Card { id: 0, suit, rank, face_up: true }
|
|
}
|
|
|
|
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
|
|
Pile { pile_type, cards }
|
|
}
|
|
|
|
// Foundation tests
|
|
#[test]
|
|
fn foundation_ace_on_empty_is_valid() {
|
|
let c = card(Suit::Hearts, Rank::Ace);
|
|
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
|
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_non_ace_on_empty_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Two);
|
|
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
|
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_two_on_ace_same_suit_is_valid() {
|
|
let c = card(Suit::Clubs, Rank::Two);
|
|
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
|
|
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_wrong_suit_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Ace);
|
|
let p = Pile::new(PileType::Foundation(Suit::Spades));
|
|
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_skipping_rank_is_invalid() {
|
|
let c = card(Suit::Diamonds, Rank::Three);
|
|
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
|
|
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
|
|
}
|
|
|
|
// Tableau tests
|
|
#[test]
|
|
fn tableau_king_on_empty_is_valid() {
|
|
let c = card(Suit::Hearts, Rank::King);
|
|
let p = Pile::new(PileType::Tableau(0));
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_non_king_on_empty_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Queen);
|
|
let p = Pile::new(PileType::Tableau(0));
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_red_on_black_one_lower_is_valid() {
|
|
let c = card(Suit::Hearts, Rank::Nine);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_same_color_is_invalid() {
|
|
let c = card(Suit::Clubs, Rank::Nine);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_wrong_rank_difference_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Eight);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_black_on_red_one_lower_is_valid() {
|
|
let c = card(Suit::Clubs, Rank::Six);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_king_on_queen_completes_suit() {
|
|
// The last card placed to complete a foundation is always King on Queen.
|
|
let c = card(Suit::Spades, Rank::King);
|
|
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
|
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_king_wrong_suit_is_invalid() {
|
|
// King of Hearts cannot go on a Spades foundation even if rank matches.
|
|
let c = card(Suit::Hearts, Rank::King);
|
|
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
|
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_ace_on_two_different_color_is_valid() {
|
|
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
|
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
|
let c = card(Suit::Hearts, Rank::Ace);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_same_rank_different_color_is_invalid() {
|
|
// Two cards of the same rank cannot be stacked regardless of colour.
|
|
let c = card(Suit::Hearts, Rank::Nine);
|
|
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));
|
|
}
|
|
}
|