From f84d7c5849031f026b7c420b11527b0e8a692e56 Mon Sep 17 00:00:00 2001 From: Solitaire Quest Date: Thu, 23 Apr 2026 11:04:15 -0700 Subject: [PATCH] fix(workspace): add derives/docs per code review, remove unused thiserror from solitaire_sync --- Cargo.lock | 1 - .../2026-04-20-phase1-2-workspace-core.md | 2170 +++++++++++++++++ solitaire_data/src/lib.rs | 6 + solitaire_gpgs/src/stub.rs | 7 +- solitaire_sync/Cargo.toml | 1 - solitaire_sync/src/lib.rs | 4 +- 6 files changed, 2183 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md diff --git a/Cargo.lock b/Cargo.lock index 163854f..38873fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6815,7 +6815,6 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror 1.0.69", "uuid", ] diff --git a/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md b/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md new file mode 100644 index 0000000..5dafe5b --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md @@ -0,0 +1,2170 @@ +# Solitaire Quest — 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: "Solitaire Quest".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 "Solitaire Quest" 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. diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 5d28020..e12df86 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -7,6 +7,8 @@ use thiserror::Error; pub enum SyncError { #[error("unsupported platform for this sync backend")] UnsupportedPlatform, + // TODO: Replace String with concrete source error types (e.g. reqwest::Error, + // serde_json::Error) when real implementations are added in Phase 8. #[error("network error: {0}")] Network(String), #[error("authentication error: {0}")] @@ -19,9 +21,13 @@ pub enum SyncError { /// methods — it never matches on a backend enum variant. #[async_trait] pub trait SyncProvider: Send + Sync { + /// Fetch the remote sync payload. Returns the latest server state for merging. async fn pull(&self) -> Result; + /// Push the local payload to the backend. Returns the merged server response. async fn push(&self, payload: &SyncPayload) -> Result; + /// Human-readable name of this backend, used in settings UI and logs. fn backend_name(&self) -> &'static str; + /// Returns true if the user is currently authenticated with this backend. 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> { diff --git a/solitaire_gpgs/src/stub.rs b/solitaire_gpgs/src/stub.rs index 00431d5..f5097c5 100644 --- a/solitaire_gpgs/src/stub.rs +++ b/solitaire_gpgs/src/stub.rs @@ -2,11 +2,14 @@ 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). +/// Google Play Games Services sync client — desktop/iOS stub. +/// +/// Always returns [`SyncError::UnsupportedPlatform`]. The real JNI implementation +/// lives in `android.rs` and is compiled only on Android (Phase: Android). pub struct GpgsClient; impl GpgsClient { + /// Creates a new `GpgsClient` stub. No-op on non-Android platforms. pub fn new() -> Self { Self } diff --git a/solitaire_sync/Cargo.toml b/solitaire_sync/Cargo.toml index c2686cd..f2d1853 100644 --- a/solitaire_sync/Cargo.toml +++ b/solitaire_sync/Cargo.toml @@ -8,4 +8,3 @@ serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } -thiserror = { workspace = true } diff --git a/solitaire_sync/src/lib.rs b/solitaire_sync/src/lib.rs index f9b0e51..d971757 100644 --- a/solitaire_sync/src/lib.rs +++ b/solitaire_sync/src/lib.rs @@ -4,14 +4,14 @@ use uuid::Uuid; /// Payload sent from client to server (and returned after server merge). /// Full fields are added in Phase 8 (Sync System). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, 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)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SyncResponse { pub server_time: DateTime, }