docs(architecture): bring source-of-truth docs to post-migration reality #133
+71
-39
@@ -1,9 +1,11 @@
|
|||||||
# Ferrous Solitaire — Architecture Document
|
# Ferrous Solitaire — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.3
|
> **Version:** 1.4
|
||||||
> **Language:** Rust (Edition 2024)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-05-12
|
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
|
||||||
|
> core card/pile types come from the upstream `card_game` workspace and
|
||||||
|
> score/undo/recycle are derived from the upstream session, not stored.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,13 +84,15 @@ ferrous_solitaire/
|
|||||||
│ ├── win_fanfare.wav
|
│ ├── win_fanfare.wav
|
||||||
│ └── ambient_loop.wav
|
│ └── ambient_loop.wav
|
||||||
│
|
│
|
||||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
|
||||||
├── solitaire_sync/ # Shared API types — used by client and server
|
├── solitaire_sync/ # Shared API types — used by client and server
|
||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
|
||||||
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
|
||||||
└── solitaire_app/ # Main binary entry point
|
├── solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||||
|
├── solitaire_assetgen/ # One-shot generator for card/background PNG assets
|
||||||
|
└── solitaire_app/ # Main binary entry point (desktop + Android cdylib)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -96,18 +100,30 @@ ferrous_solitaire/
|
|||||||
## 3. Crate Responsibilities
|
## 3. Crate Responsibilities
|
||||||
|
|
||||||
### `solitaire_core`
|
### `solitaire_core`
|
||||||
**Dependencies:** `rand`, `serde`, `chrono` only.
|
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
|
||||||
|
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
|
||||||
|
|
||||||
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
|
The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
|
||||||
|
in isolation with `cargo test -p solitaire_core`.
|
||||||
|
|
||||||
|
Since the card_game migration (2026-06-22, PR #88) the primitive types are
|
||||||
|
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
|
||||||
|
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
|
||||||
|
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
|
||||||
|
so downstream crates import from one place and never depend on the upstream
|
||||||
|
crates directly.
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
|
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
|
||||||
- Move validation logic
|
is the single source of truth for board state and stats
|
||||||
- Scoring engine
|
- `MoveError` and the `Result`-based mutation API
|
||||||
- Undo stack
|
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
|
||||||
|
take-from-foundation
|
||||||
|
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
|
||||||
|
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
|
||||||
- Win / auto-complete detection
|
- Win / auto-complete detection
|
||||||
- Achievement unlock condition evaluation
|
- Achievement unlock condition evaluation
|
||||||
- Seeded RNG for reproducible deals
|
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)
|
||||||
|
|
||||||
**Rules decisions:**
|
**Rules decisions:**
|
||||||
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
||||||
@@ -189,9 +205,13 @@ Owns:
|
|||||||
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||||
|
|
||||||
### `solitaire_app`
|
### `solitaire_app`
|
||||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
|
||||||
|
|
||||||
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
|
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
|
||||||
|
plugins and sets initial window properties. The one crate in the workspace
|
||||||
|
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
|
||||||
|
hands them to the safe `solitaire_data::android_jni` bridge; everything else
|
||||||
|
is `forbid(unsafe_code)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -559,26 +579,32 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
|||||||
|
|
||||||
### Core Game Models (`solitaire_core`)
|
### Core Game Models (`solitaire_core`)
|
||||||
|
|
||||||
|
Since the card_game migration, the primitives are upstream types re-exported
|
||||||
|
through `solitaire_core`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
|
// From `card_game` (upstream — never edit):
|
||||||
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
|
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
|
||||||
|
pub enum Rank { /* Ace ..= King */ }
|
||||||
|
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
|
||||||
|
facing is positional, tracked by the Klondike board */ }
|
||||||
|
pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
||||||
|
|
||||||
pub struct Card {
|
// From `klondike` (upstream — never edit):
|
||||||
pub id: u32,
|
pub enum KlondikePile {
|
||||||
pub suit: Suit,
|
|
||||||
pub rank: Rank,
|
|
||||||
pub face_up: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum PileType {
|
|
||||||
Stock,
|
Stock,
|
||||||
Waste,
|
Waste,
|
||||||
Foundation(Suit),
|
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||||
Tableau(usize), // 0–6
|
Tableau(Tableau), // 7 columns
|
||||||
}
|
}
|
||||||
|
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||||
|
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||||
|
serialized move format (schema v4+) */ }
|
||||||
|
```
|
||||||
|
|
||||||
pub enum DrawMode { DrawOne, DrawThree }
|
Owned by `solitaire_core`:
|
||||||
|
|
||||||
|
```rust
|
||||||
/// Active game mode. Classic is the default; others unlock at level 5.
|
/// Active game mode. Classic is the default; others unlock at level 5.
|
||||||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||||||
|
|
||||||
@@ -589,24 +615,30 @@ pub enum MoveError {
|
|||||||
RuleViolation(String),
|
RuleViolation(String),
|
||||||
UndoStackEmpty,
|
UndoStackEmpty,
|
||||||
GameAlreadyWon,
|
GameAlreadyWon,
|
||||||
|
StockEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
pub piles: HashMap<PileType, Vec<Card>>,
|
|
||||||
pub draw_mode: DrawMode,
|
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub move_count: u32,
|
|
||||||
pub undo_count: u32, // number of undos used in this game
|
|
||||||
pub recycle_count: u32, // number of stock recycles
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64, // same seed ⇒ same deal
|
||||||
pub is_won: bool,
|
pub take_from_foundation: bool,
|
||||||
pub is_auto_completable: bool,
|
session: Session<Klondike>, // private — the single source of truth
|
||||||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
|
||||||
|
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
|
||||||
|
accessors read through the session. Undo replays the instruction log
|
||||||
|
(no snapshot stack); the −15 undo penalty is applied by the upstream
|
||||||
|
score formula via the session config. Persistence (schema v5) saves
|
||||||
|
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
|
||||||
|
session by replay on load — older files carrying `score`/`undo_count`/
|
||||||
|
`recycle_count` keys load fine, the extra fields are ignored.
|
||||||
|
|
||||||
|
**Rules decision:** stock recycling is unlimited in every draw mode
|
||||||
|
(see the "Rules decisions" note in §3 `solitaire_core`).
|
||||||
|
|
||||||
### Persistence Models (`solitaire_data`)
|
### Persistence Models (`solitaire_data`)
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -644,7 +676,7 @@ pub struct AchievementRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub sfx_volume: f32, // 0.0–1.0
|
pub sfx_volume: f32, // 0.0–1.0
|
||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ solitaire_data/ # Persistence + sync client
|
|||||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
solitaire_server/ # Axum backend (optional sync layer)
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
solitaire_wasm/ # WASM bindings for browser-side replay player
|
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||||
solitaire_app/ # Entry binary
|
solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||||
|
solitaire_assetgen/ # One-shot card/background PNG asset generator
|
||||||
|
solitaire_app/ # Entry binary (desktop + Android cdylib)
|
||||||
assets/ # Runtime assets (except audio + default theme)
|
assets/ # Runtime assets (except audio + default theme)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -168,18 +168,15 @@ Three bugs fixed:
|
|||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### 1. Physical-device smoke test — PARTIALLY DONE (2026-07-06, Galaxy Fold 7)
|
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
|
||||||
|
|
||||||
v0.41.1 was installed and launched on a physical Fold 7 via adb. Verified:
|
v0.41.1 installed via adb and the full device checklist passed on hardware:
|
||||||
fold/unfold layout on both screens (incl. the #116 resume path and the
|
fold/unfold layout on both screens (incl. the #116 resume path and the
|
||||||
pile-marker fix), safe-area inset resolution, and app launch/restore basics.
|
pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
|
||||||
|
accuracy (#106), modal centring on both screens, drag-and-drop across all
|
||||||
**Still unexercised** from the device checklist: Draw-Three waste fan tap
|
pile types, text rendering, kill-and-restore, and the sync token flow.
|
||||||
accuracy (the #106 fix — switch to Draw-Three, drag the visible top waste
|
Reminder for future gates: AVD is not a substitute — `adb shell input tap`
|
||||||
card ~10×, confirm it plays *that* card), modal centring on both screens,
|
doesn't deliver real touch events.
|
||||||
drag-and-drop across all pile types, kill-and-restore, and the sync token
|
|
||||||
flow. AVD is not a substitute — `adb shell input tap` doesn't deliver real
|
|
||||||
touch events.
|
|
||||||
|
|
||||||
### 2. Matomo analytics live validation (independent — NOT a release blocker)
|
### 2. Matomo analytics live validation (independent — NOT a release blocker)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user