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>
59 KiB
Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bootstrap the Cargo workspace with all seven crates compiling cleanly, a blank Bevy window opening, and the complete Klondike game logic in solitaire_core fully tested.
Architecture: All seven crates are created with the correct dependency graph. solitaire_core contains zero Bevy/network code — pure Rust game rules, scoring, and undo. The GPGS crate is a compile-time stub enforcing the trait contract. Bevy 0.15 is used for the blank window; version may need bumping to match current stable at implementation time.
Tech Stack: Rust 2021 edition, Cargo workspace, Bevy 0.15, bevy_egui, bevy_kira_audio, rand 0.8, serde 1, chrono 0.4, thiserror 1, async-trait 0.1
Scope
This plan covers Phase 1 (workspace + blank Bevy window + GPGS stub) and Phase 2 (complete solitaire_core game logic with tests). Phases 3–8 are out of scope and should be planned separately after these phases pass all gates.
File Map
Created in Phase 1
| File | Purpose |
|---|---|
Cargo.toml |
Workspace manifest with profile settings and shared deps |
solitaire_core/Cargo.toml |
Core crate manifest (rand, serde, chrono, thiserror) |
solitaire_core/src/lib.rs |
Re-exports all public modules |
solitaire_sync/Cargo.toml |
Sync types manifest (serde, uuid, chrono) |
solitaire_sync/src/lib.rs |
Minimal stub: SyncPayload, SyncResponse |
solitaire_data/Cargo.toml |
Data crate manifest (solitaire_core, solitaire_sync, async-trait, thiserror) |
solitaire_data/src/lib.rs |
Minimal stub: SyncError, SyncProvider trait |
solitaire_engine/Cargo.toml |
Engine manifest (bevy, bevy_egui, bevy_kira_audio, solitaire_core, solitaire_data) |
solitaire_engine/src/lib.rs |
Empty stub |
solitaire_server/Cargo.toml |
Server manifest (solitaire_sync, axum, sqlx, etc.) |
solitaire_server/src/main.rs |
Stub fn main() {} |
solitaire_gpgs/Cargo.toml |
GPGS manifest (solitaire_data, async-trait) |
solitaire_gpgs/src/lib.rs |
cfg-gated re-exports |
solitaire_gpgs/src/stub.rs |
Desktop stub implementing SyncProvider |
solitaire_gpgs/src/android.rs |
Android phase TODO placeholder |
solitaire_app/Cargo.toml |
App manifest (bevy, solitaire_engine) |
solitaire_app/src/main.rs |
Bevy App::new() opening blank window |
assets/cards/faces/.gitkeep |
Placeholder |
assets/cards/backs/.gitkeep |
Placeholder |
assets/backgrounds/.gitkeep |
Placeholder |
assets/fonts/.gitkeep |
Placeholder |
assets/audio/.gitkeep |
Placeholder |
.env.example |
Server environment variable template |
Created/expanded in Phase 2
| File | Purpose |
|---|---|
solitaire_core/src/card.rs |
Suit, Rank, Card types |
solitaire_core/src/pile.rs |
PileType, Pile types |
solitaire_core/src/error.rs |
MoveError enum |
solitaire_core/src/deck.rs |
Deck::new(), Deck::shuffle(), deal_klondike() |
solitaire_core/src/rules.rs |
can_place_on_foundation(), can_place_on_tableau() |
solitaire_core/src/scoring.rs |
score_move(), score_undo(), compute_time_bonus() |
solitaire_core/src/game_state.rs |
GameState, DrawMode, StateSnapshot |
Task 1: Workspace Cargo.toml
Files:
-
Create:
Cargo.toml -
Step 1: Create the workspace Cargo.toml
[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", andbevy_kira_audio = "0.21"were compatible as of early 2025. Runcargo search bevyto 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 aGpgsClientstruct 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 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_synctypes are minimal stubs. Full fields (StatsSnapshot,PlayerProgress, etc.) are added in Phase 8.solitaire_datahasSyncProvidertrait only.StatsSnapshot,PlayerProgress, persistence code are added in Phase 4.- Bevy version numbers in
Cargo.tomlmay need updating to current stable — checkcrates.io/crates/bevyat implementation time.