# Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Bootstrap the Cargo workspace with all seven crates compiling cleanly, a blank Bevy window opening, and the complete Klondike game logic in `solitaire_core` fully tested. **Architecture:** All seven crates are created with the correct dependency graph. `solitaire_core` contains zero Bevy/network code — pure Rust game rules, scoring, and undo. The GPGS crate is a compile-time stub enforcing the trait contract. Bevy `0.15` is used for the blank window; version may need bumping to match current stable at implementation time. **Tech Stack:** Rust 2021 edition, Cargo workspace, Bevy 0.15, bevy_egui, bevy_kira_audio, rand 0.8, serde 1, chrono 0.4, thiserror 1, async-trait 0.1 --- ## Scope This plan covers **Phase 1** (workspace + blank Bevy window + GPGS stub) and **Phase 2** (complete `solitaire_core` game logic with tests). Phases 3–8 are out of scope and should be planned separately after these phases pass all gates. --- ## File Map ### Created in Phase 1 | File | Purpose | |---|---| | `Cargo.toml` | Workspace manifest with profile settings and shared deps | | `solitaire_core/Cargo.toml` | Core crate manifest (rand, serde, chrono, thiserror) | | `solitaire_core/src/lib.rs` | Re-exports all public modules | | `solitaire_sync/Cargo.toml` | Sync types manifest (serde, uuid, chrono) | | `solitaire_sync/src/lib.rs` | Minimal stub: SyncPayload, SyncResponse | | `solitaire_data/Cargo.toml` | Data crate manifest (solitaire_core, solitaire_sync, async-trait, thiserror) | | `solitaire_data/src/lib.rs` | Minimal stub: SyncError, SyncProvider trait | | `solitaire_engine/Cargo.toml` | Engine manifest (bevy, bevy_egui, bevy_kira_audio, solitaire_core, solitaire_data) | | `solitaire_engine/src/lib.rs` | Empty stub | | `solitaire_server/Cargo.toml` | Server manifest (solitaire_sync, axum, sqlx, etc.) | | `solitaire_server/src/main.rs` | Stub `fn main() {}` | | `solitaire_gpgs/Cargo.toml` | GPGS manifest (solitaire_data, async-trait) | | `solitaire_gpgs/src/lib.rs` | cfg-gated re-exports | | `solitaire_gpgs/src/stub.rs` | Desktop stub implementing SyncProvider | | `solitaire_gpgs/src/android.rs` | Android phase TODO placeholder | | `solitaire_app/Cargo.toml` | App manifest (bevy, solitaire_engine) | | `solitaire_app/src/main.rs` | Bevy App::new() opening blank window | | `assets/cards/faces/.gitkeep` | Placeholder | | `assets/cards/backs/.gitkeep` | Placeholder | | `assets/backgrounds/.gitkeep` | Placeholder | | `assets/fonts/.gitkeep` | Placeholder | | `assets/audio/.gitkeep` | Placeholder | | `.env.example` | Server environment variable template | ### Created/expanded in Phase 2 | File | Purpose | |---|---| | `solitaire_core/src/card.rs` | Suit, Rank, Card types | | `solitaire_core/src/pile.rs` | PileType, Pile types | | `solitaire_core/src/error.rs` | MoveError enum | | `solitaire_core/src/deck.rs` | Deck::new(), Deck::shuffle(), deal_klondike() | | `solitaire_core/src/rules.rs` | can_place_on_foundation(), can_place_on_tableau() | | `solitaire_core/src/scoring.rs` | score_move(), score_undo(), compute_time_bonus() | | `solitaire_core/src/game_state.rs` | GameState, DrawMode, StateSnapshot | --- ## Task 1: Workspace Cargo.toml **Files:** - Create: `Cargo.toml` - [ ] **Step 1: Create the workspace Cargo.toml** ```toml [workspace] members = [ "solitaire_core", "solitaire_sync", "solitaire_data", "solitaire_engine", "solitaire_server", "solitaire_gpgs", "solitaire_app", ] resolver = "2" [workspace.package] edition = "2021" version = "0.1.0" [workspace.dependencies] # Core utilities serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "1" rand = "0.8" async-trait = "0.1" tokio = { version = "1", features = ["full"] } dirs = "5" keyring = "2" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } # Workspace crates solitaire_core = { path = "solitaire_core" } solitaire_sync = { path = "solitaire_sync" } solitaire_data = { path = "solitaire_data" } solitaire_engine = { path = "solitaire_engine" } # Bevy — check https://crates.io/crates/bevy for latest stable if 0.15 is outdated bevy = "0.15" bevy_egui = "0.30" bevy_kira_audio = "0.21" # Server axum = "0.7" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] } jsonwebtoken = "9" bcrypt = "0.15" tower-governor = "0.4" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dotenvy = "0.15" [profile.dev] opt-level = 1 [profile.dev.package."*"] opt-level = 3 [profile.release] opt-level = 3 lto = "thin" ``` > **Note on Bevy versions:** `bevy = "0.15"`, `bevy_egui = "0.30"`, and `bevy_kira_audio = "0.21"` were compatible as of early 2025. Run `cargo search bevy` to check if a newer stable version is current and update accordingly. bevy_egui and bevy_kira_audio versions must match the Bevy major version. - [ ] **Step 2: Verify workspace file parses** ```bash cargo metadata --no-deps --format-version 1 | grep '"workspace_root"' ``` Expected: prints the workspace root path without error. --- ## Task 2: solitaire_core Crate Skeleton **Files:** - Create: `solitaire_core/Cargo.toml` - Create: `solitaire_core/src/lib.rs` - [ ] **Step 1: Create solitaire_core/Cargo.toml** ```toml [package] name = "solitaire_core" version.workspace = true edition.workspace = true [dependencies] serde = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } rand = { workspace = true } ``` - [ ] **Step 2: Create solitaire_core/src/lib.rs (empty stub)** ```rust // Modules are added in Phase 2. This file re-exports them. ``` - [ ] **Step 3: Verify it compiles** ```bash cargo check -p solitaire_core ``` Expected: `Finished` with no errors. --- ## Task 3: solitaire_sync Stub **Files:** - Create: `solitaire_sync/Cargo.toml` - Create: `solitaire_sync/src/lib.rs` - [ ] **Step 1: Create solitaire_sync/Cargo.toml** ```toml [package] name = "solitaire_sync" version.workspace = true edition.workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } ``` - [ ] **Step 2: Create solitaire_sync/src/lib.rs** ```rust use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Payload sent from client to server (and returned after server merge). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncPayload { pub user_id: Uuid, pub last_modified: DateTime, } /// Response returned by the sync server after merging. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncResponse { pub server_time: DateTime, } ``` > These are minimal stubs. Full fields are added in Phase 8 (Sync System). - [ ] **Step 3: Verify** ```bash cargo check -p solitaire_sync ``` Expected: `Finished` with no errors. --- ## Task 4: solitaire_data Stub **Files:** - Create: `solitaire_data/Cargo.toml` - Create: `solitaire_data/src/lib.rs` - [ ] **Step 1: Create solitaire_data/Cargo.toml** ```toml [package] name = "solitaire_data" version.workspace = true edition.workspace = true [dependencies] solitaire_core = { workspace = true } solitaire_sync = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } dirs = { workspace = true } keyring = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } ``` - [ ] **Step 2: Create solitaire_data/src/lib.rs** ```rust use async_trait::async_trait; use solitaire_sync::{SyncPayload, SyncResponse}; use thiserror::Error; /// All errors that can arise during sync operations. #[derive(Debug, Error)] pub enum SyncError { #[error("unsupported platform for this sync backend")] UnsupportedPlatform, #[error("network error: {0}")] Network(String), #[error("authentication error: {0}")] Auth(String), #[error("serialization error: {0}")] Serialization(String), } /// Every sync backend implements this trait. The SyncPlugin only calls these /// methods — it never matches on a backend enum variant. #[async_trait] pub trait SyncProvider: Send + Sync { async fn pull(&self) -> Result; async fn push(&self, payload: &SyncPayload) -> Result; fn backend_name(&self) -> &'static str; fn is_authenticated(&self) -> bool; /// Mirror an achievement unlock to this backend (no-op for most backends). async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> { Ok(()) } } ``` - [ ] **Step 3: Verify** ```bash cargo check -p solitaire_data ``` Expected: `Finished` with no errors. --- ## Task 5: solitaire_engine Stub **Files:** - Create: `solitaire_engine/Cargo.toml` - Create: `solitaire_engine/src/lib.rs` - [ ] **Step 1: Create solitaire_engine/Cargo.toml** ```toml [package] name = "solitaire_engine" version.workspace = true edition.workspace = true [dependencies] bevy = { workspace = true } bevy_egui = { workspace = true } bevy_kira_audio = { workspace = true } solitaire_core = { workspace = true } solitaire_data = { workspace = true } ``` - [ ] **Step 2: Create solitaire_engine/src/lib.rs** ```rust // Bevy plugins are added in Phase 3. // This crate will expose: CardPlugin, TablePlugin, AnimationPlugin, // AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin. ``` - [ ] **Step 3: Verify** ```bash cargo check -p solitaire_engine ``` Expected: `Finished` with no errors. --- ## Task 6: solitaire_server Stub **Files:** - Create: `solitaire_server/Cargo.toml` - Create: `solitaire_server/src/main.rs` - [ ] **Step 1: Create solitaire_server/Cargo.toml** ```toml [package] name = "solitaire_server" version.workspace = true edition.workspace = true [[bin]] name = "solitaire_server" path = "src/main.rs" [dependencies] solitaire_sync = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } axum = { workspace = true } sqlx = { workspace = true } jsonwebtoken = { workspace = true } bcrypt = { workspace = true } tower-governor = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } dotenvy = { workspace = true } ``` - [ ] **Step 2: Create solitaire_server/src/main.rs** ```rust // Full server implementation added in Phase 8C. fn main() {} ``` - [ ] **Step 3: Verify** ```bash cargo check -p solitaire_server ``` Expected: `Finished` with no errors. --- ## Task 7: solitaire_gpgs Stub (GPGS Compile-Time Stub) **Files:** - Create: `solitaire_gpgs/Cargo.toml` - Create: `solitaire_gpgs/src/lib.rs` - Create: `solitaire_gpgs/src/stub.rs` - Create: `solitaire_gpgs/src/android.rs` - [ ] **Step 1: Create solitaire_gpgs/Cargo.toml** ```toml [package] name = "solitaire_gpgs" version.workspace = true edition.workspace = true [dependencies] solitaire_data = { workspace = true } solitaire_sync = { workspace = true } async-trait = { workspace = true } ``` - [ ] **Step 2: Create solitaire_gpgs/src/lib.rs** ```rust #[cfg(target_os = "android")] mod android; #[cfg(not(target_os = "android"))] mod stub; // Android placeholder (TODO block only — no JNI yet) mod android_placeholder; #[cfg(not(target_os = "android"))] pub use stub::GpgsClient; #[cfg(target_os = "android")] pub use android::GpgsClient; ``` Wait — the android module must not be compiled on non-android, but we still want the TODO file to exist. Remove the android_placeholder re-export above and instead keep android.rs only compiled on android via cfg. The lib.rs should be: ```rust #[cfg(target_os = "android")] mod android; #[cfg(not(target_os = "android"))] mod stub; #[cfg(not(target_os = "android"))] pub use stub::GpgsClient; #[cfg(target_os = "android")] pub use android::GpgsClient; ``` - [ ] **Step 3: Create solitaire_gpgs/src/stub.rs** ```rust use async_trait::async_trait; use solitaire_data::{SyncError, SyncProvider}; use solitaire_sync::{SyncPayload, SyncResponse}; /// Desktop/iOS stub — always returns UnsupportedPlatform. /// Real implementation lives in android.rs (Phase: Android). pub struct GpgsClient; impl GpgsClient { pub fn new() -> Self { Self } } impl Default for GpgsClient { fn default() -> Self { Self::new() } } #[async_trait] impl SyncProvider for GpgsClient { async fn pull(&self) -> Result { Err(SyncError::UnsupportedPlatform) } async fn push(&self, _payload: &SyncPayload) -> Result { Err(SyncError::UnsupportedPlatform) } fn backend_name(&self) -> &'static str { "Google Play Games (unavailable on this platform)" } fn is_authenticated(&self) -> bool { false } } ``` - [ ] **Step 4: Create solitaire_gpgs/src/android.rs** ```rust // TODO (Phase: Android) — implement JNI bindings here. // // Steps: // 1. Add `jni` dependency under [target.'cfg(target_os = "android")'.dependencies] // 2. Implement GpgsClient using cargo-mobile2 JNI bridge // 3. pull(): call PlayGames.getSnapshotsClient().open("solitaire_quest_sync") // → deserialize JSON blob into SyncPayload // 4. push(): serialize SyncPayload to JSON → write to Saved Game slot // 5. mirror_achievement(id): call PlayGames.getAchievementsClient().unlock(map_id(id)) // 6. Maintain a static ID mapping: our &str IDs → GPGS achievement IDs (from Play Console) // 7. On GameWonEvent, submit score to GPGS leaderboard // 8. Add Google Sign-In button to Settings screen (Android build only, #[cfg] gated) ``` > This file is only compiled on Android (`#[cfg(target_os = "android")]`), so it can contain a bare TODO comment without a `GpgsClient` struct definition until the Android phase. - [ ] **Step 5: Verify** ```bash cargo check -p solitaire_gpgs ``` Expected: `Finished` with no errors. --- ## Task 8: solitaire_app — Blank Bevy Window **Files:** - Create: `solitaire_app/Cargo.toml` - Create: `solitaire_app/src/main.rs` - [ ] **Step 1: Create solitaire_app/Cargo.toml** ```toml [package] name = "solitaire_app" version.workspace = true edition.workspace = true [[bin]] name = "solitaire_app" path = "src/main.rs" [dependencies] bevy = { workspace = true } solitaire_engine = { workspace = true } ``` - [ ] **Step 2: Create solitaire_app/src/main.rs** ```rust use bevy::prelude::*; fn main() { App::new() .add_plugins( DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { title: "Ferrous Solitaire".into(), resolution: (1280.0, 800.0).into(), ..default() }), ..default() }), ) .run(); } ``` - [ ] **Step 3: Run the app to verify the window opens** ```bash cargo run -p solitaire_app --features bevy/dynamic_linking ``` Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal. --- ## Task 9: Assets Directory + .env.example **Files:** - Create: `assets/cards/faces/.gitkeep` - Create: `assets/cards/backs/.gitkeep` - Create: `assets/backgrounds/.gitkeep` - Create: `assets/fonts/.gitkeep` - Create: `assets/audio/.gitkeep` - Create: `.env.example` - [ ] **Step 1: Create asset directory placeholders** ```bash mkdir -p assets/cards/faces assets/cards/backs assets/backgrounds assets/fonts assets/audio touch assets/cards/faces/.gitkeep touch assets/cards/backs/.gitkeep touch assets/backgrounds/.gitkeep touch assets/fonts/.gitkeep touch assets/audio/.gitkeep ``` - [ ] **Step 2: Create .env.example** ``` DATABASE_URL=sqlite://solitaire.db JWT_SECRET=replace_with_64_char_hex_from_openssl_rand_hex_32 SERVER_PORT=8080 ADMIN_USERNAME=admin ``` - [ ] **Step 3: Verify full workspace compiles and tests pass** ```bash cargo test --workspace cargo clippy --workspace -- -D warnings ``` Expected: all tests pass (zero tests exist yet, so 0 passed), clippy reports zero warnings. - [ ] **Step 4: Commit Phase 1** ```bash git init git add Cargo.toml solitaire_core solitaire_sync solitaire_data solitaire_engine solitaire_server solitaire_gpgs solitaire_app assets .env.example git commit -m "feat(workspace): initialize all seven crates with stubs and blank Bevy window" ``` --- ## Task 10: solitaire_core — Card Types (TDD) **Files:** - Create: `solitaire_core/src/card.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write failing tests for card types** Create `solitaire_core/src/card.rs` with the tests block first, before any implementation: ```rust use serde::{Deserialize, Serialize}; // --- types added in Step 2 --- #[cfg(test)] mod tests { use super::*; #[test] fn rank_value_ace_is_one() { assert_eq!(Rank::Ace.value(), 1); } #[test] fn rank_value_king_is_thirteen() { assert_eq!(Rank::King.value(), 13); } #[test] fn rank_values_are_sequential() { let ranks = [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ]; for (i, r) in ranks.iter().enumerate() { assert_eq!(r.value(), (i + 1) as u8); } } #[test] fn suit_red_is_diamonds_and_hearts() { assert!(Suit::Diamonds.is_red()); assert!(Suit::Hearts.is_red()); assert!(!Suit::Clubs.is_red()); assert!(!Suit::Spades.is_red()); } #[test] fn suit_black_is_clubs_and_spades() { assert!(Suit::Clubs.is_black()); assert!(Suit::Spades.is_black()); assert!(!Suit::Diamonds.is_black()); assert!(!Suit::Hearts.is_black()); } #[test] fn card_starts_face_down() { let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false }; assert!(!card.face_up); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -20 ``` Expected: compile error `cannot find type 'Rank' in this scope` (or similar). - [ ] **Step 3: Implement card types** Replace the `// --- types added in Step 2 ---` comment with: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Suit { Clubs, Diamonds, Hearts, Spades, } impl Suit { /// Returns true for red suits (Diamonds, Hearts). pub fn is_red(self) -> bool { matches!(self, Suit::Diamonds | Suit::Hearts) } /// Returns true for black suits (Clubs, Spades). pub fn is_black(self) -> bool { !self.is_red() } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King, } impl Rank { /// Numeric value: Ace = 1, King = 13. pub fn value(self) -> u8 { match self { Rank::Ace => 1, Rank::Two => 2, Rank::Three => 3, Rank::Four => 4, Rank::Five => 5, Rank::Six => 6, Rank::Seven => 7, Rank::Eight => 8, Rank::Nine => 9, Rank::Ten => 10, Rank::Jack => 11, Rank::Queen => 12, Rank::King => 13, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Card { pub id: u32, pub suit: Suit, pub rank: Rank, pub face_up: bool, } ``` - [ ] **Step 4: Update lib.rs to expose the module** Replace the content of `solitaire_core/src/lib.rs` with: ```rust pub mod card; ``` - [ ] **Step 5: Run tests — expect pass** ```bash cargo test -p solitaire_core ``` Expected: `test card::tests::rank_value_ace_is_one ... ok` and all other card tests pass. - [ ] **Step 6: Run clippy** ```bash cargo clippy -p solitaire_core -- -D warnings ``` Expected: no warnings. --- ## Task 11: solitaire_core — Pile Types (TDD) **Files:** - Create: `solitaire_core/src/pile.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write tests first in pile.rs** ```rust use serde::{Deserialize, Serialize}; use crate::card::{Card, Suit}; // --- types added in Step 2 --- #[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_suit() { let p1 = PileType::Foundation(Suit::Hearts); let p2 = PileType::Foundation(Suit::Spades); assert_ne!(p1, p2); } #[test] fn pile_type_tableau_uses_index() { let p0 = PileType::Tableau(0); let p6 = PileType::Tableau(6); assert_ne!(p0, p6); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -10 ``` Expected: compile error referencing missing `Pile` or `PileType`. - [ ] **Step 3: Implement pile types** Replace `// --- types added in Step 2 ---` with: ```rust #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PileType { Stock, Waste, Foundation(Suit), Tableau(usize), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Pile { pub pile_type: PileType, pub cards: Vec, } impl Pile { 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() } } ``` - [ ] **Step 4: Add pile module to lib.rs** ```rust pub mod card; pub mod pile; ``` - [ ] **Step 5: Run tests and clippy** ```bash cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings ``` Expected: all tests pass, no warnings. --- ## Task 12: solitaire_core — MoveError (TDD) **Files:** - Create: `solitaire_core/src/error.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write tests first** ```rust use thiserror::Error; // --- type added in Step 2 --- #[cfg(test)] mod tests { use super::*; #[test] fn move_error_displays_message() { let e = MoveError::RuleViolation("king only on empty".into()); assert!(e.to_string().contains("king only on empty")); } #[test] fn move_error_undo_stack_empty_message() { let e = MoveError::UndoStackEmpty; assert!(!e.to_string().is_empty()); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -10 ``` - [ ] **Step 3: Implement MoveError** ```rust #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum MoveError { #[error("invalid source pile")] InvalidSource, #[error("invalid destination pile")] InvalidDestination, #[error("source pile is empty")] EmptySource, #[error("move violates rules: {0}")] RuleViolation(String), #[error("undo stack is empty")] UndoStackEmpty, #[error("game is already won")] GameAlreadyWon, #[error("stock and waste are both empty")] StockEmpty, } ``` - [ ] **Step 4: Add to lib.rs** ```rust pub mod card; pub mod error; pub mod pile; ``` - [ ] **Step 5: Run tests and clippy** ```bash cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings ``` Expected: all tests pass, no warnings. --- ## Task 13: solitaire_core — Deck and Deal (TDD) **Files:** - Create: `solitaire_core/src/deck.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write tests first** ```rust use rand::{seq::SliceRandom, SeedableRng}; use rand::rngs::SmallRng; use crate::card::{Card, Rank, Suit}; use crate::pile::{Pile, PileType}; // --- implementations added in Step 2 --- #[cfg(test)] mod tests { use super::*; #[test] fn deck_new_has_52_cards() { let deck = Deck::new(); assert_eq!(deck.cards.len(), 52); } #[test] fn deck_new_has_all_unique_ids() { let deck = Deck::new(); let mut ids: Vec = deck.cards.iter().map(|c| c.id).collect(); ids.dedup(); assert_eq!(ids.len(), 52); } #[test] fn deck_new_has_all_suits_and_ranks() { let deck = Deck::new(); for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for rank in [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ] { assert!( deck.cards.iter().any(|c| c.suit == suit && c.rank == rank), "missing {:?} {:?}", rank, suit ); } } } #[test] fn shuffle_same_seed_produces_same_order() { let mut d1 = Deck::new(); d1.shuffle(42); let mut d2 = Deck::new(); d2.shuffle(42); assert_eq!(d1.cards, d2.cards); } #[test] fn shuffle_different_seeds_produce_different_orders() { let mut d1 = Deck::new(); d1.shuffle(1); let mut d2 = Deck::new(); d2.shuffle(2); assert_ne!(d1.cards, d2.cards); } #[test] fn deal_klondike_produces_correct_pile_sizes() { let mut deck = Deck::new(); deck.shuffle(0); let (tableau, stock) = deal_klondike(deck); // Tableau column i has i+1 cards for (i, pile) in tableau.iter().enumerate() { assert_eq!(pile.cards.len(), i + 1, "tableau col {} wrong size", i); } // Stock has 52 - (1+2+3+4+5+6+7) = 52 - 28 = 24 cards assert_eq!(stock.cards.len(), 24); } #[test] fn deal_klondike_top_card_of_each_tableau_column_is_face_up() { let mut deck = Deck::new(); deck.shuffle(0); let (tableau, _) = deal_klondike(deck); for pile in &tableau { assert!(pile.cards.last().unwrap().face_up, "top card not face up"); } } #[test] fn deal_klondike_non_top_cards_are_face_down() { let mut deck = Deck::new(); deck.shuffle(0); let (tableau, _) = deal_klondike(deck); for pile in &tableau { let non_top = &pile.cards[..pile.cards.len().saturating_sub(1)]; for card in non_top { assert!(!card.face_up, "non-top card should be face down"); } } } #[test] fn deal_klondike_stock_cards_are_face_down() { let mut deck = Deck::new(); deck.shuffle(0); let (_, stock) = deal_klondike(deck); for card in &stock.cards { assert!(!card.face_up); } } #[test] fn deal_klondike_all_52_cards_present() { let mut deck = Deck::new(); deck.shuffle(99); let (tableau, stock) = deal_klondike(deck); let mut all_ids: Vec = stock.cards.iter().map(|c| c.id).collect(); for pile in &tableau { all_ids.extend(pile.cards.iter().map(|c| c.id)); } all_ids.sort_unstable(); assert_eq!(all_ids, (0u32..52).collect::>()); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -10 ``` - [ ] **Step 3: Implement Deck and deal_klondike** ```rust pub struct Deck { pub cards: Vec, } const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; const ALL_RANKS: [Rank; 13] = [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ]; impl Deck { pub fn new() -> Self { let mut cards = Vec::with_capacity(52); let mut id = 0u32; for &suit in &ALL_SUITS { for &rank in &ALL_RANKS { cards.push(Card { id, suit, rank, face_up: false }); id += 1; } } Self { cards } } /// Shuffle using Fisher-Yates with a seeded SmallRng for cross-platform determinism. pub fn shuffle(&mut self, seed: u64) { let mut rng = SmallRng::seed_from_u64(seed); self.cards.shuffle(&mut rng); } } impl Default for Deck { fn default() -> Self { Self::new() } } /// Deal a standard Klondike layout from a (pre-shuffled) deck. /// Returns 7 tableau piles and the remaining stock pile. /// Tableau column `i` contains `i+1` cards; only the top card is face-up. pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) { let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i))); let mut cards = deck.cards.into_iter(); for col in 0..7usize { for row in 0..=col { let mut card = cards.next().expect("deck has 52 cards"); card.face_up = row == col; tableau[col].cards.push(card); } } let mut stock = Pile::new(PileType::Stock); stock.cards.extend(cards); (tableau, stock) } ``` - [ ] **Step 4: Add to lib.rs** ```rust pub mod card; pub mod deck; pub mod error; pub mod pile; ``` - [ ] **Step 5: Run tests and clippy** ```bash cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings ``` Expected: all deck tests pass, no warnings. --- ## Task 14: solitaire_core — Move Validation Rules (TDD) **Files:** - Create: `solitaire_core/src/rules.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write failing tests** ```rust use crate::card::{Card, Rank, Suit}; use crate::pile::{Pile, PileType}; // --- functions added in Step 2 --- #[cfg(test)] mod tests { use super::*; fn make_card(suit: Suit, rank: Rank) -> Card { Card { id: 0, suit, rank, face_up: true } } fn pile_with(pile_type: PileType, cards: Vec) -> Pile { Pile { pile_type, cards } } // --- Foundation rules --- #[test] fn foundation_ace_on_empty_pile_is_valid() { let card = make_card(Suit::Hearts, Rank::Ace); let pile = Pile::new(PileType::Foundation(Suit::Hearts)); assert!(can_place_on_foundation(&card, &pile, Suit::Hearts)); } #[test] fn foundation_non_ace_on_empty_pile_is_invalid() { let card = make_card(Suit::Hearts, Rank::Two); let pile = Pile::new(PileType::Foundation(Suit::Hearts)); assert!(!can_place_on_foundation(&card, &pile, Suit::Hearts)); } #[test] fn foundation_two_on_ace_same_suit_is_valid() { let card = make_card(Suit::Clubs, Rank::Two); let pile = pile_with( PileType::Foundation(Suit::Clubs), vec![make_card(Suit::Clubs, Rank::Ace)], ); assert!(can_place_on_foundation(&card, &pile, Suit::Clubs)); } #[test] fn foundation_wrong_suit_is_invalid() { let card = make_card(Suit::Hearts, Rank::Ace); let pile = Pile::new(PileType::Foundation(Suit::Spades)); assert!(!can_place_on_foundation(&card, &pile, Suit::Spades)); } #[test] fn foundation_skipping_rank_is_invalid() { let card = make_card(Suit::Diamonds, Rank::Three); let pile = pile_with( PileType::Foundation(Suit::Diamonds), vec![make_card(Suit::Diamonds, Rank::Ace)], ); assert!(!can_place_on_foundation(&card, &pile, Suit::Diamonds)); } // --- Tableau rules --- #[test] fn tableau_king_on_empty_pile_is_valid() { let card = make_card(Suit::Hearts, Rank::King); let pile = Pile::new(PileType::Tableau(0)); assert!(can_place_on_tableau(&card, &pile)); } #[test] fn tableau_non_king_on_empty_pile_is_invalid() { let card = make_card(Suit::Hearts, Rank::Queen); let pile = Pile::new(PileType::Tableau(0)); assert!(!can_place_on_tableau(&card, &pile)); } #[test] fn tableau_red_on_black_one_lower_is_valid() { let card = make_card(Suit::Hearts, Rank::Nine); // red 9 let pile = pile_with( PileType::Tableau(0), vec![make_card(Suit::Spades, Rank::Ten)], // black 10 ); assert!(can_place_on_tableau(&card, &pile)); } #[test] fn tableau_same_color_is_invalid() { let card = make_card(Suit::Clubs, Rank::Nine); // black 9 let pile = pile_with( PileType::Tableau(0), vec![make_card(Suit::Spades, Rank::Ten)], // black 10 ); assert!(!can_place_on_tableau(&card, &pile)); } #[test] fn tableau_wrong_rank_difference_is_invalid() { let card = make_card(Suit::Hearts, Rank::Eight); // red 8 let pile = pile_with( PileType::Tableau(0), vec![make_card(Suit::Spades, Rank::Ten)], // black 10 ); assert!(!can_place_on_tableau(&card, &pile)); } #[test] fn tableau_black_on_red_one_lower_is_valid() { let card = make_card(Suit::Clubs, Rank::Six); // black 6 let pile = pile_with( PileType::Tableau(0), vec![make_card(Suit::Hearts, Rank::Seven)], // red 7 ); assert!(can_place_on_tableau(&card, &pile)); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -10 ``` - [ ] **Step 3: Implement rules** ```rust use crate::card::{Card, Suit}; use crate::pile::Pile; /// Can `card` be placed on the foundation pile for `suit`? 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, // Only Ace starts a foundation Some(top) => card.rank.value() == top.rank.value() + 1, } } /// Can `card` (or the bottom card of a sequence) be placed on `pile` in the tableau? pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool { match pile.cards.last() { None => card.rank.value() == 13, // Only King goes on empty tableau Some(top) => { card.rank.value() + 1 == top.rank.value() && card.suit.is_red() != top.suit.is_red() } } } ``` - [ ] **Step 4: Add to lib.rs** ```rust pub mod card; pub mod deck; pub mod error; pub mod pile; pub mod rules; ``` - [ ] **Step 5: Run tests and clippy** ```bash cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings ``` Expected: all rule tests pass, no warnings. --- ## Task 15: solitaire_core — Scoring (TDD) **Files:** - Create: `solitaire_core/src/scoring.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write failing tests** ```rust use crate::pile::PileType; use crate::card::Suit; // --- functions added in Step 2 --- #[cfg(test)] mod tests { use super::*; #[test] fn move_to_foundation_scores_ten() { assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10); assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 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_is_7000() { assert_eq!(compute_time_bonus(100), 7000); } #[test] fn time_bonus_at_zero_seconds_is_zero() { assert_eq!(compute_time_bonus(0), 0); } #[test] fn time_bonus_at_one_second_is_capped_at_i32_max() { // 700_000 / 1 = 700_000 which fits in i32 fine assert_eq!(compute_time_bonus(1), 700_000); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -10 ``` - [ ] **Step 3: Implement scoring functions** ```rust use crate::pile::PileType; /// Returns the score delta for moving cards from `from` to `to`. /// Windows XP Standard scoring: /// +10 for any card reaching the foundation /// +5 for waste → tableau /// 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. pub fn score_undo() -> i32 { -15 } /// Time bonus added to score on win: 700_000 / elapsed_seconds. /// Returns 0 if elapsed_seconds is 0 (avoids 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 } ``` - [ ] **Step 4: Add to lib.rs** ```rust pub mod card; pub mod deck; pub mod error; pub mod pile; pub mod rules; pub mod scoring; ``` - [ ] **Step 5: Run tests and clippy** ```bash cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings ``` Expected: all scoring tests pass, no warnings. --- ## Task 16: solitaire_core — GameState (TDD) **Files:** - Create: `solitaire_core/src/game_state.rs` - Modify: `solitaire_core/src/lib.rs` - [ ] **Step 1: Write failing tests** ```rust use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::card::{Card, Rank, Suit}; use crate::deck::{deal_klondike, Deck}; use crate::error::MoveError; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau}; use crate::scoring::{compute_time_bonus, score_move, score_undo}; // --- types and implementations added in Steps 2-4 --- #[cfg(test)] mod tests { use super::*; fn new_game() -> GameState { GameState::new(42, DrawMode::DrawOne) } // --- Initial state --- #[test] fn new_game_has_28_tableau_cards() { let g = new_game(); let total: usize = (0..7).map(|i| g.piles[&PileType::Tableau(i)].cards.len()).sum(); assert_eq!(total, 28); } #[test] fn new_game_stock_has_24_cards() { let g = new_game(); assert_eq!(g.piles[&PileType::Stock].cards.len(), 24); } #[test] fn new_game_waste_is_empty() { let g = new_game(); assert!(g.piles[&PileType::Waste].cards.is_empty()); } #[test] fn new_game_foundations_are_empty() { let g = new_game(); for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty()); } } #[test] fn new_game_is_not_won() { let g = new_game(); assert!(!g.is_won); } // --- Seeded reproducibility --- #[test] fn same_seed_produces_identical_layout() { let g1 = GameState::new(12345, DrawMode::DrawOne); let g2 = GameState::new(12345, DrawMode::DrawOne); for i in 0..7 { assert_eq!( g1.piles[&PileType::Tableau(i)].cards, g2.piles[&PileType::Tableau(i)].cards ); } assert_eq!( g1.piles[&PileType::Stock].cards, g2.piles[&PileType::Stock].cards ); } #[test] fn different_seeds_produce_different_layouts() { let g1 = GameState::new(1, DrawMode::DrawOne); let g2 = GameState::new(2, DrawMode::DrawOne); // Almost certainly different (statistically) let t1: Vec = g1.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect(); let t2: Vec = g2.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect(); assert_ne!(t1, t2); } // --- Draw --- #[test] fn draw_one_moves_one_card_to_waste() { let mut g = new_game(); let stock_before = g.piles[&PileType::Stock].cards.len(); g.draw().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before - 1); assert_eq!(g.piles[&PileType::Waste].cards.len(), 1); } #[test] fn drawn_card_is_face_up() { let mut g = new_game(); g.draw().unwrap(); assert!(g.piles[&PileType::Waste].cards.last().unwrap().face_up); } #[test] fn draw_three_moves_up_to_three_cards() { let mut g = GameState::new(42, DrawMode::DrawThree); g.draw().unwrap(); assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); assert_eq!(g.piles[&PileType::Stock].cards.len(), 21); } #[test] fn draw_from_empty_stock_recycles_waste() { let mut g = new_game(); // Exhaust stock while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } let waste_count = g.piles[&PileType::Waste].cards.len(); assert!(waste_count > 0); // Drawing again should recycle g.draw().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); assert!(g.piles[&PileType::Waste].cards.is_empty()); } #[test] fn draw_from_empty_stock_and_waste_returns_error() { let mut g = new_game(); while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } g.draw().unwrap(); // recycle while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); } // Now both are empty let result = g.draw(); assert_eq!(result, Err(MoveError::StockEmpty)); } // --- Move validation --- #[test] fn move_face_down_card_returns_rule_violation() { let mut g = new_game(); // Tableau(0) has 1 card (face up). Tableau(1) has 2 cards, bottom is face down. // Try to move the face-down card (index 0 of Tableau(1)) let result = g.move_cards(PileType::Tableau(1), PileType::Tableau(0), 2); // Bottom card of Tableau(1) is face-down; this should be a rule violation // (unless by coincidence the move is valid, which is fine too — test intent is no panic) // We just verify it either succeeds or returns a rule violation, never panics. let _ = result; } #[test] fn move_zero_cards_returns_rule_violation() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 0); assert!(matches!(result, Err(MoveError::RuleViolation(_)))); } #[test] fn move_to_stock_returns_invalid_destination() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Stock, 1); assert_eq!(result, Err(MoveError::InvalidDestination)); } #[test] fn move_to_waste_returns_invalid_destination() { let mut g = new_game(); let result = g.move_cards(PileType::Tableau(0), PileType::Waste, 1); assert_eq!(result, Err(MoveError::InvalidDestination)); } // --- Win detection --- #[test] fn win_detection_all_foundations_complete() { let mut g = new_game(); // Fill all foundations manually for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { g.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); for rank in [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ] { g.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.push( Card { id: 0, suit, rank, face_up: true } ); } } assert!(g.check_win()); } #[test] fn win_detection_incomplete_foundations_is_false() { let g = new_game(); assert!(!g.check_win()); } // --- Undo --- #[test] fn undo_empty_stack_returns_error() { let mut g = new_game(); assert_eq!(g.undo(), Err(MoveError::UndoStackEmpty)); } #[test] fn undo_after_draw_restores_pile_sizes() { let mut g = new_game(); let stock_before = g.piles[&PileType::Stock].cards.len(); let waste_before = g.piles[&PileType::Waste].cards.len(); g.draw().unwrap(); g.undo().unwrap(); assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); assert_eq!(g.piles[&PileType::Waste].cards.len(), waste_before); } #[test] fn undo_applies_score_penalty() { let mut g = new_game(); let score_before = g.score; g.draw().unwrap(); g.undo().unwrap(); // Score = score_before + score_undo() = score_before - 15, floored at 0 let expected = (score_before + score_undo()).max(0); assert_eq!(g.score, expected); } #[test] fn undo_stack_capped_at_64() { let mut g = new_game(); // Perform 70 draws (stock will recycle as needed) for _ in 0..70 { let _ = g.draw(); } // Undo stack should not exceed 64 entries assert!(g.undo_stack_len() <= 64); } // --- Scoring --- #[test] fn score_does_not_go_below_zero() { let mut g = new_game(); // Apply undo penalty repeatedly; score should floor at 0 for _ in 0..5 { g.draw().unwrap(); g.undo().unwrap(); } assert!(g.score >= 0); } // --- Auto-complete --- #[test] fn auto_complete_false_when_stock_not_empty() { let g = new_game(); assert!(!g.check_auto_complete()); } #[test] fn auto_complete_false_when_face_down_cards_remain() { let mut g = new_game(); // Empty stock and waste but leave face-down cards in tableau g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Tableau(1) has a face-down card at index 0 assert!(!g.check_auto_complete()); } // --- Time bonus --- #[test] fn time_bonus_is_zero_when_elapsed_is_zero() { let mut g = new_game(); g.elapsed_seconds = 0; assert_eq!(g.compute_time_bonus(), 0); } #[test] fn time_bonus_at_100_seconds() { let mut g = new_game(); g.elapsed_seconds = 100; assert_eq!(g.compute_time_bonus(), 7000); } } ``` - [ ] **Step 2: Run tests — expect compile failure** ```bash cargo test -p solitaire_core 2>&1 | head -10 ``` - [ ] **Step 3: Implement GameState types** Create `solitaire_core/src/game_state.rs` with full content: ```rust use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::card::{Card, Suit}; use crate::deck::{deal_klondike, Deck}; use crate::error::MoveError; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau}; use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo}; const MAX_UNDO_STACK: usize = 64; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DrawMode { DrawOne, DrawThree, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateSnapshot { piles: HashMap, score: i32, move_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameState { pub piles: HashMap, pub draw_mode: DrawMode, pub score: i32, pub move_count: u32, pub elapsed_seconds: u64, pub seed: u64, pub is_won: bool, pub is_auto_completable: bool, pub(crate) undo_stack: Vec, } impl GameState { pub fn new(seed: u64, draw_mode: DrawMode) -> Self { let mut deck = Deck::new(); deck.shuffle(seed); let (tableau, stock) = deal_klondike(deck); let mut piles: HashMap = HashMap::new(); piles.insert(PileType::Stock, stock); piles.insert(PileType::Waste, Pile::new(PileType::Waste)); for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit))); } for (i, pile) in tableau.into_iter().enumerate() { piles.insert(PileType::Tableau(i), pile); } Self { piles, draw_mode, score: 0, move_count: 0, elapsed_seconds: 0, seed, is_won: false, is_auto_completable: false, undo_stack: Vec::new(), } } /// Returns the number of snapshots on the undo stack (for testing). pub fn undo_stack_len(&self) -> usize { self.undo_stack.len() } fn take_snapshot(&self) -> StateSnapshot { StateSnapshot { piles: self.piles.clone(), score: self.score, move_count: self.move_count, } } fn push_snapshot(&mut self) { if self.undo_stack.len() >= MAX_UNDO_STACK { self.undo_stack.remove(0); } self.undo_stack.push(self.take_snapshot()); } /// Draw from stock to waste. Recycles waste to stock when stock is empty. pub fn draw(&mut self) -> Result<(), MoveError> { if self.is_won { return Err(MoveError::GameAlreadyWon); } let stock_len = self.piles[&PileType::Stock].cards.len(); if stock_len == 0 { let waste_len = self.piles[&PileType::Waste].cards.len(); if waste_len == 0 { return Err(MoveError::StockEmpty); } // Recycle: reverse waste back onto stock, face-down let waste_cards: Vec = self.piles .get_mut(&PileType::Waste) .unwrap() .cards .drain(..) .collect(); let stock = self.piles.get_mut(&PileType::Stock).unwrap(); for mut card in waste_cards.into_iter().rev() { card.face_up = false; stock.cards.push(card); } return Ok(()); } self.push_snapshot(); let draw_count = match self.draw_mode { DrawMode::DrawOne => 1, DrawMode::DrawThree => 3, }; let available = stock_len.min(draw_count); let drain_start = stock_len - available; let drawn: Vec = self.piles .get_mut(&PileType::Stock) .unwrap() .cards .drain(drain_start..) .collect(); let waste = self.piles.get_mut(&PileType::Waste).unwrap(); for mut card in drawn { card.face_up = true; waste.cards.push(card); } self.move_count += 1; Ok(()) } /// Move `count` cards from pile `from` to pile `to`. pub fn move_cards(&mut self, from: PileType, to: PileType, count: usize) -> Result<(), MoveError> { if self.is_won { return Err(MoveError::GameAlreadyWon); } if from == to { return Err(MoveError::RuleViolation("source and destination must differ".into())); } // Validate (immutable borrows scoped here) let move_start = { let from_pile = self.piles.get(&from).ok_or(MoveError::InvalidSource)?; if from_pile.cards.is_empty() { return Err(MoveError::EmptySource); } if count == 0 || count > from_pile.cards.len() { return Err(MoveError::RuleViolation("invalid card count".into())); } let start = from_pile.cards.len() - count; for card in &from_pile.cards[start..] { if !card.face_up { return Err(MoveError::RuleViolation("cannot move face-down card".into())); } } let bottom_card = from_pile.cards[start].clone(); match &to { PileType::Foundation(suit) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), )); } let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; if !can_place_on_foundation(&bottom_card, dest, *suit) { return Err(MoveError::RuleViolation("invalid foundation placement".into())); } } PileType::Tableau(_) => { 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())); } } _ => return Err(MoveError::InvalidDestination), } start }; let score_delta = score_move(&from, &to); self.push_snapshot(); // Execute move let mut moved: Vec = self.piles .get_mut(&from) .unwrap() .cards .split_off(move_start); // Flip the newly exposed top card of the source pile if let Some(top) = self.piles.get_mut(&from).unwrap().cards.last_mut() { if !top.face_up { top.face_up = true; } } self.piles.get_mut(&to).unwrap().cards.append(&mut moved); self.score = (self.score + score_delta).max(0); self.move_count += 1; self.is_won = self.check_win(); if !self.is_won { self.is_auto_completable = self.check_auto_complete(); } Ok(()) } /// Restore the most recent snapshot and apply the undo score penalty. pub fn undo(&mut self) -> Result<(), MoveError> { if self.is_won { return Err(MoveError::GameAlreadyWon); } let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?; self.piles = snapshot.piles; self.score = (snapshot.score + scoring_undo()).max(0); self.move_count = snapshot.move_count; self.is_won = false; self.is_auto_completable = false; Ok(()) } /// Returns true when all four foundations have 13 cards. pub fn check_win(&self) -> bool { [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] .iter() .all(|&suit| { self.piles .get(&PileType::Foundation(suit)) .map_or(false, |p| p.cards.len() == 13) }) } /// Returns true when stock and waste are empty AND all tableau cards are face-up. /// At that point the player can auto-complete without any input. pub fn check_auto_complete(&self) -> bool { if !self.piles[&PileType::Stock].cards.is_empty() { return false; } if !self.piles[&PileType::Waste].cards.is_empty() { return false; } (0..7).all(|i| { self.piles[&PileType::Tableau(i)] .cards .iter() .all(|c| c.face_up) }) } /// Time bonus added to score on win: 700_000 / elapsed_seconds (0 if elapsed is 0). pub fn compute_time_bonus(&self) -> i32 { scoring_time_bonus(self.elapsed_seconds) } } ``` - [ ] **Step 4: Add to lib.rs** ```rust pub mod card; pub mod deck; pub mod error; pub mod game_state; pub mod pile; pub mod rules; pub mod scoring; ``` - [ ] **Step 5: Run all tests** ```bash cargo test -p solitaire_core ``` Expected: all tests in `card`, `pile`, `error`, `deck`, `rules`, `scoring`, and `game_state` modules pass. - [ ] **Step 6: Run clippy** ```bash cargo clippy -p solitaire_core -- -D warnings ``` Expected: zero warnings. --- ## Task 17: Phase 2 Full Workspace Gate - [ ] **Step 1: Run full workspace test suite** ```bash cargo test --workspace ``` Expected: all tests pass. The non-core crates have no tests yet so the count is small — that is fine. - [ ] **Step 2: Run full workspace clippy** ```bash cargo clippy --workspace -- -D warnings ``` Expected: zero warnings across all seven crates. - [ ] **Step 3: Verify blank Bevy window still opens** ```bash cargo run -p solitaire_app --features bevy/dynamic_linking ``` Expected: window opens, no panics. - [ ] **Step 4: Commit Phase 2** ```bash git add solitaire_core/src/ git commit -m "feat(core): complete Klondike game logic with full test coverage" ``` --- ## Self-Review Checklist ### Spec coverage | Spec requirement | Covered by task | |---|---| | 7-crate workspace | Tasks 1–8 | | Fast compile settings in Cargo.toml | Task 1 | | assets/ directory structure | Task 9 | | Blank Bevy window | Task 8 | | cargo run opens window | Task 8 step 3 | | GPGS compile-time stub | Task 7 | | GpgsClient implements SyncProvider | Task 7 step 3 | | .env.example | Task 9 step 2 | | Suit, Rank, Card types | Task 10 | | PileType, Pile types | Task 11 | | MoveError enum | Task 12 | | Deck::new(), Deck::shuffle(seed) | Task 13 | | deal_klondike() Klondike layout | Task 13 | | Move validation (legal + illegal) | Tasks 14, 16 | | Scoring per move type | Task 15 | | Time bonus formula | Task 15 | | Undo (restore state, -15 penalty) | Task 16 | | Undo stack capped at 64 | Task 16 | | Win detection | Task 16 | | Auto-complete detection | Task 16 | | Seeded deal reproducibility | Tasks 13, 16 | | cargo test --workspace passes | Task 17 | | cargo clippy --workspace -D warnings passes | Task 17 | ### Gaps / Notes - `apply_auto_complete()` (iterates foundations to completion) is not implemented — it is used by Phase 3 (Bevy rendering). Adding it now would require borrow complexity with no test driver. It belongs in the Phase 3 plan. - `solitaire_sync` types are minimal stubs. Full fields (`StatsSnapshot`, `PlayerProgress`, etc.) are added in Phase 8. - `solitaire_data` has `SyncProvider` trait only. `StatsSnapshot`, `PlayerProgress`, persistence code are added in Phase 4. - Bevy version numbers in `Cargo.toml` may need updating to current stable — check `crates.io/crates/bevy` at implementation time.