Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e57b759e | |||
| c9adcaa5e4 | |||
| 15bb136c79 | |||
| 16a1139eab | |||
| 710cabcf0d | |||
| 5acd8e4cd0 |
@@ -0,0 +1,66 @@
|
|||||||
|
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||||
|
# locally, run on every master push and pull request. Until this workflow
|
||||||
|
# existed, nothing in CI ran the test suite at all — a direct push to
|
||||||
|
# master (or the web-wasm-rebuild bot commit) was entirely unguarded.
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'solitaire_app/**'
|
||||||
|
- 'solitaire_assetgen/**'
|
||||||
|
- 'solitaire_core/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
|
- 'solitaire_engine/**'
|
||||||
|
- 'solitaire_server/src/**'
|
||||||
|
- 'solitaire_server/tests/**'
|
||||||
|
- 'solitaire_server/migrations/**'
|
||||||
|
- 'solitaire_sync/**'
|
||||||
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.cargo/**'
|
||||||
|
- '.sqlx/**'
|
||||||
|
- '.gitea/workflows/test.yml'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: clippy
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||||
|
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||||
|
- name: Install Bevy native dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
|
# same as the web-e2e workflow's server prebuild.
|
||||||
|
- name: Clippy (deny warnings)
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: 'true'
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: 'true'
|
||||||
|
run: cargo test --workspace
|
||||||
+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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+16
@@ -156,3 +156,19 @@ opt-level = 3
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
||||||
|
# Size-focused profile for the browser canvas build (solitaire_web →
|
||||||
|
# canvas_bg.wasm). Download size is the constraint on the web, not peak
|
||||||
|
# throughput: fat LTO + one codegen unit + opt-level "s" cut the Bevy wasm
|
||||||
|
# bundle substantially versus plain release. This is rustc-side sizing only —
|
||||||
|
# the binaryen pass in build_wasm.sh stays at wasm-opt -O2 because -Oz has
|
||||||
|
# miscompiled Bevy's render pipeline before (grey screen on first load).
|
||||||
|
[profile.wasm-release]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "s"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
# No `strip`: on wasm it also removes the target_features custom section,
|
||||||
|
# which makes wasm-opt reject the module ("all used features should be
|
||||||
|
# allowed" on trunc_sat). wasm-opt drops the name section in its output
|
||||||
|
# anyway, so strip buys nothing here.
|
||||||
|
|||||||
+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)
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -67,7 +67,9 @@ if ! command -v wasm-bindgen &> /dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building solitaire_web (Bevy WASM app)..."
|
echo "Building solitaire_web (Bevy WASM app)..."
|
||||||
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
|
# wasm-release is the size-focused profile (fat LTO, CGU=1, opt-level "s") —
|
||||||
|
# see [profile.wasm-release] in Cargo.toml. Download size is the constraint.
|
||||||
|
cargo build --profile wasm-release --target wasm32-unknown-unknown -p solitaire_web
|
||||||
|
|
||||||
echo "Running wasm-bindgen for solitaire_web..."
|
echo "Running wasm-bindgen for solitaire_web..."
|
||||||
wasm-bindgen \
|
wasm-bindgen \
|
||||||
@@ -75,7 +77,7 @@ wasm-bindgen \
|
|||||||
--out-name canvas \
|
--out-name canvas \
|
||||||
--target web \
|
--target web \
|
||||||
--no-typescript \
|
--no-typescript \
|
||||||
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
|
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||||
|
|
||||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||||
|
|||||||
Reference in New Issue
Block a user