95df5421c9
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.
Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.
can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.
next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.
The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.
Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.
9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
is unaffected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
96 lines
2.8 KiB
Rust
96 lines
2.8 KiB
Rust
use crate::pile::PileType;
|
|
|
|
/// Score delta for moving cards from `from` to `to`.
|
|
///
|
|
/// Windows XP Standard scoring:
|
|
/// - +10 for any card reaching a foundation pile
|
|
/// - +5 for a waste → tableau move
|
|
/// - 0 for all other moves
|
|
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
|
match to {
|
|
PileType::Foundation(_) => 10,
|
|
PileType::Tableau(_) => {
|
|
if matches!(from, PileType::Waste) { 5 } else { 0 }
|
|
}
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// Score penalty applied when the player uses undo: -15.
|
|
pub fn score_undo() -> i32 {
|
|
-15
|
|
}
|
|
|
|
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
|
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
|
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
|
if elapsed_seconds == 0 {
|
|
return 0;
|
|
}
|
|
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn move_to_foundation_scores_ten() {
|
|
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
|
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn waste_to_tableau_scores_five() {
|
|
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_to_tableau_scores_zero() {
|
|
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_penalty_is_negative_fifteen() {
|
|
assert_eq!(score_undo(), -15);
|
|
}
|
|
|
|
#[test]
|
|
fn time_bonus_at_100_seconds() {
|
|
assert_eq!(compute_time_bonus(100), 7000);
|
|
}
|
|
|
|
#[test]
|
|
fn time_bonus_at_zero_is_zero() {
|
|
assert_eq!(compute_time_bonus(0), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn time_bonus_at_one_second() {
|
|
assert_eq!(compute_time_bonus(1), 700_000);
|
|
}
|
|
|
|
#[test]
|
|
fn non_waste_to_tableau_scores_zero() {
|
|
// Foundation → Tableau is impossible in practice but must score 0.
|
|
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
|
// Tableau → Tableau (restack) scores 0.
|
|
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn move_to_stock_or_waste_scores_zero() {
|
|
// These destinations are illegal moves in practice, but the function
|
|
// must not panic and should return 0.
|
|
assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0);
|
|
assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
|
// Very short elapsed time would overflow without the .min() guard.
|
|
let bonus = compute_time_bonus(1);
|
|
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
|
|
}
|
|
}
|