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