feat(workspace): initialize all seven crates with stubs and blank Bevy window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Solitaire Quest
2026-04-23 11:00:42 -07:00
commit 684f07746d
27 changed files with 11000 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URL=sqlite://solitaire.db
JWT_SECRET=replace_with_64_char_hex_from_openssl_rand_hex_32
SERVER_PORT=8080
ADMIN_USERNAME=admin
+6
View File
@@ -0,0 +1,6 @@
/target
*.db
*.db-shm
*.db-wal
.env
*.tmp
+1063
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
# Solitaire Quest — Claude Code Instructions
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
---
## Project Layout
```
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
solitaire_data/ # Persistence + SyncProvider trait + server client
solitaire_engine/ # Bevy ECS systems, components, plugins
solitaire_server/ # Axum sync server binary
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
solitaire_app/ # Thin binary entry point
assets/ # Loaded at runtime via Bevy AssetServer only
```
---
## Build & Test Commands
```bash
# Dev run (fast compile via dynamic linking)
cargo run -p solitaire_app --features bevy/dynamic_linking
# Release build
cargo build --workspace --release
# All tests — MUST pass before any commit
cargo test --workspace
# Lint — MUST pass clean (zero warnings)
cargo clippy --workspace -- -D warnings
# Run sync server locally
cargo run -p solitaire_server
# Check a single crate
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
```
---
## Hard Rules
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- No hardcoded bytes in source. All assets go through Bevy's `AssetServer`.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
- `solitaire_gpgs` is a stub until Android work begins. Do not write JNI bindings yet; keep the compile-time stub so the trait contract is enforced from day one.
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change.
---
## Code Style
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
- Prefer `Into<T>` over concrete types in public API function parameters.
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
---
## Bevy Conventions
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
- Resources own shared state. Events communicate between systems. Components own per-entity data.
- All egui screens live in `solitaire_engine::ui`. Never mix egui and Bevy spawn logic in the same system.
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
---
## Git Workflow
- Commit after each passing phase, not after every file change.
- Commit message format: `type(scope): description`
- `feat(core): add draw-three mode validation`
- `fix(engine): card z-order during drag`
- `test(core): undo stack boundary conditions`
- `chore(server): add sqlx migration 002`
- Never commit with failing tests or clippy warnings.
- Never commit secrets, `.env` files, or `*.db` files.
---
## Ask Before Doing
- Adding a new crate dependency (discuss alternatives first).
- Changing a type in `solitaire_sync` (breaking change on both client and server).
- Altering the database schema (requires a new sqlx migration).
- Introducing `unsafe` code anywhere.
- Changing the merge strategy in `solitaire_sync::merge()`.
---
## Lessons Learned
> Add entries here when Claude makes a mistake so it isn't repeated.
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
Generated
+9533
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
[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]
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 }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
bevy = "0.15"
bevy_egui = "0.30"
bevy_kira_audio = "0.21"
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"
View File
View File
View File
View File
View File
+12
View File
@@ -0,0 +1,12 @@
[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 }
+16
View File
@@ -0,0 +1,16 @@
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
resolution: (1280.0, 800.0).into(),
..default()
}),
..default()
}),
)
.run();
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "solitaire_core"
version.workspace = true
edition.workspace = true
[dependencies]
serde = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
rand = { workspace = true }
+1
View File
@@ -0,0 +1 @@
// Game logic modules are added in Phase 2.
+17
View File
@@ -0,0 +1,17 @@
[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 }
+30
View File
@@ -0,0 +1,30 @@
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(())
}
}
+11
View File
@@ -0,0 +1,11 @@
[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 }
+3
View File
@@ -0,0 +1,3 @@
// Bevy plugins are added in Phase 3.
// This crate will expose: CardPlugin, TablePlugin, AnimationPlugin,
// AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin.
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "solitaire_gpgs"
version.workspace = true
edition.workspace = true
[dependencies]
solitaire_data = { workspace = true }
solitaire_sync = { workspace = true }
async-trait = { workspace = true }
+12
View File
@@ -0,0 +1,12 @@
// 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)
+11
View File
@@ -0,0 +1,11 @@
#[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;
+38
View File
@@ -0,0 +1,38 @@
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
}
}
+25
View File
@@ -0,0 +1,25 @@
[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 }
+2
View File
@@ -0,0 +1,2 @@
// Full server implementation added in Phase 8C.
fn main() {}
+11
View File
@@ -0,0 +1,11 @@
[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 }
+17
View File
@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Payload sent from client to server (and returned after server merge).
/// Full fields are added in Phase 8 (Sync System).
#[derive(Debug, Clone, Serialize, Deserialize)]
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>,
}