Files
Ferrous-Solitaire/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md
T
funman300 8325bf6cf7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:04:45 -07:00

59 KiB
Raw Blame History

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 38 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

[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
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

[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)
// Modules are added in Phase 2. This file re-exports them.
  • Step 3: Verify it compiles
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

[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
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<Utc>,
}

/// Response returned by the sync server after merging.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResponse {
    pub server_time: DateTime<Utc>,
}

These are minimal stubs. Full fields are added in Phase 8 (Sync System).

  • Step 3: Verify
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

[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
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<SyncPayload, SyncError>;
    async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
    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
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

[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
// Bevy plugins are added in Phase 3.
// This crate will expose: CardPlugin, TablePlugin, AnimationPlugin,
// AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin.
  • Step 3: Verify
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

[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
// Full server implementation added in Phase 8C.
fn main() {}
  • Step 3: Verify
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

[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
#[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:

#[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
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<SyncPayload, SyncError> {
        Err(SyncError::UnsupportedPlatform)
    }

    async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
        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
// 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
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

[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
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
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

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
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
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:

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
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:

#[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:

pub mod card;
  • Step 5: Run tests — expect pass
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
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

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
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:

#[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<Card>,
}

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
pub mod card;
pub mod pile;
  • Step 5: Run tests and clippy
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

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
cargo test -p solitaire_core 2>&1 | head -10
  • Step 3: Implement MoveError
#[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
pub mod card;
pub mod error;
pub mod pile;
  • Step 5: Run tests and clippy
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

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<u32> = 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<u32> = 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::<Vec<_>>());
    }
}
  • Step 2: Run tests — expect compile failure
cargo test -p solitaire_core 2>&1 | head -10
  • Step 3: Implement Deck and deal_klondike
pub struct Deck {
    pub cards: Vec<Card>,
}

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
pub mod card;
pub mod deck;
pub mod error;
pub mod pile;
  • Step 5: Run tests and clippy
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

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<Card>) -> 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
cargo test -p solitaire_core 2>&1 | head -10
  • Step 3: Implement rules
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
pub mod card;
pub mod deck;
pub mod error;
pub mod pile;
pub mod rules;
  • Step 5: Run tests and clippy
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

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
cargo test -p solitaire_core 2>&1 | head -10
  • Step 3: Implement scoring functions
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
pub mod card;
pub mod deck;
pub mod error;
pub mod pile;
pub mod rules;
pub mod scoring;
  • Step 5: Run tests and clippy
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

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<u32> = g1.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect();
        let t2: Vec<u32> = 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
cargo test -p solitaire_core 2>&1 | head -10
  • Step 3: Implement GameState types

Create solitaire_core/src/game_state.rs with full content:

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<PileType, Pile>,
    score: i32,
    move_count: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameState {
    pub piles: HashMap<PileType, Pile>,
    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<StateSnapshot>,
}

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<PileType, Pile> = 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<Card> = 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<Card> = 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<Card> = 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
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
cargo test -p solitaire_core

Expected: all tests in card, pile, error, deck, rules, scoring, and game_state modules pass.

  • Step 6: Run clippy
cargo clippy -p solitaire_core -- -D warnings

Expected: zero warnings.


Task 17: Phase 2 Full Workspace Gate

  • Step 1: Run full workspace test suite
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
cargo clippy --workspace -- -D warnings

Expected: zero warnings across all seven crates.

  • Step 3: Verify blank Bevy window still opens
cargo run -p solitaire_app --features bevy/dynamic_linking

Expected: window opens, no panics.

  • Step 4: Commit Phase 2
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 18
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.