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:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
*.tmp
|
||||
+1063
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+56
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1 @@
|
||||
// Game logic modules are added in Phase 2.
|
||||
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,3 @@
|
||||
// Bevy plugins are added in Phase 3.
|
||||
// This crate will expose: CardPlugin, TablePlugin, AnimationPlugin,
|
||||
// AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin.
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
// Full server implementation added in Phase 8C.
|
||||
fn main() {}
|
||||
@@ -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 }
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user