Compare commits

...

6 Commits

Author SHA1 Message Date
funman300 f6e57b759e ci: pin toolchain to 1.95.0 and install Bevy native deps
Test / test (pull_request) Successful in 27m47s
Review feedback on #135: floating 'stable' + -D warnings lets every new
clippy release redden master with new lints — pin 1.95.0 like the
web-wasm-rebuild workflow. And ubuntu-latest lacks the ALSA/udev/X11/
Wayland dev packages the Bevy crates link against; install the standard
Bevy CI set (the project's own builder images have no desktop-Bevy
precedent — android-builder targets the NDK and web-e2e only builds the
server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:28:28 -07:00
funman300 c9adcaa5e4 ci: add workspace clippy + test gate workflow
Test / test (pull_request) Failing after 1m35s
Until now no CI workflow ran the test suite or clippy at all — the
android-release, docker-build, web-e2e, web-wasm-rebuild, and
builder-image workflows cover packaging and e2e, but a direct push to
master (including the web-wasm-rebuild bot commit) never executed
cargo test or clippy. The gate discipline in CLAUDE.md §6 existed only
on developer machines.

test.yml runs the exact §6 commands (clippy --workspace --all-targets
-D warnings; cargo test --workspace) on master pushes and PRs, with
SQLX_OFFLINE against the checked-in .sqlx cache, path-filtered so
docs-only merges don't burn CI. Toolchain/cache mirror web-e2e.yml.

Found during the 2026-07-06 large-scale review (finding H1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:21:19 -07:00
funman300 15bb136c79 Merge pull request 'perf(web): size-focused wasm-release profile for the canvas build (36.2 → 23.2 MB)' (#134) from perf/wasm-size-profile into master
Build and Deploy / build-and-push (push) Successful in 7m4s
Web E2E / web-e2e (push) Successful in 4m51s
Web WASM Rebuild / rebuild (push) Successful in 9m30s
2026-07-06 22:44:33 +00:00
funman300 16a1139eab perf(web): size-focused wasm-release profile for the canvas build
canvas_bg.wasm shipped at 36.2 MB — plain release (opt-level 3, thin
LTO) piped through wasm-opt -O2. Download size, not throughput, is the
binding constraint for the browser canvas, so solitaire_web now builds
with a dedicated wasm-release profile: opt-level "s", fat LTO, one
codegen unit. Local result: 23.2 MB after the unchanged wasm-opt -O2
pass — 35.9% smaller.

The binaryen pass stays at -O2 (the -Oz grey-screen miscompile note in
build_wasm.sh still applies). profile.strip is deliberately NOT set: 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).

Verified with the new artifact: all 5 play_canvas e2e specs pass (debug
bridge, draw3 param, apply/undo, replay diagnostics, 40-seed autoplay
invariants). Headless pixel verification is inconclusive in this
environment — wgpu cannot create a usable adapter locally and panics
identically on the OLD artifact too — so visual parity should be
confirmed against production after the next deploy.

pkg/ artifacts are intentionally not committed: the web-wasm-rebuild
workflow is the single source of truth and will regenerate them with
this profile on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:44:16 -07:00
funman300 710cabcf0d Merge pull request 'docs(architecture): bring source-of-truth docs to post-migration reality' (#133) from docs/architecture-post-migration into master 2026-07-06 22:26:18 +00:00
funman300 5acd8e4cd0 docs(architecture): bring source-of-truth docs to post-migration reality
ARCHITECTURE.md predated the card_game migration (PR #88, 2026-06-22)
and still documented the pre-migration core: local Card with face_up,
PileType, stored score/undo/recycle, and a snapshot undo stack. Rewrites
the Core Game Models section around the upstream card_game/klondike
types and the derived-stats/replay-undo model, updates the
solitaire_core crate section (deps + ownership), adds solitaire_web and
solitaire_assetgen to the workspace tree, and corrects the
solitaire_app and Settings entries. Version bumped to 1.4.

Also adds the two missing crates to the CLAUDE.md crate map (AGENTS.md,
its gitignored local twin, was regenerated in place) and records the
full device checklist passing on the Fold 7 in SESSION_HANDOFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:26:06 -07:00
6 changed files with 167 additions and 52 deletions
+66
View File
@@ -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
View File
@@ -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), // 06 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.01.0 pub sfx_volume: f32, // 0.01.0
pub music_volume: f32, pub music_volume: f32,
pub animation_speed: AnimSpeed, pub animation_speed: AnimSpeed,
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.