feat(core): take-from-foundation house rule
Add `GameState::take_from_foundation` flag (default false). When off, Foundation→Tableau moves are blocked at the core rule layer. When on, the top card of a foundation pile may be moved back to a compatible tableau column (one card at a time). Wire the matching `Settings::take_from_foundation` field through `handle_new_game` so the player's preference applies to every new deal. Four targeted tests cover: blocked-by-default, allowed-when-enabled, illegal-tableau-placement, and count>1 rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,10 @@ pub struct GameState {
|
|||||||
/// Used by the `comeback` achievement condition.
|
/// Used by the `comeback` achievement condition.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recycle_count: u32,
|
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
|
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||||
/// the field. The loader refuses any value other than
|
/// the field. The loader refuses any value other than
|
||||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||||
@@ -187,6 +191,7 @@ impl GameState {
|
|||||||
is_auto_completable: false,
|
is_auto_completable: false,
|
||||||
undo_count: 0,
|
undo_count: 0,
|
||||||
recycle_count: 0,
|
recycle_count: 0,
|
||||||
|
take_from_foundation: false,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
undo_stack: VecDeque::new(),
|
undo_stack: VecDeque::new(),
|
||||||
}
|
}
|
||||||
@@ -312,6 +317,18 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
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)?;
|
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||||
if !can_place_on_tableau(&bottom_card, dest) {
|
if !can_place_on_tableau(&bottom_card, dest) {
|
||||||
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
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",
|
"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(_)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,12 @@ pub struct Settings {
|
|||||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub leaderboard_display_name: Option<String>,
|
pub leaderboard_display_name: Option<String>,
|
||||||
|
/// 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 {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -357,6 +363,7 @@ impl Default for Settings {
|
|||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
|
take_from_foundation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -490,6 +490,9 @@ fn handle_new_game(
|
|||||||
let chosen_seed = initial_seed;
|
let chosen_seed = initial_seed;
|
||||||
|
|
||||||
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
|
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
|
// Reset the in-flight replay buffer — a fresh deal starts with
|
||||||
// an empty move list. The previously saved replay on disk
|
// an empty move list. The previously saved replay on disk
|
||||||
// (latest_replay.json) is preserved until the player wins again.
|
// (latest_replay.json) is preserved until the player wins again.
|
||||||
|
|||||||
Reference in New Issue
Block a user