69c6e88188
- Derive PartialOrd+Ord on PileType and sort pile entries in pile_map_serde before serializing so save-file output is deterministic (M-4) - Add #[serde(skip)] to undo_stack so transient undo history is never written to save files, eliminating unnecessary bloat (M-3) - Add merge_at() accepting an explicit resolved_at timestamp so callers can inject the server-side time; merge() wraps it with Utc::now() for backwards compatibility (M-1) - Fix url_encode to percent-encode UTF-8 bytes rather than Unicode codepoints so multi-byte characters produce RFC 3986-compliant output (M-2) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
106 lines
3.4 KiB
Rust
106 lines
3.4 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use crate::card::{Card, Suit};
|
||
|
||
/// Identifies which pile on the board a set of cards belongs to.
|
||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
pub enum PileType {
|
||
/// The face-down draw pile.
|
||
Stock,
|
||
/// The face-up discard pile drawn to.
|
||
Waste,
|
||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||
/// is derived from the bottom card of the pile (always an Ace by
|
||
/// construction).
|
||
Foundation(u8),
|
||
/// One of the seven tableau columns (0–6).
|
||
Tableau(usize),
|
||
}
|
||
|
||
/// A named collection of cards in a specific board position.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct Pile {
|
||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||
pub pile_type: PileType,
|
||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||
pub cards: Vec<Card>,
|
||
}
|
||
|
||
impl Pile {
|
||
/// Creates a new empty pile of the given type.
|
||
pub fn new(pile_type: PileType) -> Self {
|
||
Self { pile_type, cards: Vec::new() }
|
||
}
|
||
|
||
/// Returns a reference to the top (last) card, or `None` if empty.
|
||
pub fn top(&self) -> Option<&Card> {
|
||
self.cards.last()
|
||
}
|
||
|
||
/// For foundation piles: returns `Some(suit)` once at least one card has
|
||
/// landed (the bottom card is always an Ace of the claimed suit).
|
||
/// Returns `None` for empty foundations or non-foundation piles.
|
||
pub fn claimed_suit(&self) -> Option<Suit> {
|
||
match self.pile_type {
|
||
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::card::{Card, Rank, Suit};
|
||
|
||
#[test]
|
||
fn new_pile_is_empty() {
|
||
let pile = Pile::new(PileType::Stock);
|
||
assert!(pile.cards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn pile_top_returns_last_card() {
|
||
let mut pile = Pile::new(PileType::Waste);
|
||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||
pile.cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Two, face_up: true });
|
||
assert_eq!(pile.top().unwrap().id, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn pile_top_on_empty_is_none() {
|
||
let pile = Pile::new(PileType::Waste);
|
||
assert!(pile.top().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pile_type_foundation_uses_slot_index() {
|
||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||
}
|
||
|
||
#[test]
|
||
fn pile_type_tableau_uses_index() {
|
||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||
}
|
||
|
||
#[test]
|
||
fn claimed_suit_is_none_for_empty_foundation() {
|
||
let pile = Pile::new(PileType::Foundation(0));
|
||
assert!(pile.claimed_suit().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn claimed_suit_is_none_for_non_foundation() {
|
||
let mut pile = Pile::new(PileType::Tableau(0));
|
||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||
assert!(pile.claimed_suit().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn claimed_suit_returns_bottom_card_suit() {
|
||
let mut pile = Pile::new(PileType::Foundation(2));
|
||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||
pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true });
|
||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||
}
|
||
}
|