diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 16043d2..3d1aa0e 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -145,6 +145,10 @@ pub struct GameState { /// Used by the `comeback` achievement condition. #[serde(default)] pub recycle_count: u32, + /// When `true`, the player may move the top card of a foundation pile back + /// onto a compatible tableau column. Off by default — non-standard house rule. + #[serde(default)] + pub take_from_foundation: bool, /// Save-file schema version. Defaults to `1` for older files that pre-date /// the field. The loader refuses any value other than /// [`GAME_STATE_SCHEMA_VERSION`]. @@ -187,6 +191,7 @@ impl GameState { is_auto_completable: false, undo_count: 0, recycle_count: 0, + take_from_foundation: false, schema_version: GAME_STATE_SCHEMA_VERSION, undo_stack: VecDeque::new(), } @@ -312,6 +317,18 @@ impl GameState { } } PileType::Tableau(_) => { + if matches!(&from, PileType::Foundation(_)) { + if !self.take_from_foundation { + return Err(MoveError::RuleViolation( + "take-from-foundation rule is disabled".into(), + )); + } + if count != 1 { + return Err(MoveError::RuleViolation( + "only one card can return from foundation at a time".into(), + )); + } + } let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; if !can_place_on_tableau(&bottom_card, dest) { return Err(MoveError::RuleViolation("invalid tableau placement".into())); @@ -1258,4 +1275,71 @@ mod tests { "must target the Hearts-claimed slot, not the empty slot 0", ); } + + fn setup_take_from_foundation_game() -> GameState { + let mut g = new_game(); + // Clear the board so we control the layout exactly. + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + // Foundation slot 0: A♠, 2♠ (top = 2♠) + let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap(); + f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); + f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true }); + // Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1) + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true, + }); + g + } + + #[test] + fn take_from_foundation_blocked_by_default() { + let mut g = setup_take_from_foundation_game(); + assert!(!g.take_from_foundation); + let err = g + .move_cards(PileType::Foundation(0), PileType::Tableau(0), 1) + .unwrap_err(); + assert!( + matches!(err, MoveError::RuleViolation(_)), + "expected RuleViolation, got {err:?}", + ); + } + + #[test] + fn take_from_foundation_allowed_when_enabled() { + let mut g = setup_take_from_foundation_game(); + g.take_from_foundation = true; + g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap(); + // Foundation slot 0 should now hold only the Ace. + assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1); + assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace); + // The 2♠ should be on top of tableau 0 above the 3♥. + let t0 = &g.piles[&PileType::Tableau(0)].cards; + assert_eq!(t0.len(), 2); + assert_eq!(t0[1].rank, Rank::Two); + } + + #[test] + fn take_from_foundation_rejects_illegal_tableau_placement() { + let mut g = setup_take_from_foundation_game(); + g.take_from_foundation = true; + // Tableau 1 is empty — only a King can go there; 2♠ is not a King. + let err = g + .move_cards(PileType::Foundation(0), PileType::Tableau(1), 1) + .unwrap_err(); + assert!(matches!(err, MoveError::RuleViolation(_))); + } + + #[test] + fn take_from_foundation_rejects_count_gt_1() { + let mut g = setup_take_from_foundation_game(); + g.take_from_foundation = true; + let err = g + .move_cards(PileType::Foundation(0), PileType::Tableau(0), 2) + .unwrap_err(); + assert!(matches!(err, MoveError::RuleViolation(_))); + } } diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 8ef214a..5641314 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -237,6 +237,12 @@ pub struct Settings { /// field existed deserialize cleanly to `None` via `#[serde(default)]`. #[serde(default)] pub leaderboard_display_name: Option, + /// When `true`, the player may drag the top card of a completed foundation + /// pile back onto a compatible tableau column — a non-standard house rule. + /// Off by default. Older `settings.json` files deserialize cleanly to + /// `false` via `#[serde(default)]`. + #[serde(default)] + pub take_from_foundation: bool, } fn default_draw_mode() -> DrawMode { @@ -357,6 +363,7 @@ impl Default for Settings { replay_move_interval_secs: default_replay_move_interval_secs(), last_difficulty: None, leaderboard_display_name: None, + take_from_foundation: false, } } } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index a77354d..3295aff 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -490,6 +490,9 @@ fn handle_new_game( let chosen_seed = initial_seed; game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode); + if let Some(s) = settings.as_ref() { + game.0.take_from_foundation = s.0.take_from_foundation; + } // Reset the in-flight replay buffer — a fresh deal starts with // an empty move list. The previously saved replay on disk // (latest_replay.json) is preserved until the player wins again.