diff --git a/CLAUDE_PROMPT_PACK.md b/CLAUDE_PROMPT_PACK.md deleted file mode 100644 index 35a49c0..0000000 --- a/CLAUDE_PROMPT_PACK.md +++ /dev/null @@ -1,497 +0,0 @@ -# CLAUDE_PROMPT_PACK.md - -version: 1.0 - ---- - -# 0. GLOBAL INSTRUCTION (prepend to every prompt) - -``` -You must follow CLAUDE_SPEC.md strictly. - -Rules: -- Do not expand scope beyond what is defined -- Do not refactor unrelated code -- Do not introduce new dependencies to solitaire_core or solitaire_sync without confirmation -- Prefer minimal, surgical changes -- Use existing patterns in the codebase -- Return minimal diffs or changed functions only - -Before writing code: -1. List relevant constraints from CLAUDE_SPEC.md -2. Identify risks -3. Then implement -``` - ---- - -# 1. FEATURE IMPLEMENTATION - -``` -# TASK: Feature Implementation - -feature: "" - -goal: -"" - -scope: -crates: [] -systems: [] -files: [] - -non_goals: -- "" - -constraints: -- must follow CLAUDE_SPEC.md -- event-driven architecture required -- no blocking operations -- no cross-crate leakage - -acceptance_criteria: -- "" -- "" - -edge_cases: -- "" - ---- - -## Required Patterns - -Use this pattern for systems: - - ---- - -## Output Format - -intent: -plan: -constraints_used: -risks: - -code_changes: -(minimal diffs only) - -notes: -``` - ---- - -# 2. BUGFIX - -``` -# TASK: Bug Fix - -bug_description: -"" - -expected_behavior: -"" - -root_cause_hint (optional): -"" - -scope: -crates: [] -files: [] - -constraints: -- minimal fix only -- no refactors unless required -- must add regression protection if applicable - ---- - -## Requirements - -1. Identify root cause -2. Fix it minimally -3. Preserve all invariants -4. Do not change unrelated logic - ---- - -## Output Format - -analysis: -root_cause: -fix_strategy: - -code_changes: -(minimal diff) - -regression_test (only if high-value): - -notes: -``` - ---- - -# 3. REFACTOR - -``` -# TASK: Refactor - -target: -"" - -goal: -"" - -scope: -crates: [] -files: [] - -non_goals: -- no behavior changes -- no new features - -constraints: -- must preserve behavior exactly -- must respect crate boundaries -- must not duplicate logic - ---- - -## Refactor Type - -- [ ] simplify logic -- [ ] reduce duplication -- [ ] improve readability -- [ ] performance (non-invasive) - ---- - -## Output Format - -analysis: -issues_found: - -refactor_plan: - -code_changes: -(diff only) - -verification: -- behavior unchanged: yes/no -- invariants preserved: yes/no - -notes: -``` - ---- - -# 4. SYSTEM DESIGN (NEW FEATURE) - -``` -# TASK: System Design - -feature: -"" - -goal: -"" - -constraints: -- must fit existing architecture -- must follow plugin + event model -- must not violate crate boundaries - ---- - -## Required Output - -design: - -components: -- plugins: -- systems: -- events: -- resources: - -data_flow: -(step-by-step) - -integration_points: -- where it connects to existing systems - -risks: -- "" - -tradeoffs: -- "" - ---- - -## DO NOT - -- write full implementation -- modify unrelated systems -``` - ---- - -# 5. NEW BEVY SYSTEM - -``` -# TASK: Add Bevy System - -system_name: -"" - -trigger: -(event or condition) - -reads: -[Resources] - -writes: -[Resources] - -emits: -[Events] - -constraints: -- must be event-driven -- must not directly mutate unrelated state -- must be single responsibility - ---- - -## Output Format - -system_signature: - -implementation: -(code only) - -notes: -``` - ---- - -# 6. CORE LOGIC FUNCTION (solitaire_core) - -``` -# TASK: Core Logic Implementation - -function: -"" - -goal: -"" - -rules: -- no IO -- no async -- no Bevy -- deterministic - -invariants: -- "" -- "" - -errors: -- "" - ---- - -## Output Format - -constraints_checked: - -implementation: -(code only) - -edge_case_handling: - -notes: -``` - ---- - -# 7. SYNC / MERGE LOGIC - -``` -# TASK: Sync Logic - -goal: -"" - -constraints: -- must be deterministic -- must be idempotent -- must be lossless -- must not delete data - -rules: -- counters → max -- times → min -- collections → union - ---- - -## Output Format - -analysis: - -merge_logic: - -code_changes: - -invariants_verified: -- deterministic -- idempotent -- lossless - -notes: -``` - ---- - -# 8. PERFORMANCE OPTIMIZATION - -``` -# TASK: Optimization - -target: -"" - -constraints: -- no behavior change -- no architecture change -- minimal code changes - ---- - -## Output Format - -analysis: -bottleneck: - -optimization_strategy: - -code_changes: - -impact_estimate: - -notes: -``` - ---- - -# 9. TEST GENERATION (STRICT MODE) - -``` -# TASK: Test Generation - -target: -"" - -reason: -- bugfix | complex logic | invariant protection - -constraints: -- no redundant tests -- must test real behavior -- must fail if logic breaks - ---- - -## Output Format - -test_cases: -- "" - -test_code: - -notes: -``` - ---- - -# 10. DEBUGGING / INVESTIGATION - -``` -# TASK: Debug - -problem: -"" - -context: -"" - ---- - -## Required Steps - -1. List possible causes -2. Narrow down most likely -3. Suggest verification steps -4. Provide minimal fix - ---- - -## Output Format - -hypotheses: - -most_likely: - -verification_steps: - -fix: - -notes: -``` - ---- - -# 11. HARD CONSTRAINT OVERRIDE (RARE) - -``` -# TASK: Exception Handling - -reason: -"" - -requested_exception: -"" - -justification: -"" - ---- - -## Output Format - -analysis: - -alternatives_considered: - -final_decision: - -risk: -``` - ---- - -# 12. STOP CONDITIONS (always append) - -``` -Stop when: -- acceptance criteria are met -- code is minimal and correct - -Do NOT: -- expand scope -- refactor unrelated code -- optimize prematurely -``` - ---- - -# END diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md deleted file mode 100644 index 74b0cab..0000000 --- a/CLAUDE_SPEC.md +++ /dev/null @@ -1,296 +0,0 @@ -# CLAUDE_SPEC.md - -version: 1.0 - ---- - -## 0. Global Rules - -(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.) - -rules: - -* id: single_source_of_truth - description: "GameStateResource is the only mutable game state in runtime" - -* id: sync_is_additive - description: "Remote data must never destructively overwrite local data" - ---- - -## 1. Crate Graph - -crates: -solitaire_core: -depends_on: [rand, serde, chrono] -forbidden_deps: [bevy, reqwest, tokio, std::fs] - -solitaire_sync: -depends_on: [serde, serde_json, uuid, chrono] -role: "shared_types" - -solitaire_data: -depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring] -role: "persistence_and_sync" - -solitaire_engine: -depends_on: [bevy, kira, solitaire_core, solitaire_data] -role: "runtime_engine" - -solitaire_server: -depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken] -role: "backend" - -solitaire_wasm: -depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen] -role: "wasm_replay_player" - -solitaire_app: -depends_on: [solitaire_engine] -role: "entrypoint" - ---- - -## 2. Data Ownership - -ownership: -GameState: -owner: solitaire_core -mutable_in: solitaire_engine -access_pattern: "via GameStateResource only" - -StatsSnapshot: -owner: solitaire_data - -PlayerProgress: -owner: solitaire_data - -AchievementRecord: -owner: solitaire_data - -SyncPayload: -owner: solitaire_sync - ---- - -## 3. State Transitions - -state_machine: -GameState: -transitions: -- action: move_cards -returns: Result - -``` - - action: draw - returns: Result - - - action: undo - returns: Result - -invariants: - - "52 cards always exist" - - "no duplicate card IDs" - - "all cards belong to exactly one pile" -``` - ---- - -## 4. Event System - -events: - -input: -- MoveRequestEvent -- DrawRequestEvent -- UndoRequestEvent -- NewGameRequestEvent - -state: -- StateChangedEvent -- GameWonEvent - -meta: -- AchievementUnlockedEvent -- SyncCompleteEvent - -rules: - -* "Input events trigger core logic" -* "Core logic emits state events" -* "UI reacts to state events only" - ---- - -## 5. Sync Contract - -sync: - -provider_trait: -methods: -- pull() -> SyncPayload -- push(payload) -> SyncResponse - -guarantees: -- "non-blocking during gameplay" -- "blocking allowed on exit only" - -merge: -rules: -counters: "max" -best_times: "min" -collections: "union" -achievements: "never removed" - -``` -properties: - - deterministic - - idempotent - - lossless -``` - ---- - -## 6. Persistence - -storage: - -format: json - -files: -- stats.json -- progress.json -- achievements.json -- settings.json -- game_state.json - -guarantees: -- atomic_write: true -- crash_safe: true - ---- - -## 7. Engine Rules - -engine: - -mutation_rules: -- "Only GameLogicSystem mutates GameState" -- "UI systems are read-only" - -threading: -- "sync runs on AsyncComputeTaskPool" -- "main thread must never block" - -plugins: -pattern: "feature_isolation" -communication: "events and resources" - ---- - -## 8. Server Contract - -server: - -auth: -method: jwt -access_expiry: 24h -refresh_expiry: 30d - -endpoints: -- POST /api/auth/register -- POST /api/auth/login -- GET /api/sync/pull -- POST /api/sync/push - -limits: -payload_max: 1MB -rate_limit: "10 req/min auth routes" - ---- - -## 9. Achievement System - -achievements: - -definition_location: solitaire_core -state_location: solitaire_data - -types: -- condition_based -- event_driven - -rule: -- "achievements cannot be revoked" - ---- - -## 10. Testing Rules - -testing: - -philosophy: -- "test real failures" -- "avoid redundant tests" - -required_coverage: -solitaire_core: -- move_validation -- undo_integrity -- win_detection - -``` -solitaire_sync: - - merge_correctness - - idempotency -``` - ---- - -## 11. Prohibited Patterns - -(See CLAUDE.md §11 for the canonical forbidden-patterns list.) - ---- - -## 12. Extension Points - -extensibility: - -sync_backends: -pattern: "implement SyncProvider" - -game_modes: -location: solitaire_core::GameMode - -plugins: -rule: "new feature = new plugin" - ---- - -## 13. Validation Checklist (for Claude) - -validation: - -* check: "crate dependency rules respected" -* check: "no panics in core" -* check: "events used for cross-system communication" -* check: "GameState mutations centralized" -* check: "merge function properties preserved" -* check: "no blocking operations in main loop" - ---- - -## 14. Mental Model - -model: - -layers: -- core -- engine -- data -- server - -flow: -- input -> engine -> core -> engine -> ui -- data <-> sync <-> server diff --git a/CLAUDE_WORKFLOW.md b/CLAUDE_WORKFLOW.md deleted file mode 100644 index 58e8a9a..0000000 --- a/CLAUDE_WORKFLOW.md +++ /dev/null @@ -1,335 +0,0 @@ -# CLAUDE_WORKFLOW.md - -version: 1.0 - ---- - -## 0. Overview - -This workflow defines a **two-agent system**: - -* **Builder Agent** → writes and modifies code -* **Guardian Agent** → enforces architecture + rejects invalid changes - -No code is considered valid unless it passes Guardian validation. - ---- - -## 1. Agent Roles - -### 1.1 Builder Agent - -role: "code_generation" - -responsibilities: - -* implement features -* refactor code -* generate tests (only when justified) -* follow CLAUDE_SPEC.md - -constraints: - -* cannot bypass validation -* must declare intent before writing code - -output_contract: -must_produce: -- change_summary -- files_modified -- reasoning (short) -- code_diff - ---- - -### 1.2 Guardian Agent - -role: "architecture_enforcement" - -responsibilities: - -* validate against CLAUDE_SPEC.md -* detect violations -* reject or approve changes -* suggest minimal fixes (not full rewrites) - -constraints: - -* no feature implementation -* no large rewrites -* must be deterministic - -output_contract: -must_produce: -- status: APPROVED | REJECTED -- violations[] -- required_fixes[] -- optional_improvements[] - ---- - -## 2. Workflow Pipeline - -```text -User Request - ↓ -Builder Agent (proposal + code) - ↓ -Guardian Agent (validation) - ↓ -IF approved → commit -IF rejected → feedback → Builder retry -``` - ---- - -## 3. Builder Protocol - -### Step 1 — Intent Declaration - -Builder MUST start with: - -```yaml -intent: - feature: "" - crates_touched: [] - systems_affected: [] - risk_level: low|medium|high -``` - ---- - -### Step 2 — Plan - -```yaml -plan: - - step: "..." - - step: "..." -``` - ---- - -### Step 3 — Implementation - -* Only modify declared crates -* Follow ownership rules -* Use events for cross-system communication - ---- - -### Step 4 — Output - -```yaml -change_summary: "..." - -files_modified: - - path: ... - change: "..." - -violations_self_check: - - none | list - -notes: "short reasoning" -``` - ---- - -## 4. Guardian Protocol - -### Step 1 — Spec Validation - -Check against: - -* crate boundaries -* mutation rules -* event system usage -* sync guarantees -* forbidden patterns - ---- - -### Step 2 — Invariant Validation - -Must verify: - -* GameState invariants preserved -* no new panic paths -* no blocking calls in engine -* merge properties unchanged - ---- - -### Step 3 — Output Decision - -#### APPROVED - -```yaml -status: APPROVED - -notes: - - "no violations" -``` - ---- - -#### REJECTED - -```yaml -status: REJECTED - -violations: - - id: core_purity_violation - file: "solitaire_core/src/..." - reason: "uses std::fs" - -required_fixes: - - "move IO to solitaire_data" - -optional_improvements: - - "simplify event naming" -``` - ---- - -## 5. Enforcement Rules - -### Hard Fail (automatic rejection) - -* core crate uses IO / Bevy / network -* GameState mutated outside GameLogicSystem -* blocking async on main thread -* duplicate logic across crates -* merge function altered incorrectly - ---- - -### Soft Fail (allowed but flagged) - -* unnecessary complexity -* redundant tests -* minor architectural drift - ---- - -## 6. Iteration Loop - -Max attempts per task: **3** - -```text -Attempt 1 → Reject → Fix -Attempt 2 → Reject → Fix -Attempt 3 → Final decision -``` - -If still failing: -→ escalate to user - ---- - -## 7. Diff Strategy - -Builder MUST produce: - -* minimal diffs -* no unrelated refactors -* no formatting-only changes - ---- - -## 8. Test Strategy Integration - -Builder rules: - -* only add tests if: - - * fixing a bug - * protecting complex logic - * validating invariants - -Guardian rejects: - -* redundant tests -* no-op tests - ---- - -## 9. Optional Extensions - -### 9.1 Third Agent (Optimizer) - -role: performance + cleanup - -runs AFTER approval: - -* reduce allocations -* simplify logic -* improve ECS scheduling - ---- - -### 9.2 CI Integration - -Pipeline: - -```text -Builder → Guardian → cargo check → clippy → tests -``` - -Guardian runs BEFORE compilation to catch structural issues early. - ---- - -## 10. Example Interaction - -### Builder - -```yaml -intent: - feature: "undo stack limit fix" - crates_touched: [solitaire_core] - risk_level: low -``` - -```yaml -change_summary: "limit undo stack to 64 entries" - -files_modified: - - solitaire_core/src/game_state.rs - -notes: "prevents unbounded memory growth" -``` - ---- - -### Guardian - -```yaml -status: APPROVED - -notes: - - "respects core constraints" - - "no invariant violations" -``` - ---- - -## 11. Mental Model - -* Builder = **creative** -* Guardian = **strict** - -Builder explores -Guardian enforces - -Neither replaces the other. - ---- - -## 12. Success Criteria - -System is working if: - -* architectural violations go to ~0 -* code stays consistent across features -* refactors become safe -* complexity grows sub-linearly diff --git a/SESSION_HANDOFF.md b/SESSION_HANDOFF.md deleted file mode 100644 index c728965..0000000 --- a/SESSION_HANDOFF.md +++ /dev/null @@ -1,177 +0,0 @@ -# Ferrous Solitaire — Session Handoff - -**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin. - -Phase 8 closes the self-hosted-server connection arc end-to-end: login/register -modal, re-auth on token expiry, account deletion flow, server deployment -artifacts (Dockerfile + docker-compose), replay upload on win, web replay -player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out, -and full server integration tests. - ---- - -## Current state - -- **HEAD locally:** `03be4fc` (feat: leaderboard custom display name). -- **HEAD on origin:** `03be4fc` (fully pushed). -- **Working tree:** clean (only `solitaire-release.jks.bak2` untracked — intentional). -- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean. -- **Tests:** **1300+ passing / 0 failing** across the workspace. -- **Tags on origin:** `v0.9.0` through `v0.22.0`. - ---- - -## What shipped in Phase 8 (432061c – bd388fe) - -| Commit | Summary | -|--------|---------| -| `432061c` | Sync setup modal (login/register/connect/disconnect) | -| `6ce5564` | Re-auth on expired session + server deployment artifacts | -| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor | -| `bd388fe` | CHANGELOG v0.23.0 documentation | - -Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG): -- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback -- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id` -- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets -- DB migration 002: `replays` table + two indexes -- Full server integration tests for replay endpoints -- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history) -- Stats panel "Copy Share Link" button reads `share_url` from replay history - ---- - -## Open punch list (ordered by priority) - -### 1. Documentation debt (no code) -- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session -- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3 -- [x] SESSION_HANDOFF.md update — this file - -### 2. Leaderboard wiring gaps -- [x] **Best-score auto-post.** Done (`303c78a`): `update_leaderboard_if_opted_in` - called from both first-push and merge paths in `sync.rs`; uses SQLite `MIN`/`MAX` - in the UPDATE so scores never regress on stale data. -- [x] **Display name = username.** Done (`03be4fc`): `leaderboard_display_name: - Option` added to `Settings`; editor modal in leaderboard panel; persists - to `settings.json`; `handle_opt_in_button` prefers custom name over username. - -### 3. Security hardening -- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table - (migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration - tests. -- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor` - decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min - steady-state; integration test passes. - -### 4. Android validation -- [x] **Android Keystore functional test.** Done (2026-05-11, Pixel 7 AVD, - Android 14): `load_access_token()` exercised via `start_pull`; logcat confirmed - `NotFound` returned cleanly — no JNI panic. See `docs/android/PLAYABILITY_TODO.md` P4. -- [x] **JNI clipboard functional test.** Done (2026-05-11): temporary `KEYCODE_C` - hook confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14. - Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`. - Note: `adb shell input tap` doesn't deliver touch events on headless AVD (documented). -- [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib` - is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible. - -### 5. Feature completeness -- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in - Settings Appearance section. Shows import path label, scans user_theme_dir() - for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry. -- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op - default never overridden and never called; achievements already sync via - `SyncPayload` push. Deleted from trait and blanket impl. -- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root - documents `wasm-pack build --target web`, cleans up pkg metadata files, - includes dependency guard + install instructions. -- [x] **Server password reset.** Done (`7514684`): `--reset-password ` - subcommand reads new password from stdin, bcrypt-hashes it, invalidates all - active sessions for the user. - -### 5b. Android UX polish (2026-05-12) - -- [x] **UX-1 — Modal Done button in gesture zone.** `apply_safe_area_to_modal_scrims` system - added to `SafeAreaInsetsPlugin` (`safe_area.rs`). Pads every `ModalScrim` bottom by - `insets.bottom / scale`. Fires on resource change + `Added`. Verified on device. -- [x] **UX-5b — Home mode glyph corruption.** Geometric Shapes (U+25xx, absent from FiraMono) - replaced with card suits U+2660–2666 in `home_plugin.rs`. Affects Zen/Challenge/Daily mode - selector buttons at level 5+. -- [x] **UX-7 — Help text wrap.** Android HUD entry shortened to - `"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs` — fits one line. -- [x] **BUG-3 — Multi-modal stacking.** `handle_menu_button` now checks - `scrims: Query<(), With>` and guards `spawn_menu_popover` with `scrims.is_empty()`. - Verified on device: ≡ tap while Stats open does nothing. - - **Note:** These 4 fixes are implemented and verified but not yet committed. - -### 6. Testing gaps -- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both - `jwt_refresh_on_401_succeeds` (pull) and - `push_retries_after_401_on_expired_access_token` (push) in - `solitaire_data/tests/sync_round_trip.rs`. -- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver - searches seeds 1–200 at test time; steps every move through `ReplayPlayer`; - asserts `is_won = true` on the final `StateSnapshot`. - ---- - -## ARCHITECTURE.md gaps (for the update pass) - -Items missing from the doc: -1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities) -2. Replay API endpoints (§9 API Reference — 3 new routes) -3. Web replay player route (`/replays/:id` + `ServeDir /web`) -4. `SyncProvider` trait: 6 added methods -5. Theme system in Bevy plugin table (§5) -6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`, - `reduce_motion_mode`, `window_geometry`, `selected_card_back`, - `selected_background` -7. DB migration 002 (§7) -8. Update "Last Updated" date - ---- - -## Process notes - -- **Commit attribution:** use `funman300` as git user. Co-author line: - `Co-Authored-By: Claude Sonnet 4.6 `. -- **Commit format:** `type(scope): description` per CLAUDE.md §7. -- **Never commit without:** `cargo test --workspace` passing + clippy clean. -- **Sub-agents** stage/verify only; orchestrator commits. -- **`CARD_PLAN.md`** referenced in `theme/` module comments but not present in - repo. Clean up references or commit the file. -- **Token-port pattern** (v0.20.0): when migrating tokens, walk every concrete - artifact downstream — PNGs, SVGs, literals, comments. Three "walked past this" - follow-ups in v0.21.0 all had this shape. - ---- - -## Resume prompt - -``` -You are a senior Rust + Bevy developer working on Ferrous Solitaire. -Working directory: . -Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed. - -READ FIRST (in order): - 1. SESSION_HANDOFF.md — this file - 2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail - 3. CLAUDE.md — unified-4.0 rule set - 4. ARCHITECTURE.md — v1.3, fully up to date - 5. docs/ui-mockups/ — design system + mockup library - 6. docs/android/ — Android setup + build runbook - 7. ~/.claude/projects//memory/MEMORY.md - -OPEN WORK: - Phase 8 punch list is fully closed. All items verified complete. - Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking). - - 4 Android UX fixes are implemented and verified but NOT YET COMMITTED: - - BUG-3 (hud_plugin.rs): multi-modal stacking guard - - UX-7 (help_plugin.rs): help text wrap on Android - - UX-5b (home_plugin.rs): FiraMono glyph corruption in mode selector - - UX-1 (safe_area.rs): modal Done button in gesture zone - - Commit those first, then suggest Phase 9 planning. -``` diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md deleted file mode 100644 index 15304c9..0000000 --- a/docs/SESSION_HANDOFF.md +++ /dev/null @@ -1,262 +0,0 @@ -# Ferrous Solitaire — Session Handoff (ARCHIVED) - -> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical -> reference only. The authoritative session handoff is at the repo root: -> `SESSION_HANDOFF.md`.** - -> Last updated: 2026-04-25 -> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git -> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean - ---- - -## What Has Been Built - -### Phase 1 — Workspace Setup ✅ COMPLETE - -All seven Cargo crates created and compiling cleanly: - -| Crate | Status | Purpose | -|---|---|---| -| `solitaire_core` | Fully implemented | Pure Rust game logic — NO Bevy, NO network | -| `solitaire_sync` | Stub | Shared API types (`SyncPayload`, `SyncResponse`) | -| `solitaire_data` | Stub | `SyncError` enum + `SyncProvider` trait | -| `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 | -| `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C | -| `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android | -| `solitaire_app` | Working | Opens blank Bevy window titled "Ferrous Solitaire" at 1280×800 | - -Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place. - -### Phase 2 — Core Game Engine ✅ COMPLETE - -`solitaire_core` is fully implemented with 68 passing tests and zero clippy warnings. - -**Modules:** -- `card.rs` — `Suit` (Clubs/Diamonds/Hearts/Spades, `is_red()`/`is_black()`), `Rank` (Ace–King, `value() -> u8`), `Card` (id, suit, rank, face_up) -- `pile.rs` — `PileType` (Stock, Waste, Foundation(Suit), Tableau(usize)), `Pile` (new, top) -- `error.rs` — `MoveError`: InvalidSource, InvalidDestination, EmptySource, RuleViolation(String), UndoStackEmpty, GameAlreadyWon, StockEmpty -- `deck.rs` — `Deck::new()`, `Deck::shuffle(seed: u64)` using seeded `StdRng` (cross-platform deterministic), `deal_klondike(deck) -> ([Pile; 7], Pile)` -- `rules.rs` — `can_place_on_foundation(card, pile, suit)`, `can_place_on_tableau(card, pile)` -- `scoring.rs` — `score_move(from, to)`, `score_undo()` (-15), `compute_time_bonus(elapsed_seconds)` (700_000/s) -- `game_state.rs` — `DrawMode`, `GameState` with full game loop - -**GameState public API:** -```rust -GameState::new(seed: u64, draw_mode: DrawMode) -> Self -GameState::draw(&mut self) -> Result<(), MoveError> -GameState::move_cards(&mut self, from: PileType, to: PileType, count: usize) -> Result<(), MoveError> -GameState::undo(&mut self) -> Result<(), MoveError> -GameState::check_win(&self) -> bool -GameState::check_auto_complete(&self) -> bool -GameState::compute_time_bonus(&self) -> i32 -GameState::undo_stack_len(&self) -> usize -``` - -**Key GameState rules:** -- Undo stack capped at 64 entries (oldest evicted) -- Score never goes below 0 -- Waste recycling is unlimited — `StockEmpty` only when both stock AND waste are simultaneously empty -- Recycle (waste → stock) pushes a snapshot so it can be undone -- Newly exposed top card of source pile is flipped face-up automatically on `move_cards` -- Win: all 4 foundations at 13 cards -- Auto-complete: stock empty + waste empty + all tableau cards face-up - ---- - -## Commit History - -``` -b8dc7cb fix(core): remove stock_recycled limit, replace unwrap, snapshot on recycle, fix derives -58f1465 feat(core): add GameState with draw, move_cards, undo, win/auto-complete detection -43194b0 fix(core): use StdRng doc comment, replace expect() with debug_assert in deal_klondike -17bbec0 feat(core): add pile, error, deck, rules, scoring modules with tests -fcf878b feat(core): add Card, Suit, Rank types with tests -f84d7c5 fix(workspace): add derives/docs per code review, remove unused thiserror from solitaire_sync -684f077 feat(workspace): initialize all seven crates with stubs and blank Bevy window -``` - ---- - -### Phase 3 — Bevy Rendering & Interaction ✅ COMPLETE - -All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin`, `InputPlugin`, `AnimationPlugin`. Full game playable — drag/drop with rule validation, keyboard shortcuts (U/N/D/Esc), animated slides, win cascade. UI via `bevy::ui`, no egui. - -### Phase 4 — Statistics Persistence ✅ COMPLETE - -- `solitaire_data::StatsSnapshot` with `update_on_win` / `record_abandoned` / `win_rate` -- Atomic file I/O via `save_stats_to` (`.tmp` → rename) -- `StatsPlugin` in `solitaire_engine` — loads on startup, persists on `GameWonEvent` (win) and `NewGameRequestEvent` (abandoned if move_count>0 and not won) -- Full-window overlay toggled with `S` — games played/won, win rate, streak, best score, fastest, avg -- `StatsPlugin::default()` for production, `StatsPlugin::headless()` for tests (no disk I/O) - -### Phase 5 — Achievements ✅ COMPLETE (14 of ~19) - -- `solitaire_core::achievement` — `AchievementContext` + `AchievementDef` + `ALL_ACHIEVEMENTS` + `check_achievements` -- `solitaire_core::GameState.undo_count` — tracks whether undo was used (for `no_undo` / `speed_and_skill`) -- `solitaire_data::AchievementRecord` + atomic `achievements.json` persistence -- `AchievementPlugin` — on `GameWonEvent`, build context from `StatsResource` + `GameState` + `chrono::Local` hour, evaluate all conditions, persist newly-unlocked records, emit `AchievementUnlockedEvent(id)` -- `AnimationPlugin`'s toast resolves the event's ID to the achievement's name via `achievement_plugin::display_name_for` -- New `StatsUpdate` system set lets `AchievementPlugin` order itself after stats are incremented -- Deferred: `daily_devotee` (needs `PlayerProgress`), `comeback` (needs recycle counter), `zen_winner` (needs modes), `perfectionist` (needs max-score calc). Stubs can be added in later phases. - -### Phase 6 (part 1) — XP, Levels, ProgressPlugin ✅ COMPLETE - -- `solitaire_data::PlayerProgress` with `total_xp`, `level`, daily/weekly/unlock fields -- `level_for_xp(xp)` and `xp_for_win(time, used_undo)` helpers (per ARCHITECTURE.md §13) -- `add_xp(amount) -> prev_level` with `leveled_up_from(prev)` for level-up detection -- Atomic `progress.json` persistence via `save_progress_to` / `load_progress_from` -- `ProgressPlugin` — on `GameWonEvent`, awards XP (base 50 + speed bonus 10–50 + no-undo 25), persists, emits `LevelUpEvent` -- `ProgressUpdate` system set for ordering downstream systems -- `ProgressPlugin::default()` for production, `::headless()` for tests - -### Phase 6 (part 2a) — Daily Challenge + Level-Up Toast ✅ COMPLETE - -- `daily_seed_for(date)` deterministic per-date seed -- `PlayerProgress::record_daily_completion(date)` with streak / reset / idempotency rules -- `DailyChallengePlugin`: today's seed in a resource; pressing **C** starts a daily-seed new game; on winning a daily-seed game, awards **+100 XP**, updates streak, persists, fires `DailyChallengeCompletedEvent` -- `LevelUpEvent` now spawns a toast through `AnimationPlugin` -- `daily_devotee` achievement wired (streak ≥ 7); `AchievementContext` gains `daily_challenge_streak` and reads from `ProgressResource` - -### Phase 6 (part 2b) — Weekly Goals ✅ COMPLETE - -- `solitaire_data::weekly` — `WeeklyGoalKind`, `WeeklyGoalDef`, `WeeklyGoalContext`, `current_iso_week_key`, three starter goals (5 wins / 3 no-undo / 3 fast) -- `PlayerProgress` — `weekly_goal_week_iso`, `roll_weekly_goals_if_new_week`, `record_weekly_progress` -- `WeeklyGoalsPlugin` — on `GameWonEvent`, rolls week if needed, increments matching goals, awards `WEEKLY_GOAL_XP` (75) per completion, fires `WeeklyGoalCompletedEvent` - -### Phase 6 (part 3) — Completion Toasts + Progression Panel ✅ COMPLETE - -- `AnimationPlugin` now surfaces `DailyChallengeCompletedEvent` (shows streak) and `WeeklyGoalCompletedEvent` (shows goal description) as 3-second toasts. -- Stats overlay (**S** key) appends a Progression section: level, total XP, daily streak, and a Weekly Goals list iterating `WEEKLY_GOALS` with `progress/target` for each. - -### Phase 6 (part 4a) — Elapsed Time + Zen Mode ✅ COMPLETE - -- `tick_elapsed_time` in `GamePlugin` ticks `GameState.elapsed_seconds` once per real-world second while not won; `advance_elapsed` is a pure helper for direct unit testing. -- `GameMode` enum (`Classic` / `Zen`) added to `solitaire_core::game_state`. `GameState.mode` field; `GameState::new_with_mode` ctor. Zen suppresses scoring in `move_cards` and `undo`. Field is `#[serde(default)]` for backwards-compatible saved games. -- `NewGameRequestEvent` carries an optional `mode`; `handle_new_game` falls back to the current game's mode when `None`. -- `Z` key starts a fresh Zen game. - -### Phase 6 (part 4b) — Challenge Mode + Level-5 Gate ✅ COMPLETE - -- `GameMode::Challenge` variant in core; `undo()` returns `RuleViolation` in Challenge. -- `solitaire_data::challenge` — `CHALLENGE_SEEDS` static list, `challenge_seed_for(index)` wrapping modulo length, `challenge_count()`. -- `PlayerProgress.challenge_index` (serde-default) tracks progression. -- `ChallengePlugin` advances the cursor on Challenge-mode wins, persists, fires `ChallengeAdvancedEvent`. **X** key starts a Challenge-mode game with the current seed. -- Both **Z** (Zen) and **X** (Challenge) are gated to `level >= CHALLENGE_UNLOCK_LEVEL` (5). - -### Phase 6 (part 4c) — Time Attack + Unlock UI ✅ COMPLETE - -- `GameMode::TimeAttack` variant added to core (no scoring/undo changes — just a session marker). -- `TimeAttackPlugin` (engine) — `TimeAttackResource { active, remaining_secs, wins }` (session-only, not persisted), `TimeAttackEndedEvent { wins }`. **T** starts a session (gated to level ≥ 5) and deals a TimeAttack-mode game; the timer (`TIME_ATTACK_DURATION_SECS = 600.0`) decrements each frame; wins during the active session bump the counter and auto-deal a fresh game. -- `AnimationPlugin` surfaces `TimeAttackEndedEvent` as a 5-second summary toast. -- `StatsPlugin` overlay (**S**) appends an "Unlocks" subsection (card backs / backgrounds, sorted/deduped, "None" when empty) and a live "Time Attack" panel showing remaining minutes/seconds + wins while a session is active. -- Helper `format_id_list` factored out + tested. - -### Phase 7 (part 1) — Help Overlay + Challenge Toast ✅ COMPLETE - -- `HelpPlugin`: **H** or `?` toggles a full-window cheat sheet listing all keybindings (gameplay, mode hotkeys, overlays). 3 unit tests. -- `AnimationPlugin` now surfaces `ChallengeAdvancedEvent` as a 3-second toast ("Challenge N cleared!"). - -### Phase 7 (part 2) — Synthesized SFX + AudioPlugin ✅ COMPLETE - -- New workspace crate `solitaire_assetgen` with bin `gen_sfx`. Synthesizes five 44.1kHz mono 16-bit PCM WAVs from a deterministic LCG noise source + sine/square synths into `assets/audio/`. Run with `cargo run -p solitaire_assetgen --bin gen_sfx`. Output is committed; end users never run the generator. -- `AudioPlugin` (`solitaire_engine`): embeds the WAVs via `include_bytes!()`, decodes once via `kira::StaticSoundData::from_cursor`, plays on `DrawRequestEvent` (flip), `MoveRequestEvent` (place), `NewGameRequestEvent` (deal), `GameWonEvent` (fanfare). -- Backend handle stored as `NonSend` (cpal stream is `!Send` on some platforms). Plugin degrades gracefully if no audio device is available — logs a warning, gameplay continues silently. -- Single decode unit test (`embedded_wavs_decode_successfully`) keeps the loader and generator in sync. - -### Phase 7 (part 3) — MoveRejectedEvent + Pause Menu ✅ COMPLETE - -- New `MoveRejectedEvent { from, to, count }`. `end_drag` fires it when the cursor is over a real pile but `can_place_*` rejects the placement. `AudioPlugin` plays `card_invalid.wav` on it. -- New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more. -- `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour. - -### Phase 7 (part 4) — Settings + SFX Volume Control ✅ COMPLETE - -- New `solitaire_data::Settings { sfx_volume, first_run_complete }` with atomic JSON persistence (`save_settings_to` / `load_settings_from`). `sanitized()` clamps out-of-range volumes after deserialization. Default `sfx_volume = 0.8`. -- New `SettingsPlugin` (engine) with `SettingsResource`, `headless()` ctor, and `SettingsChangedEvent`. **\[** / **\]** adjust SFX volume by `SFX_STEP` (0.1), clamped; persists on change. No-op + no event when already at the rail. -- `AudioPlugin` applies `sfx_volume` to kira's main track at startup and on every `SettingsChangedEvent` (so changes take effect mid-game without restart). -- `AnimationPlugin` shows a brief "SFX: 70%" toast on every change so players see the new value. -- Help cheat sheet lists the **\[** / **\]** keys. -- 4 plugin tests + 6 data tests added — defaults, clamping, round-trip persistence. - -### Phase 7 (part 5) — First-Run Onboarding ✅ COMPLETE - -- New `OnboardingPlugin`. At `PostStartup`, if `Settings.first_run_complete == false`, spawns a centered welcome banner pointing at the **H**/`?` cheat sheet (ZIndex 230). Any key or mouse-button press dismisses it, sets the flag, and persists `settings.json` — returning players never see it again. -- 4 unit tests cover spawn-only-on-first-run, key dismiss, and click dismiss. - -## What Is Next - -Phase 7 polish slate is done. Phase 8 (sync) is next. - -### Phase 8 — Sync - -| Phase | Scope | -|---|---| -| Phase 8A | Local storage scaffolding + `SyncProvider` plumbing in `solitaire_data` | -| Phase 8B | Self-hosted Axum server (auth, sync endpoints, SQLite schema) | -| Phase 8C | `SolitaireServerClient` (`SyncProvider` impl) + `SyncPlugin` lifecycle | -| Phase 8D | GPGS stub fully wired into the settings UI (Android-only `cfg`-gated) | - -### Tiny optional polish (anytime) - -- **Ambient loop**: optional sixth WAV — needs taste, deferred until artwork phase. -- **Block input while paused**: drag/hotkeys still work mid-pause; tightening this would make pause behave more like a true modal. - ---- - -## Important Implementation Notes - -### Versions (Cargo.toml workspace deps) - -- `bevy = "0.15"` (resolved to 0.15.3) — UI via built-in `bevy::ui`, no bevy_egui -- `kira = "0.9"` — audio via `kira` crate directly, no bevy_kira_audio or AssetServer -- `rand = "0.8"` — note: `small_rng` feature is NOT enabled; use `StdRng`, not `SmallRng` - -### Asset strategy - -- No `AssetServer` — assets embedded at compile time using `include_bytes!()` -- Fonts: `Font::try_from_bytes(include_bytes!("../assets/fonts/main.ttf"))` -- Audio: load from `&[u8]` via `kira` `StaticSoundData::from_cursor()` -- Card rendering: procedural (`bevy::prelude::Sprite` + `Text2d`) — no sprite sheets required - -### Hard rules (from CLAUDE.md) -- `solitaire_core` and `solitaire_sync` must NEVER gain Bevy or network dependencies -- No `unwrap()` or `panic!()` in game logic — use `Result<_, MoveError>` everywhere -- All state transitions return `Result` — `debug_assert!` is acceptable for structural invariants -- `SyncPlugin` must NEVER match on `SyncBackend` enum inside a Bevy system — always call through the `SyncProvider` trait -- Atomic file writes only: write to `.tmp` then `rename()` -- `cargo clippy --workspace -- -D warnings` must pass clean -- `cargo test --workspace` must pass clean - -### Lessons from this session -- `rand = "0.8"` without `features = ["small_rng"]` means `SmallRng` is unavailable — use `StdRng` -- `tower-governor` uses underscores in the crate name (not hyphens in Cargo.toml) -- When implementing `draw()` in `GameState`: recycle is unlimited, stop condition is BOTH piles empty simultaneously -- Recycle must push a snapshot (so it can be undone) even though it doesn't count as a "move" - ---- - -## Implementation Plan Document - -The detailed task-by-task plan for Phases 1 and 2 is at: -`docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md` - -For Phase 3 onwards, write a new plan using the `superpowers:writing-plans` skill before starting implementation. - ---- - -## Running the Project - -```bash -# Check everything compiles -cargo check --workspace - -# Run all tests (214 tests, all should pass) -cargo test --workspace - -# Lint (must be zero warnings) -cargo clippy --workspace -- -D warnings - -# Run the game -cargo run -p solitaire_app --features bevy/dynamic_linking -``` diff --git a/docs/android/PLAYABILITY_TODO.md b/docs/android/PLAYABILITY_TODO.md deleted file mode 100644 index ed09ba5..0000000 --- a/docs/android/PLAYABILITY_TODO.md +++ /dev/null @@ -1,270 +0,0 @@ -# Android Playability TODO - -**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK -running on a real device showed the desktop HUD projected onto a -360 dp portrait viewport with no mobile adaptation. This list -tracks the work needed to make the APK genuinely playable, not -just "boots without crashing." - -**Context:** v0.22.3 (signed release APK) builds and launches. -JNI bridges (clipboard, keystore) compile but are untested on -hardware. The work below is UI/UX port work — no architectural -rewrites required. - ---- - -## Reading from the v0.22.3 screenshot - -| Region | Observation | -|--------|-------------| -| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset | -| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp | -| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch | -| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right | -| Card backs | Face-down cards render as solid red squares, not back-art texture | -| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout | -| Bottom edge | No accommodation for Android gesture / home-indicator area | - ---- - -## P0 — Blocking playability - -- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by - `b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin` - query `WindowInsets.getInsets(systemBars())` via JNI on Android; - HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the - change-detection fix-up system re-applies `base_top + insets.top` - whenever the resource updates. Bottom inset is captured but not - yet consumed (waits for bottom-anchored UI). -- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD - column and the right action button row are now capped at - `max_width: 50 %` and the button row + tier-row child Nodes carry - `flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks - to multiple lines (right-justified) and the tier rows wrap - individually instead of overflowing into the action column. On - desktop (≥ 1280 px) the 50 % cap is wider than any natural row - width so the existing single-line layout is unchanged. -- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by - `fcc7337`.* `AssetPlugin::file_path = "../assets"` was set - unconditionally to fix the desktop `cargo run -p solitaire_app` - CWD relativity, but on Android cargo-apk packages the same - directory into the APK at `assets/` and Bevy's - AndroidAssetReader is already rooted there — prepending `../` - walked the reader out of the APK assets root and every load - failed silently. The face-down branch then fell through to the - `card_back_colour(0)` solid-red brick fallback. Gated the - override behind `#[cfg(not(target_os = "android"))]`. -- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout` - was clamping the input window up to `MIN_WINDOW = 800 × 600`, - so a 360 dp phone got laid out as if it were 800-wide and the - outer piles fell outside the actual viewport. Lowered the floor - to 320 × 400 (below the smallest reasonable phone) so real - Android resolutions flow through without clamping, while keeping - a sentinel to guard against degenerate / startup-zero windows. - New regression test `phone_portrait_layout_fits_horizontally` - asserts all 13 piles fit a 360 × 800 viewport. - -## P1 — Touch UX - -- [x] **Suppress keyboard-hint labels on Android.** *Closed - 2026-05-10.* `spawn_action_button` now nulls the `hotkey` - argument on Android via a `#[cfg(target_os = "android")]` rebind, - so the U / Esc / F1 / N chips next to the action row labels - disappear on touch builds. Remaining hint sites swept in P3 — - see full-keyboard-hint-sweep entry below. -- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action - button Node carries `min_width: Val::Px(48.0), min_height: - Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is - a no-op for buttons whose content already exceeds 48 px in - either axis. Applied universally rather than cfg-gated since - Material's guideline applies to all input modes. Cards, pile - markers, modal close buttons not yet audited — track as P3 if - they fall below threshold on hardware. -- [x] **Portrait-first card spacing.** *Closed 2026-05-11.* - `compute_layout` now derives an adaptive `tableau_fan_frac` from the - available vertical space below the tableau row. On height-limited - (desktop) windows the formula returns ≈ 0.25 and the clamp keeps the - existing behaviour. On width-limited (portrait phone) windows — where - card size is constrained by the 9-column horizontal packing — the fan - fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp). - `tableau_facedown_fan_frac` scales proportionally. Both values live in - the `Layout` struct; `card_plugin::card_positions` and - `input_plugin::card_position` / `pile_drop_rect` read from the struct - so rendering and hit-testing stay in sync across viewport sizes. -- [x] **Double-tap auto-move visible feedback.** *Closed 2026-05-11.* - On a recognised double-tap (priority 1 single-card or priority 2 - stack move), the moved card(s) receive a 0.35 s lime flash - (`STATE_SUCCESS` tint + `HintHighlight { remaining: 0.35 }`) before - the move request is written. The flash persists through the card - animation and is cleaned up by the existing `tick_hint_highlight` - system. Hardware trigger-verification remains a manual step — connect - AVD or device and confirm two rapid `TouchPhase::Ended` events within - 0.5 s produce the lime flash. - -## P2 — Polish - -- [x] **Drag responsiveness on touch.** *Closed 2026-05-11.* - Two code-side improvements shipped; final feel confirmation still needs - hardware: - 1. `start_drag` (mouse path) now bails out when a touch is just-pressed - (`Touches::iter_just_pressed()`), ensuring `touch_start_drag` always - owns the drag state on touch-screen devices — including Bevy/Winit - versions that simulate `MouseButton::Left` from the primary touch. - 2. Mobile drag commit threshold lowered 10 px → 8 px, matching Android's - `ViewConfiguration.getScaledTouchSlop()` spec. Smaller threshold → - smaller snap-on-commit and faster perceived response. - **Remaining:** connect AVD or device and verify drag feels responsive - with no stutter; tune threshold further if needed. -- [x] **Long-press menu.** *Closed 2026-05-11.* New system - `radial_open_on_long_press` in `radial_menu.rs` counts up while a - touch is held (`drag.active_touch_id.is_some() && !drag.committed`) - and opens `RightClickRadialState::Active` after 0.5 s — the same - state the right-click path uses. Existing radial infrastructure - then handles everything: - - `radial_track_cursor` extended to fall back to the first active - touch when no cursor position is available, so sliding the held - finger moves the hover ring. - - `radial_handle_release_or_cancel` extended to confirm/cancel on - `Touches::iter_just_released()` in addition to right-mouse release. - - `handle_double_tap` skips when the radial is active (guards a - narrow edge case where the finger lifts at exactly the same frame - the 0.5 s threshold fires). - Hardware verification needed: confirm the 0.5 s hold feel, verify - sliding to a destination and lifting confirms the move. -- [x] **HUD typography.** *Closed 2026-05-11.* New system - `update_hud_typography` fires on `WindowResized` and adjusts Tier-1 - font sizes based on viewport width. Below 480 logical px: Score - `TYPE_HEADLINE` (26) → `TYPE_BODY_LG` (18), Moves/Timer - `TYPE_BODY_LG` (18) → `TYPE_CAPTION` (11), so all three items fit - in the 180 dp HUD column on a 360 dp phone. At ≥ 480 px the - original sizes are restored — desktop/tablet layout unchanged. - `add_message::()` added defensively to `HudPlugin` - so the system works under `MinimalPlugins` in tests. -- [x] **Orientation lock.** *Closed 2026-05-11.* Added - `[package.metadata.android.application.activity]` section to - `solitaire_app/Cargo.toml` with `orientation = "portrait"`. - cargo-apk/ndk-build maps this to `android:screenOrientation="portrait"` - in the generated `AndroidManifest.xml`. Remove (or add a landscape - layout) before enabling auto-rotate. - -## P3 — Asset density - -- [x] **Density-aware card scaling.** *Closed 2026-05-11 — no code change - required.* `WindowResized` fires with **logical** pixels; sprites are - sized in world units (1 world unit = 1 logical pixel); Bevy's renderer - maps logical → physical via `scale_factor` internally. On a 360 dp - 3×-DPI phone, cards are 40 logical dp = 120 physical px. The 256 × 384 px - card textures are **downscaled** to fit (256 → 120 px) — quality is fine. - Upscaling only occurs if `card_width × scale_factor > 256`, i.e. a - tablet with a logical width > 765 dp at 3× DPI — no current target - device falls in that range. Revisit if the game ships on large-screen - high-DPI tablets. -- [x] **App-icon density buckets.** *Closed 2026-05-11.* Created - `solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png` - from the existing `assets/icon/` PNGs (48→mdpi, 64→hdpi, 128→xhdpi, - 256→xxhdpi+xxxhdpi). Added `resources = "res"` to - `[package.metadata.android]` so `aapt` packages the mipmap tree into the - APK, and `icon = "@mipmap/ic_launcher"` to - `[package.metadata.android.application]` so the launcher references it. -- [x] **Full keyboard-hint sweep.** *Closed 2026-05-11.* Extended the - P1 suppression to cover all remaining hint sites: - - `ui_modal.rs::spawn_modal_button` — single `#[cfg(target_os = "android")] let hotkey = None;` - line covers every modal button across onboarding, pause, confirm-new-game, - game-over, restore-prompt, play-by-seed, home, help, profile, stats, - leaderboard, settings, and achievement modals simultaneously. - - `home_plugin.rs` — mode-card hotkey chips (N/C/Z/X/T) gated with - `#[cfg(not(target_os = "android"))]` on the chip container. - - `replay_overlay.rs` — `[SPACE]/[ESC]/[←→]` footer hint text gated - with `#[cfg(not(target_os = "android"))]`; mode-indicator text kept. - - `help_plugin.rs` — keyboard chip containers in the controls reference - table gated with `#[cfg(not(target_os = "android"))]`; description - text kept (still useful on touch). - -## P4 — Stability / runtime - -- [x] **B0004 ECS hierarchy warnings.** *Investigated 2026-05-11 — no - fix required.* B0004 fires via Bevy's `validate_parent_has_component` - hook when a child entity has UI component `C` (e.g. `Node`, - `InheritedVisibility`) but its parent doesn't yet. In Bevy 0.18, - `.despawn()` is recursive (docs: "When a parent is despawned, all - children will also be despawned"), so all `.despawn()` calls in the - engine are safe. The warnings seen on the Pixel 7 AVD during startup - are a component-propagation timing artifact — UI children reach the - hook before the parent's inherited components finish initialising — - not a gameplay defect. `despawn_related::()` in - `card_plugin.rs` is explicit child-only teardown (parent kept alive) - and is correct. No gameplay bugs attributed to these warnings over 2+ - min AVD runtime. -- [x] **AVD functional tests for JNI bridges.** *Closed 2026-05-11.* - Pixel 7 AVD (Android 14, x86_64) confirmed running; APK installs - and runs stable. Key findings: - - **Keystore JNI — verified working.** Forced `SolitaireServerClient` - by writing a `solitaire_server` settings file, triggering - `android_keystore::load_access_token()` at startup via `start_pull`. - Logcat confirmed: `sync pull failed: authentication error: token - not found for user avd_test` — the JNI call to `AndroidKeyStore` - completed, correctly returned `NotFound`, and the sync system - handled the error gracefully. No panic, no crash from the JNI layer. - - **Clipboard JNI — verified working.** Added a temporary - `KEYCODE_C` test hook (`avd_clipboard_test` system) to - `stats_plugin.rs`, rebuilt the APK, pressed C on the AVD. - Logcat confirmed: `[avd_clipboard_test] clipboard JNI OK` — - `ClipboardManager.setPrimaryClip()` succeeded on Android 14. - Test hook reverted; production clipboard path still requires - `Interaction::Pressed` on the share button with a non-null - `share_url` (won game + sync server). - - **Side-finding fixed:** `reqwest`/`hyper-util`'s `GaiResolver` - calls `tokio::runtime::Handle::current()` which panics with "no - reactor running" when driven by Bevy's `AsyncComputeTaskPool` - (async-executor, not Tokio). Fixed in `sync_plugin.rs`: all three - `AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback - now wrap HTTP futures in a temporary - `tokio::runtime::Builder::new_current_thread().enable_all()` runtime. - - **Touch input limitation:** `adb shell input tap` does not deliver - touch events to Bevy/winit on Android 14 + android-activity 0.6.1 - in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally. - ---- - -## P5 — UX polish (2026-05-12) - -- [x] **UX-1 — Modal Done button unreachable in gesture zone.** *Closed - 2026-05-12.* New `apply_safe_area_to_modal_scrims` system in - `safe_area.rs` pads every `ModalScrim` bottom by `insets.bottom / - window.scale_factor()` (logical pixels). Fires when `SafeAreaInsets` - changes AND when a new `ModalScrim` is spawned (`Added` - filter). Verified on device: Settings Done button reachable at physical - y ≈ 1800–2000 (was y ≈ 2232+, inside gesture zone). -- [x] **UX-5b — Home mode selector glyph corruption.** *Closed - 2026-05-12.* `home_plugin.rs` mode glyphs changed from Geometric Shapes - block (U+25xx — absent from FiraMono, renders as rectangles) to card - suits U+2660 ♠ / U+2665 ♥ / U+2666 ♦. Affects Zen, Challenge, and - Daily mode selector buttons shown at level 5+. -- [x] **UX-7 — Help screen HUD button entry wraps to two lines.** *Closed - 2026-05-12.* Android `CONTROL_SECTIONS` entry for ≡ button shortened - from `"Menu: Stats, Settings, Profile, Achievements"` to - `"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs`. - Fits on one line at 360 dp. -- [x] **BUG-3 — Multi-modal stacking (Stats + Profile simultaneously).** *Closed - 2026-05-12.* `handle_menu_button` in `hud_plugin.rs` now checks - `scrims: Query<(), With>` and only calls - `spawn_menu_popover` when `scrims.is_empty()`. Tapping ≡ while any - modal is open is a no-op. Verified on device. - -## Notes / decisions - -* This list is screenshot-driven; expect more items to surface once - P0 unblocks actually moving cards on hardware. -* The pattern across all the bugs is "no one ran the relevant code - path on Android yet." The hard work — Bevy 0.18 on Android, - JNI bridges, signed CI builds — is done. What's left is a - coordinated pass of `#[cfg(target_os = "android")]` gates plus - making `LayoutResource` query the real surface size. -* Where possible, prefer responsive layout (query window size) over - branching `#[cfg]` blocks. Branches are fine for input methods - (touch vs. mouse) but not for screen geometry — a foldable or - desktop window of equivalent size should look the same. diff --git a/docs/android_investigation.md b/docs/android_investigation.md deleted file mode 100644 index 4f8db27..0000000 --- a/docs/android_investigation.md +++ /dev/null @@ -1,247 +0,0 @@ -# Android Port Investigation - -> **Date:** 2026-04-28 -> **Author:** Claude Code -> **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2 - ---- - -## Summary - -A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **12–18 developer-days** to reach a playable-but-rough state. - ---- - -## 1. Bevy on Android — Current Status - -Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation. - -**What works:** -- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices. -- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly. -- Bevy UI (the `bevy::ui` module used for all overlays). -- `WindowResized` events fire correctly, so the layout system will recompute for any screen size. - -**What does not work / needs attention:** -- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking. -- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings. -- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op. -- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI. -- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android. - -**cargo-mobile2 integration for Bevy:** -The standard path is: -1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2` -2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue. -3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point. -4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android. - -**Required `Cargo.toml` changes (workspace level):** -```toml -[target.'cfg(target_os = "android")'.dependencies] -# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim. -# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2. -``` - -**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability. - ---- - -## 2. Audio — `kira` + `DefaultBackend` - -**The problem:** -`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature. - -**Current code behavior:** -The `AudioPlugin::build` already handles the "no audio device" case gracefully: -```rust -let mut manager = AudioManager::::new(AudioManagerSettings::default()).ok(); -if manager.is_none() { - warn!("audio device unavailable; SFX disabled"); -} -``` -This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback. - -**What is needed for working audio on Android:** -- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed. -- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds. -- `include_bytes!` for the WAV assets is compile-time and unaffected by platform. - -**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this. - ---- - -## 3. `keyring` Crate — No Android Backend - -**The problem:** -`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend. - -On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation. - -**Current failure mode:** -Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely. - -**Options for Android credential storage:** - -| Option | Security | Effort | Notes | -|---|---|---|---| -| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. | -| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 4–6 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. | -| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 5–8 days | Proper Android keychain equivalent. Complex JNI. | -| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. | - -**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires: -1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex>`). -2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`. - -**Required `solitaire_data/Cargo.toml` change:** -```toml -[target.'cfg(not(target_os = "android"))'.dependencies] -keyring = { workspace = true } - -[target.'cfg(target_os = "android")'.dependencies] -# keyring is replaced by in-memory storage; no dependency needed -``` - ---- - -## 4. `dirs` Crate — Data Directory on Android - -**The problem:** -`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path. - -**Current code behavior:** -All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`. - -**Fix required:** -Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either: -- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global. -- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`. - -The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`. - -**Effort:** 1–2 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites. - ---- - -## 5. Touch Input — Current State and Gaps - -**What already exists (strong foundation):** - -The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline: - -| System | Purpose | Status | -|---|---|---| -| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete | -| `touch_start_drag` | Begin a touch drag on a face-up card | Complete | -| `touch_follow_drag` | Move card(s) with the active finger | Complete | -| `touch_end_drag` | Resolve the drag (move or reject) | Complete | - -The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically. - -**Gaps for a production Android experience:** - -1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day. - -2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 2–3 days for a minimal floating action button strip. - -3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch. - -4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play. - -5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable. - ---- - -## 6. Additional Issues Not in Scope of the Four Research Areas - -**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`. - -**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux. - -**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day). - -**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates. - -**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected. - ---- - -## 7. Required Changes Per Crate - -### `solitaire_core` and `solitaire_sync` -No changes required. Both are pure Rust with no platform dependencies. - -### `solitaire_data` -| Change | Effort | -|---|---| -| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days | -| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day | -| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days | -| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days | - -### `solitaire_engine` -| Change | Effort | -|---|---| -| Conditionally disable `CursorPlugin` on Android | 0.5 days | -| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days | -| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day | -| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days | -| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days | -| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day | -| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 1–2 days | - -### `solitaire_app` -| Change | Effort | -|---|---| -| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days | -| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days | -| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days | -| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days | - -### Build / Toolchain -| Change | Effort | -|---|---| -| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day | -| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days | -| Get a first build compiling (resolve linker / NDK issues) | 1–2 days | - ---- - -## 8. Estimated Effort - -| Phase | Description | Days | -|---|---|---| -| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 | -| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 | -| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 | -| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 2–3 | -| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 | -| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 | -| **Total** | | **14–17 days** | - -This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 1–2 more days. - ---- - -## 9. Recommended First Step - -**Get the workspace to compile for `aarch64-linux-android` without running.** - -This surfaces all the real linker and dependency errors before writing any gameplay code: - -```bash -# Install toolchain -rustup target add aarch64-linux-android -cargo install --locked cargo-mobile2 - -# In the workspace root: -cargo mobile init # generates android/ directory - -# Attempt a library build targeting Android -cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60 -``` - -The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental. - -Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps. diff --git a/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md b/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md deleted file mode 100644 index 7e6f5e5..0000000 --- a/docs/superpowers/plans/2026-04-20-phase1-2-workspace-core.md +++ /dev/null @@ -1,2170 +0,0 @@ -# Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bootstrap the Cargo workspace with all seven crates compiling cleanly, a blank Bevy window opening, and the complete Klondike game logic in `solitaire_core` fully tested. - -**Architecture:** All seven crates are created with the correct dependency graph. `solitaire_core` contains zero Bevy/network code — pure Rust game rules, scoring, and undo. The GPGS crate is a compile-time stub enforcing the trait contract. Bevy `0.15` is used for the blank window; version may need bumping to match current stable at implementation time. - -**Tech Stack:** Rust 2021 edition, Cargo workspace, Bevy 0.15, bevy_egui, bevy_kira_audio, rand 0.8, serde 1, chrono 0.4, thiserror 1, async-trait 0.1 - ---- - -## Scope - -This plan covers **Phase 1** (workspace + blank Bevy window + GPGS stub) and **Phase 2** (complete `solitaire_core` game logic with tests). Phases 3–8 are out of scope and should be planned separately after these phases pass all gates. - ---- - -## File Map - -### Created in Phase 1 - -| File | Purpose | -|---|---| -| `Cargo.toml` | Workspace manifest with profile settings and shared deps | -| `solitaire_core/Cargo.toml` | Core crate manifest (rand, serde, chrono, thiserror) | -| `solitaire_core/src/lib.rs` | Re-exports all public modules | -| `solitaire_sync/Cargo.toml` | Sync types manifest (serde, uuid, chrono) | -| `solitaire_sync/src/lib.rs` | Minimal stub: SyncPayload, SyncResponse | -| `solitaire_data/Cargo.toml` | Data crate manifest (solitaire_core, solitaire_sync, async-trait, thiserror) | -| `solitaire_data/src/lib.rs` | Minimal stub: SyncError, SyncProvider trait | -| `solitaire_engine/Cargo.toml` | Engine manifest (bevy, bevy_egui, bevy_kira_audio, solitaire_core, solitaire_data) | -| `solitaire_engine/src/lib.rs` | Empty stub | -| `solitaire_server/Cargo.toml` | Server manifest (solitaire_sync, axum, sqlx, etc.) | -| `solitaire_server/src/main.rs` | Stub `fn main() {}` | -| `solitaire_gpgs/Cargo.toml` | GPGS manifest (solitaire_data, async-trait) | -| `solitaire_gpgs/src/lib.rs` | cfg-gated re-exports | -| `solitaire_gpgs/src/stub.rs` | Desktop stub implementing SyncProvider | -| `solitaire_gpgs/src/android.rs` | Android phase TODO placeholder | -| `solitaire_app/Cargo.toml` | App manifest (bevy, solitaire_engine) | -| `solitaire_app/src/main.rs` | Bevy App::new() opening blank window | -| `assets/cards/faces/.gitkeep` | Placeholder | -| `assets/cards/backs/.gitkeep` | Placeholder | -| `assets/backgrounds/.gitkeep` | Placeholder | -| `assets/fonts/.gitkeep` | Placeholder | -| `assets/audio/.gitkeep` | Placeholder | -| `.env.example` | Server environment variable template | - -### Created/expanded in Phase 2 - -| File | Purpose | -|---|---| -| `solitaire_core/src/card.rs` | Suit, Rank, Card types | -| `solitaire_core/src/pile.rs` | PileType, Pile types | -| `solitaire_core/src/error.rs` | MoveError enum | -| `solitaire_core/src/deck.rs` | Deck::new(), Deck::shuffle(), deal_klondike() | -| `solitaire_core/src/rules.rs` | can_place_on_foundation(), can_place_on_tableau() | -| `solitaire_core/src/scoring.rs` | score_move(), score_undo(), compute_time_bonus() | -| `solitaire_core/src/game_state.rs` | GameState, DrawMode, StateSnapshot | - ---- - -## Task 1: Workspace Cargo.toml - -**Files:** -- Create: `Cargo.toml` - -- [ ] **Step 1: Create the workspace Cargo.toml** - -```toml -[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] -# Core utilities -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 } - -# Workspace crates -solitaire_core = { path = "solitaire_core" } -solitaire_sync = { path = "solitaire_sync" } -solitaire_data = { path = "solitaire_data" } -solitaire_engine = { path = "solitaire_engine" } - -# Bevy — check https://crates.io/crates/bevy for latest stable if 0.15 is outdated -bevy = "0.15" -bevy_egui = "0.30" -bevy_kira_audio = "0.21" - -# Server -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" -``` - -> **Note on Bevy versions:** `bevy = "0.15"`, `bevy_egui = "0.30"`, and `bevy_kira_audio = "0.21"` were compatible as of early 2025. Run `cargo search bevy` to check if a newer stable version is current and update accordingly. bevy_egui and bevy_kira_audio versions must match the Bevy major version. - -- [ ] **Step 2: Verify workspace file parses** - -```bash -cargo metadata --no-deps --format-version 1 | grep '"workspace_root"' -``` -Expected: prints the workspace root path without error. - ---- - -## Task 2: solitaire_core Crate Skeleton - -**Files:** -- Create: `solitaire_core/Cargo.toml` -- Create: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Create solitaire_core/Cargo.toml** - -```toml -[package] -name = "solitaire_core" -version.workspace = true -edition.workspace = true - -[dependencies] -serde = { workspace = true } -chrono = { workspace = true } -thiserror = { workspace = true } -rand = { workspace = true } -``` - -- [ ] **Step 2: Create solitaire_core/src/lib.rs (empty stub)** - -```rust -// Modules are added in Phase 2. This file re-exports them. -``` - -- [ ] **Step 3: Verify it compiles** - -```bash -cargo check -p solitaire_core -``` -Expected: `Finished` with no errors. - ---- - -## Task 3: solitaire_sync Stub - -**Files:** -- Create: `solitaire_sync/Cargo.toml` -- Create: `solitaire_sync/src/lib.rs` - -- [ ] **Step 1: Create solitaire_sync/Cargo.toml** - -```toml -[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 } -``` - -- [ ] **Step 2: Create solitaire_sync/src/lib.rs** - -```rust -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Payload sent from client to server (and returned after server merge). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncPayload { - pub user_id: Uuid, - pub last_modified: DateTime, -} - -/// Response returned by the sync server after merging. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncResponse { - pub server_time: DateTime, -} -``` - -> These are minimal stubs. Full fields are added in Phase 8 (Sync System). - -- [ ] **Step 3: Verify** - -```bash -cargo check -p solitaire_sync -``` -Expected: `Finished` with no errors. - ---- - -## Task 4: solitaire_data Stub - -**Files:** -- Create: `solitaire_data/Cargo.toml` -- Create: `solitaire_data/src/lib.rs` - -- [ ] **Step 1: Create solitaire_data/Cargo.toml** - -```toml -[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 } -``` - -- [ ] **Step 2: Create solitaire_data/src/lib.rs** - -```rust -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; - async fn push(&self, payload: &SyncPayload) -> Result; - 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(()) - } -} -``` - -- [ ] **Step 3: Verify** - -```bash -cargo check -p solitaire_data -``` -Expected: `Finished` with no errors. - ---- - -## Task 5: solitaire_engine Stub - -**Files:** -- Create: `solitaire_engine/Cargo.toml` -- Create: `solitaire_engine/src/lib.rs` - -- [ ] **Step 1: Create solitaire_engine/Cargo.toml** - -```toml -[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 } -``` - -- [ ] **Step 2: Create solitaire_engine/src/lib.rs** - -```rust -// Bevy plugins are added in Phase 3. -// This crate will expose: CardPlugin, TablePlugin, AnimationPlugin, -// AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin. -``` - -- [ ] **Step 3: Verify** - -```bash -cargo check -p solitaire_engine -``` -Expected: `Finished` with no errors. - ---- - -## Task 6: solitaire_server Stub - -**Files:** -- Create: `solitaire_server/Cargo.toml` -- Create: `solitaire_server/src/main.rs` - -- [ ] **Step 1: Create solitaire_server/Cargo.toml** - -```toml -[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 } -``` - -- [ ] **Step 2: Create solitaire_server/src/main.rs** - -```rust -// Full server implementation added in Phase 8C. -fn main() {} -``` - -- [ ] **Step 3: Verify** - -```bash -cargo check -p solitaire_server -``` -Expected: `Finished` with no errors. - ---- - -## Task 7: solitaire_gpgs Stub (GPGS Compile-Time Stub) - -**Files:** -- Create: `solitaire_gpgs/Cargo.toml` -- Create: `solitaire_gpgs/src/lib.rs` -- Create: `solitaire_gpgs/src/stub.rs` -- Create: `solitaire_gpgs/src/android.rs` - -- [ ] **Step 1: Create solitaire_gpgs/Cargo.toml** - -```toml -[package] -name = "solitaire_gpgs" -version.workspace = true -edition.workspace = true - -[dependencies] -solitaire_data = { workspace = true } -solitaire_sync = { workspace = true } -async-trait = { workspace = true } -``` - -- [ ] **Step 2: Create solitaire_gpgs/src/lib.rs** - -```rust -#[cfg(target_os = "android")] -mod android; - -#[cfg(not(target_os = "android"))] -mod stub; - -// Android placeholder (TODO block only — no JNI yet) -mod android_placeholder; - -#[cfg(not(target_os = "android"))] -pub use stub::GpgsClient; - -#[cfg(target_os = "android")] -pub use android::GpgsClient; -``` - -Wait — the android module must not be compiled on non-android, but we still want the TODO file to exist. Remove the android_placeholder re-export above and instead keep android.rs only compiled on android via cfg. The lib.rs should be: - -```rust -#[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; -``` - -- [ ] **Step 3: Create solitaire_gpgs/src/stub.rs** - -```rust -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 { - Err(SyncError::UnsupportedPlatform) - } - - async fn push(&self, _payload: &SyncPayload) -> Result { - Err(SyncError::UnsupportedPlatform) - } - - fn backend_name(&self) -> &'static str { - "Google Play Games (unavailable on this platform)" - } - - fn is_authenticated(&self) -> bool { - false - } -} -``` - -- [ ] **Step 4: Create solitaire_gpgs/src/android.rs** - -```rust -// 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) -``` - -> This file is only compiled on Android (`#[cfg(target_os = "android")]`), so it can contain a bare TODO comment without a `GpgsClient` struct definition until the Android phase. - -- [ ] **Step 5: Verify** - -```bash -cargo check -p solitaire_gpgs -``` -Expected: `Finished` with no errors. - ---- - -## Task 8: solitaire_app — Blank Bevy Window - -**Files:** -- Create: `solitaire_app/Cargo.toml` -- Create: `solitaire_app/src/main.rs` - -- [ ] **Step 1: Create solitaire_app/Cargo.toml** - -```toml -[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 } -``` - -- [ ] **Step 2: Create solitaire_app/src/main.rs** - -```rust -use bevy::prelude::*; - -fn main() { - App::new() - .add_plugins( - DefaultPlugins.set(WindowPlugin { - primary_window: Some(Window { - title: "Ferrous Solitaire".into(), - resolution: (1280.0, 800.0).into(), - ..default() - }), - ..default() - }), - ) - .run(); -} -``` - -- [ ] **Step 3: Run the app to verify the window opens** - -```bash -cargo run -p solitaire_app --features bevy/dynamic_linking -``` -Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal. - ---- - -## Task 9: Assets Directory + .env.example - -**Files:** -- Create: `assets/cards/faces/.gitkeep` -- Create: `assets/cards/backs/.gitkeep` -- Create: `assets/backgrounds/.gitkeep` -- Create: `assets/fonts/.gitkeep` -- Create: `assets/audio/.gitkeep` -- Create: `.env.example` - -- [ ] **Step 1: Create asset directory placeholders** - -```bash -mkdir -p assets/cards/faces assets/cards/backs assets/backgrounds assets/fonts assets/audio -touch assets/cards/faces/.gitkeep -touch assets/cards/backs/.gitkeep -touch assets/backgrounds/.gitkeep -touch assets/fonts/.gitkeep -touch assets/audio/.gitkeep -``` - -- [ ] **Step 2: Create .env.example** - -``` -DATABASE_URL=sqlite://solitaire.db -JWT_SECRET=replace_with_64_char_hex_from_openssl_rand_hex_32 -SERVER_PORT=8080 -ADMIN_USERNAME=admin -``` - -- [ ] **Step 3: Verify full workspace compiles and tests pass** - -```bash -cargo test --workspace -cargo clippy --workspace -- -D warnings -``` -Expected: all tests pass (zero tests exist yet, so 0 passed), clippy reports zero warnings. - -- [ ] **Step 4: Commit Phase 1** - -```bash -git init -git add Cargo.toml solitaire_core solitaire_sync solitaire_data solitaire_engine solitaire_server solitaire_gpgs solitaire_app assets .env.example -git commit -m "feat(workspace): initialize all seven crates with stubs and blank Bevy window" -``` - ---- - -## Task 10: solitaire_core — Card Types (TDD) - -**Files:** -- Create: `solitaire_core/src/card.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write failing tests for card types** - -Create `solitaire_core/src/card.rs` with the tests block first, before any implementation: - -```rust -use serde::{Deserialize, Serialize}; - -// --- types added in Step 2 --- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rank_value_ace_is_one() { - assert_eq!(Rank::Ace.value(), 1); - } - - #[test] - fn rank_value_king_is_thirteen() { - assert_eq!(Rank::King.value(), 13); - } - - #[test] - fn rank_values_are_sequential() { - let ranks = [ - Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, - Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, - Rank::Jack, Rank::Queen, Rank::King, - ]; - for (i, r) in ranks.iter().enumerate() { - assert_eq!(r.value(), (i + 1) as u8); - } - } - - #[test] - fn suit_red_is_diamonds_and_hearts() { - assert!(Suit::Diamonds.is_red()); - assert!(Suit::Hearts.is_red()); - assert!(!Suit::Clubs.is_red()); - assert!(!Suit::Spades.is_red()); - } - - #[test] - fn suit_black_is_clubs_and_spades() { - assert!(Suit::Clubs.is_black()); - assert!(Suit::Spades.is_black()); - assert!(!Suit::Diamonds.is_black()); - assert!(!Suit::Hearts.is_black()); - } - - #[test] - fn card_starts_face_down() { - let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false }; - assert!(!card.face_up); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -20 -``` -Expected: compile error `cannot find type 'Rank' in this scope` (or similar). - -- [ ] **Step 3: Implement card types** - -Replace the `// --- types added in Step 2 ---` comment with: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Suit { - Clubs, - Diamonds, - Hearts, - Spades, -} - -impl Suit { - /// Returns true for red suits (Diamonds, Hearts). - pub fn is_red(self) -> bool { - matches!(self, Suit::Diamonds | Suit::Hearts) - } - - /// Returns true for black suits (Clubs, Spades). - pub fn is_black(self) -> bool { - !self.is_red() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Rank { - Ace, - Two, - Three, - Four, - Five, - Six, - Seven, - Eight, - Nine, - Ten, - Jack, - Queen, - King, -} - -impl Rank { - /// Numeric value: Ace = 1, King = 13. - pub fn value(self) -> u8 { - match self { - Rank::Ace => 1, - Rank::Two => 2, - Rank::Three => 3, - Rank::Four => 4, - Rank::Five => 5, - Rank::Six => 6, - Rank::Seven => 7, - Rank::Eight => 8, - Rank::Nine => 9, - Rank::Ten => 10, - Rank::Jack => 11, - Rank::Queen => 12, - Rank::King => 13, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Card { - pub id: u32, - pub suit: Suit, - pub rank: Rank, - pub face_up: bool, -} -``` - -- [ ] **Step 4: Update lib.rs to expose the module** - -Replace the content of `solitaire_core/src/lib.rs` with: - -```rust -pub mod card; -``` - -- [ ] **Step 5: Run tests — expect pass** - -```bash -cargo test -p solitaire_core -``` -Expected: `test card::tests::rank_value_ace_is_one ... ok` and all other card tests pass. - -- [ ] **Step 6: Run clippy** - -```bash -cargo clippy -p solitaire_core -- -D warnings -``` -Expected: no warnings. - ---- - -## Task 11: solitaire_core — Pile Types (TDD) - -**Files:** -- Create: `solitaire_core/src/pile.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write tests first in pile.rs** - -```rust -use serde::{Deserialize, Serialize}; -use crate::card::{Card, Suit}; - -// --- types added in Step 2 --- - -#[cfg(test)] -mod tests { - use super::*; - use crate::card::{Card, Rank, Suit}; - - #[test] - fn new_pile_is_empty() { - let pile = Pile::new(PileType::Stock); - assert!(pile.cards.is_empty()); - } - - #[test] - fn pile_top_returns_last_card() { - let mut pile = Pile::new(PileType::Waste); - pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }); - pile.cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Two, face_up: true }); - assert_eq!(pile.top().unwrap().id, 1); - } - - #[test] - fn pile_top_on_empty_is_none() { - let pile = Pile::new(PileType::Waste); - assert!(pile.top().is_none()); - } - - #[test] - fn pile_type_foundation_uses_suit() { - let p1 = PileType::Foundation(Suit::Hearts); - let p2 = PileType::Foundation(Suit::Spades); - assert_ne!(p1, p2); - } - - #[test] - fn pile_type_tableau_uses_index() { - let p0 = PileType::Tableau(0); - let p6 = PileType::Tableau(6); - assert_ne!(p0, p6); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -10 -``` -Expected: compile error referencing missing `Pile` or `PileType`. - -- [ ] **Step 3: Implement pile types** - -Replace `// --- types added in Step 2 ---` with: - -```rust -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum PileType { - Stock, - Waste, - Foundation(Suit), - Tableau(usize), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Pile { - pub pile_type: PileType, - pub cards: Vec, -} - -impl Pile { - pub fn new(pile_type: PileType) -> Self { - Self { pile_type, cards: Vec::new() } - } - - /// Returns a reference to the top (last) card, or None if empty. - pub fn top(&self) -> Option<&Card> { - self.cards.last() - } -} -``` - -- [ ] **Step 4: Add pile module to lib.rs** - -```rust -pub mod card; -pub mod pile; -``` - -- [ ] **Step 5: Run tests and clippy** - -```bash -cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings -``` -Expected: all tests pass, no warnings. - ---- - -## Task 12: solitaire_core — MoveError (TDD) - -**Files:** -- Create: `solitaire_core/src/error.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write tests first** - -```rust -use thiserror::Error; - -// --- type added in Step 2 --- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn move_error_displays_message() { - let e = MoveError::RuleViolation("king only on empty".into()); - assert!(e.to_string().contains("king only on empty")); - } - - #[test] - fn move_error_undo_stack_empty_message() { - let e = MoveError::UndoStackEmpty; - assert!(!e.to_string().is_empty()); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -10 -``` - -- [ ] **Step 3: Implement MoveError** - -```rust -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum MoveError { - #[error("invalid source pile")] - InvalidSource, - #[error("invalid destination pile")] - InvalidDestination, - #[error("source pile is empty")] - EmptySource, - #[error("move violates rules: {0}")] - RuleViolation(String), - #[error("undo stack is empty")] - UndoStackEmpty, - #[error("game is already won")] - GameAlreadyWon, - #[error("stock and waste are both empty")] - StockEmpty, -} -``` - -- [ ] **Step 4: Add to lib.rs** - -```rust -pub mod card; -pub mod error; -pub mod pile; -``` - -- [ ] **Step 5: Run tests and clippy** - -```bash -cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings -``` -Expected: all tests pass, no warnings. - ---- - -## Task 13: solitaire_core — Deck and Deal (TDD) - -**Files:** -- Create: `solitaire_core/src/deck.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write tests first** - -```rust -use rand::{seq::SliceRandom, SeedableRng}; -use rand::rngs::SmallRng; -use crate::card::{Card, Rank, Suit}; -use crate::pile::{Pile, PileType}; - -// --- implementations added in Step 2 --- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deck_new_has_52_cards() { - let deck = Deck::new(); - assert_eq!(deck.cards.len(), 52); - } - - #[test] - fn deck_new_has_all_unique_ids() { - let deck = Deck::new(); - let mut ids: Vec = deck.cards.iter().map(|c| c.id).collect(); - ids.dedup(); - assert_eq!(ids.len(), 52); - } - - #[test] - fn deck_new_has_all_suits_and_ranks() { - let deck = Deck::new(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - for rank in [ - Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, - Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, - Rank::Jack, Rank::Queen, Rank::King, - ] { - assert!( - deck.cards.iter().any(|c| c.suit == suit && c.rank == rank), - "missing {:?} {:?}", - rank, - suit - ); - } - } - } - - #[test] - fn shuffle_same_seed_produces_same_order() { - let mut d1 = Deck::new(); - d1.shuffle(42); - let mut d2 = Deck::new(); - d2.shuffle(42); - assert_eq!(d1.cards, d2.cards); - } - - #[test] - fn shuffle_different_seeds_produce_different_orders() { - let mut d1 = Deck::new(); - d1.shuffle(1); - let mut d2 = Deck::new(); - d2.shuffle(2); - assert_ne!(d1.cards, d2.cards); - } - - #[test] - fn deal_klondike_produces_correct_pile_sizes() { - let mut deck = Deck::new(); - deck.shuffle(0); - let (tableau, stock) = deal_klondike(deck); - - // Tableau column i has i+1 cards - for (i, pile) in tableau.iter().enumerate() { - assert_eq!(pile.cards.len(), i + 1, "tableau col {} wrong size", i); - } - - // Stock has 52 - (1+2+3+4+5+6+7) = 52 - 28 = 24 cards - assert_eq!(stock.cards.len(), 24); - } - - #[test] - fn deal_klondike_top_card_of_each_tableau_column_is_face_up() { - let mut deck = Deck::new(); - deck.shuffle(0); - let (tableau, _) = deal_klondike(deck); - for pile in &tableau { - assert!(pile.cards.last().unwrap().face_up, "top card not face up"); - } - } - - #[test] - fn deal_klondike_non_top_cards_are_face_down() { - let mut deck = Deck::new(); - deck.shuffle(0); - let (tableau, _) = deal_klondike(deck); - for pile in &tableau { - let non_top = &pile.cards[..pile.cards.len().saturating_sub(1)]; - for card in non_top { - assert!(!card.face_up, "non-top card should be face down"); - } - } - } - - #[test] - fn deal_klondike_stock_cards_are_face_down() { - let mut deck = Deck::new(); - deck.shuffle(0); - let (_, stock) = deal_klondike(deck); - for card in &stock.cards { - assert!(!card.face_up); - } - } - - #[test] - fn deal_klondike_all_52_cards_present() { - let mut deck = Deck::new(); - deck.shuffle(99); - let (tableau, stock) = deal_klondike(deck); - let mut all_ids: Vec = stock.cards.iter().map(|c| c.id).collect(); - for pile in &tableau { - all_ids.extend(pile.cards.iter().map(|c| c.id)); - } - all_ids.sort_unstable(); - assert_eq!(all_ids, (0u32..52).collect::>()); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -10 -``` - -- [ ] **Step 3: Implement Deck and deal_klondike** - -```rust -pub struct Deck { - pub cards: Vec, -} - -const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; -const ALL_RANKS: [Rank; 13] = [ - Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, - Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, - Rank::Jack, Rank::Queen, Rank::King, -]; - -impl Deck { - pub fn new() -> Self { - let mut cards = Vec::with_capacity(52); - let mut id = 0u32; - for &suit in &ALL_SUITS { - for &rank in &ALL_RANKS { - cards.push(Card { id, suit, rank, face_up: false }); - id += 1; - } - } - Self { cards } - } - - /// Shuffle using Fisher-Yates with a seeded SmallRng for cross-platform determinism. - pub fn shuffle(&mut self, seed: u64) { - let mut rng = SmallRng::seed_from_u64(seed); - self.cards.shuffle(&mut rng); - } -} - -impl Default for Deck { - fn default() -> Self { - Self::new() - } -} - -/// Deal a standard Klondike layout from a (pre-shuffled) deck. -/// Returns 7 tableau piles and the remaining stock pile. -/// Tableau column `i` contains `i+1` cards; only the top card is face-up. -pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) { - let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i))); - let mut cards = deck.cards.into_iter(); - - for col in 0..7usize { - for row in 0..=col { - let mut card = cards.next().expect("deck has 52 cards"); - card.face_up = row == col; - tableau[col].cards.push(card); - } - } - - let mut stock = Pile::new(PileType::Stock); - stock.cards.extend(cards); - (tableau, stock) -} -``` - -- [ ] **Step 4: Add to lib.rs** - -```rust -pub mod card; -pub mod deck; -pub mod error; -pub mod pile; -``` - -- [ ] **Step 5: Run tests and clippy** - -```bash -cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings -``` -Expected: all deck tests pass, no warnings. - ---- - -## Task 14: solitaire_core — Move Validation Rules (TDD) - -**Files:** -- Create: `solitaire_core/src/rules.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write failing tests** - -```rust -use crate::card::{Card, Rank, Suit}; -use crate::pile::{Pile, PileType}; - -// --- functions added in Step 2 --- - -#[cfg(test)] -mod tests { - use super::*; - - fn make_card(suit: Suit, rank: Rank) -> Card { - Card { id: 0, suit, rank, face_up: true } - } - - fn pile_with(pile_type: PileType, cards: Vec) -> Pile { - Pile { pile_type, cards } - } - - // --- Foundation rules --- - - #[test] - fn foundation_ace_on_empty_pile_is_valid() { - let card = make_card(Suit::Hearts, Rank::Ace); - let pile = Pile::new(PileType::Foundation(Suit::Hearts)); - assert!(can_place_on_foundation(&card, &pile, Suit::Hearts)); - } - - #[test] - fn foundation_non_ace_on_empty_pile_is_invalid() { - let card = make_card(Suit::Hearts, Rank::Two); - let pile = Pile::new(PileType::Foundation(Suit::Hearts)); - assert!(!can_place_on_foundation(&card, &pile, Suit::Hearts)); - } - - #[test] - fn foundation_two_on_ace_same_suit_is_valid() { - let card = make_card(Suit::Clubs, Rank::Two); - let pile = pile_with( - PileType::Foundation(Suit::Clubs), - vec![make_card(Suit::Clubs, Rank::Ace)], - ); - assert!(can_place_on_foundation(&card, &pile, Suit::Clubs)); - } - - #[test] - fn foundation_wrong_suit_is_invalid() { - let card = make_card(Suit::Hearts, Rank::Ace); - let pile = Pile::new(PileType::Foundation(Suit::Spades)); - assert!(!can_place_on_foundation(&card, &pile, Suit::Spades)); - } - - #[test] - fn foundation_skipping_rank_is_invalid() { - let card = make_card(Suit::Diamonds, Rank::Three); - let pile = pile_with( - PileType::Foundation(Suit::Diamonds), - vec![make_card(Suit::Diamonds, Rank::Ace)], - ); - assert!(!can_place_on_foundation(&card, &pile, Suit::Diamonds)); - } - - // --- Tableau rules --- - - #[test] - fn tableau_king_on_empty_pile_is_valid() { - let card = make_card(Suit::Hearts, Rank::King); - let pile = Pile::new(PileType::Tableau(0)); - assert!(can_place_on_tableau(&card, &pile)); - } - - #[test] - fn tableau_non_king_on_empty_pile_is_invalid() { - let card = make_card(Suit::Hearts, Rank::Queen); - let pile = Pile::new(PileType::Tableau(0)); - assert!(!can_place_on_tableau(&card, &pile)); - } - - #[test] - fn tableau_red_on_black_one_lower_is_valid() { - let card = make_card(Suit::Hearts, Rank::Nine); // red 9 - let pile = pile_with( - PileType::Tableau(0), - vec![make_card(Suit::Spades, Rank::Ten)], // black 10 - ); - assert!(can_place_on_tableau(&card, &pile)); - } - - #[test] - fn tableau_same_color_is_invalid() { - let card = make_card(Suit::Clubs, Rank::Nine); // black 9 - let pile = pile_with( - PileType::Tableau(0), - vec![make_card(Suit::Spades, Rank::Ten)], // black 10 - ); - assert!(!can_place_on_tableau(&card, &pile)); - } - - #[test] - fn tableau_wrong_rank_difference_is_invalid() { - let card = make_card(Suit::Hearts, Rank::Eight); // red 8 - let pile = pile_with( - PileType::Tableau(0), - vec![make_card(Suit::Spades, Rank::Ten)], // black 10 - ); - assert!(!can_place_on_tableau(&card, &pile)); - } - - #[test] - fn tableau_black_on_red_one_lower_is_valid() { - let card = make_card(Suit::Clubs, Rank::Six); // black 6 - let pile = pile_with( - PileType::Tableau(0), - vec![make_card(Suit::Hearts, Rank::Seven)], // red 7 - ); - assert!(can_place_on_tableau(&card, &pile)); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -10 -``` - -- [ ] **Step 3: Implement rules** - -```rust -use crate::card::{Card, Suit}; -use crate::pile::Pile; - -/// Can `card` be placed on the foundation pile for `suit`? -pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool { - if card.suit != suit { - return false; - } - match pile.cards.last() { - None => card.rank.value() == 1, // Only Ace starts a foundation - Some(top) => card.rank.value() == top.rank.value() + 1, - } -} - -/// Can `card` (or the bottom card of a sequence) be placed on `pile` in the tableau? -pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool { - match pile.cards.last() { - None => card.rank.value() == 13, // Only King goes on empty tableau - Some(top) => { - card.rank.value() + 1 == top.rank.value() - && card.suit.is_red() != top.suit.is_red() - } - } -} -``` - -- [ ] **Step 4: Add to lib.rs** - -```rust -pub mod card; -pub mod deck; -pub mod error; -pub mod pile; -pub mod rules; -``` - -- [ ] **Step 5: Run tests and clippy** - -```bash -cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings -``` -Expected: all rule tests pass, no warnings. - ---- - -## Task 15: solitaire_core — Scoring (TDD) - -**Files:** -- Create: `solitaire_core/src/scoring.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write failing tests** - -```rust -use crate::pile::PileType; -use crate::card::Suit; - -// --- functions added in Step 2 --- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn move_to_foundation_scores_ten() { - assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10); - assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10); - } - - #[test] - fn waste_to_tableau_scores_five() { - assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5); - } - - #[test] - fn tableau_to_tableau_scores_zero() { - assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0); - } - - #[test] - fn undo_penalty_is_negative_fifteen() { - assert_eq!(score_undo(), -15); - } - - #[test] - fn time_bonus_at_100_seconds_is_7000() { - assert_eq!(compute_time_bonus(100), 7000); - } - - #[test] - fn time_bonus_at_zero_seconds_is_zero() { - assert_eq!(compute_time_bonus(0), 0); - } - - #[test] - fn time_bonus_at_one_second_is_capped_at_i32_max() { - // 700_000 / 1 = 700_000 which fits in i32 fine - assert_eq!(compute_time_bonus(1), 700_000); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -10 -``` - -- [ ] **Step 3: Implement scoring functions** - -```rust -use crate::pile::PileType; - -/// Returns the score delta for moving cards from `from` to `to`. -/// Windows XP Standard scoring: -/// +10 for any card reaching the foundation -/// +5 for waste → tableau -/// 0 for all other moves -pub fn score_move(from: &PileType, to: &PileType) -> i32 { - match to { - PileType::Foundation(_) => 10, - PileType::Tableau(_) => { - if matches!(from, PileType::Waste) { 5 } else { 0 } - } - _ => 0, - } -} - -/// Score penalty applied when the player uses undo. -pub fn score_undo() -> i32 { - -15 -} - -/// Time bonus added to score on win: 700_000 / elapsed_seconds. -/// Returns 0 if elapsed_seconds is 0 (avoids division by zero). -pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 { - if elapsed_seconds == 0 { - return 0; - } - (700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32 -} -``` - -- [ ] **Step 4: Add to lib.rs** - -```rust -pub mod card; -pub mod deck; -pub mod error; -pub mod pile; -pub mod rules; -pub mod scoring; -``` - -- [ ] **Step 5: Run tests and clippy** - -```bash -cargo test -p solitaire_core && cargo clippy -p solitaire_core -- -D warnings -``` -Expected: all scoring tests pass, no warnings. - ---- - -## Task 16: solitaire_core — GameState (TDD) - -**Files:** -- Create: `solitaire_core/src/game_state.rs` -- Modify: `solitaire_core/src/lib.rs` - -- [ ] **Step 1: Write failing tests** - -```rust -use std::collections::HashMap; -use serde::{Deserialize, Serialize}; -use crate::card::{Card, Rank, Suit}; -use crate::deck::{deal_klondike, Deck}; -use crate::error::MoveError; -use crate::pile::{Pile, PileType}; -use crate::rules::{can_place_on_foundation, can_place_on_tableau}; -use crate::scoring::{compute_time_bonus, score_move, score_undo}; - -// --- types and implementations added in Steps 2-4 --- - -#[cfg(test)] -mod tests { - use super::*; - - fn new_game() -> GameState { - GameState::new(42, DrawMode::DrawOne) - } - - // --- Initial state --- - - #[test] - fn new_game_has_28_tableau_cards() { - let g = new_game(); - let total: usize = (0..7).map(|i| g.piles[&PileType::Tableau(i)].cards.len()).sum(); - assert_eq!(total, 28); - } - - #[test] - fn new_game_stock_has_24_cards() { - let g = new_game(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), 24); - } - - #[test] - fn new_game_waste_is_empty() { - let g = new_game(); - assert!(g.piles[&PileType::Waste].cards.is_empty()); - } - - #[test] - fn new_game_foundations_are_empty() { - let g = new_game(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty()); - } - } - - #[test] - fn new_game_is_not_won() { - let g = new_game(); - assert!(!g.is_won); - } - - // --- Seeded reproducibility --- - - #[test] - fn same_seed_produces_identical_layout() { - let g1 = GameState::new(12345, DrawMode::DrawOne); - let g2 = GameState::new(12345, DrawMode::DrawOne); - for i in 0..7 { - assert_eq!( - g1.piles[&PileType::Tableau(i)].cards, - g2.piles[&PileType::Tableau(i)].cards - ); - } - assert_eq!( - g1.piles[&PileType::Stock].cards, - g2.piles[&PileType::Stock].cards - ); - } - - #[test] - fn different_seeds_produce_different_layouts() { - let g1 = GameState::new(1, DrawMode::DrawOne); - let g2 = GameState::new(2, DrawMode::DrawOne); - // Almost certainly different (statistically) - let t1: Vec = g1.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect(); - let t2: Vec = g2.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect(); - assert_ne!(t1, t2); - } - - // --- Draw --- - - #[test] - fn draw_one_moves_one_card_to_waste() { - let mut g = new_game(); - let stock_before = g.piles[&PileType::Stock].cards.len(); - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before - 1); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 1); - } - - #[test] - fn drawn_card_is_face_up() { - let mut g = new_game(); - g.draw().unwrap(); - assert!(g.piles[&PileType::Waste].cards.last().unwrap().face_up); - } - - #[test] - fn draw_three_moves_up_to_three_cards() { - let mut g = GameState::new(42, DrawMode::DrawThree); - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); - assert_eq!(g.piles[&PileType::Stock].cards.len(), 21); - } - - #[test] - fn draw_from_empty_stock_recycles_waste() { - let mut g = new_game(); - // Exhaust stock - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - let waste_count = g.piles[&PileType::Waste].cards.len(); - assert!(waste_count > 0); - // Drawing again should recycle - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); - assert!(g.piles[&PileType::Waste].cards.is_empty()); - } - - #[test] - fn draw_from_empty_stock_and_waste_returns_error() { - let mut g = new_game(); - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); // recycle - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - // Now both are empty - let result = g.draw(); - assert_eq!(result, Err(MoveError::StockEmpty)); - } - - // --- Move validation --- - - #[test] - fn move_face_down_card_returns_rule_violation() { - let mut g = new_game(); - // Tableau(0) has 1 card (face up). Tableau(1) has 2 cards, bottom is face down. - // Try to move the face-down card (index 0 of Tableau(1)) - let result = g.move_cards(PileType::Tableau(1), PileType::Tableau(0), 2); - // Bottom card of Tableau(1) is face-down; this should be a rule violation - // (unless by coincidence the move is valid, which is fine too — test intent is no panic) - // We just verify it either succeeds or returns a rule violation, never panics. - let _ = result; - } - - #[test] - fn move_zero_cards_returns_rule_violation() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 0); - assert!(matches!(result, Err(MoveError::RuleViolation(_)))); - } - - #[test] - fn move_to_stock_returns_invalid_destination() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Stock, 1); - assert_eq!(result, Err(MoveError::InvalidDestination)); - } - - #[test] - fn move_to_waste_returns_invalid_destination() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Waste, 1); - assert_eq!(result, Err(MoveError::InvalidDestination)); - } - - // --- Win detection --- - - #[test] - fn win_detection_all_foundations_complete() { - let mut g = new_game(); - // Fill all foundations manually - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - g.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); - for rank in [ - Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, - Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, - Rank::Jack, Rank::Queen, Rank::King, - ] { - g.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.push( - Card { id: 0, suit, rank, face_up: true } - ); - } - } - assert!(g.check_win()); - } - - #[test] - fn win_detection_incomplete_foundations_is_false() { - let g = new_game(); - assert!(!g.check_win()); - } - - // --- Undo --- - - #[test] - fn undo_empty_stack_returns_error() { - let mut g = new_game(); - assert_eq!(g.undo(), Err(MoveError::UndoStackEmpty)); - } - - #[test] - fn undo_after_draw_restores_pile_sizes() { - let mut g = new_game(); - let stock_before = g.piles[&PileType::Stock].cards.len(); - let waste_before = g.piles[&PileType::Waste].cards.len(); - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); - assert_eq!(g.piles[&PileType::Waste].cards.len(), waste_before); - } - - #[test] - fn undo_applies_score_penalty() { - let mut g = new_game(); - let score_before = g.score; - g.draw().unwrap(); - g.undo().unwrap(); - // Score = score_before + score_undo() = score_before - 15, floored at 0 - let expected = (score_before + score_undo()).max(0); - assert_eq!(g.score, expected); - } - - #[test] - fn undo_stack_capped_at_64() { - let mut g = new_game(); - // Perform 70 draws (stock will recycle as needed) - for _ in 0..70 { - let _ = g.draw(); - } - // Undo stack should not exceed 64 entries - assert!(g.undo_stack_len() <= 64); - } - - // --- Scoring --- - - #[test] - fn score_does_not_go_below_zero() { - let mut g = new_game(); - // Apply undo penalty repeatedly; score should floor at 0 - for _ in 0..5 { - g.draw().unwrap(); - g.undo().unwrap(); - } - assert!(g.score >= 0); - } - - // --- Auto-complete --- - - #[test] - fn auto_complete_false_when_stock_not_empty() { - let g = new_game(); - assert!(!g.check_auto_complete()); - } - - #[test] - fn auto_complete_false_when_face_down_cards_remain() { - let mut g = new_game(); - // Empty stock and waste but leave face-down cards in tableau - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - // Tableau(1) has a face-down card at index 0 - assert!(!g.check_auto_complete()); - } - - // --- Time bonus --- - - #[test] - fn time_bonus_is_zero_when_elapsed_is_zero() { - let mut g = new_game(); - g.elapsed_seconds = 0; - assert_eq!(g.compute_time_bonus(), 0); - } - - #[test] - fn time_bonus_at_100_seconds() { - let mut g = new_game(); - g.elapsed_seconds = 100; - assert_eq!(g.compute_time_bonus(), 7000); - } -} -``` - -- [ ] **Step 2: Run tests — expect compile failure** - -```bash -cargo test -p solitaire_core 2>&1 | head -10 -``` - -- [ ] **Step 3: Implement GameState types** - -Create `solitaire_core/src/game_state.rs` with full content: - -```rust -use std::collections::HashMap; -use serde::{Deserialize, Serialize}; -use crate::card::{Card, Suit}; -use crate::deck::{deal_klondike, Deck}; -use crate::error::MoveError; -use crate::pile::{Pile, PileType}; -use crate::rules::{can_place_on_foundation, can_place_on_tableau}; -use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo}; - -const MAX_UNDO_STACK: usize = 64; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum DrawMode { - DrawOne, - DrawThree, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StateSnapshot { - piles: HashMap, - score: i32, - move_count: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GameState { - pub piles: HashMap, - pub draw_mode: DrawMode, - pub score: i32, - pub move_count: u32, - pub elapsed_seconds: u64, - pub seed: u64, - pub is_won: bool, - pub is_auto_completable: bool, - pub(crate) undo_stack: Vec, -} - -impl GameState { - pub fn new(seed: u64, draw_mode: DrawMode) -> Self { - let mut deck = Deck::new(); - deck.shuffle(seed); - let (tableau, stock) = deal_klondike(deck); - - let mut piles: HashMap = HashMap::new(); - piles.insert(PileType::Stock, stock); - piles.insert(PileType::Waste, Pile::new(PileType::Waste)); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit))); - } - for (i, pile) in tableau.into_iter().enumerate() { - piles.insert(PileType::Tableau(i), pile); - } - - Self { - piles, - draw_mode, - score: 0, - move_count: 0, - elapsed_seconds: 0, - seed, - is_won: false, - is_auto_completable: false, - undo_stack: Vec::new(), - } - } - - /// Returns the number of snapshots on the undo stack (for testing). - pub fn undo_stack_len(&self) -> usize { - self.undo_stack.len() - } - - fn take_snapshot(&self) -> StateSnapshot { - StateSnapshot { - piles: self.piles.clone(), - score: self.score, - move_count: self.move_count, - } - } - - fn push_snapshot(&mut self) { - if self.undo_stack.len() >= MAX_UNDO_STACK { - self.undo_stack.remove(0); - } - self.undo_stack.push(self.take_snapshot()); - } - - /// Draw from stock to waste. Recycles waste to stock when stock is empty. - pub fn draw(&mut self) -> Result<(), MoveError> { - if self.is_won { - return Err(MoveError::GameAlreadyWon); - } - - let stock_len = self.piles[&PileType::Stock].cards.len(); - - if stock_len == 0 { - let waste_len = self.piles[&PileType::Waste].cards.len(); - if waste_len == 0 { - return Err(MoveError::StockEmpty); - } - // Recycle: reverse waste back onto stock, face-down - let waste_cards: Vec = self.piles - .get_mut(&PileType::Waste) - .unwrap() - .cards - .drain(..) - .collect(); - let stock = self.piles.get_mut(&PileType::Stock).unwrap(); - for mut card in waste_cards.into_iter().rev() { - card.face_up = false; - stock.cards.push(card); - } - return Ok(()); - } - - self.push_snapshot(); - - let draw_count = match self.draw_mode { - DrawMode::DrawOne => 1, - DrawMode::DrawThree => 3, - }; - let available = stock_len.min(draw_count); - let drain_start = stock_len - available; - - let drawn: Vec = self.piles - .get_mut(&PileType::Stock) - .unwrap() - .cards - .drain(drain_start..) - .collect(); - - let waste = self.piles.get_mut(&PileType::Waste).unwrap(); - for mut card in drawn { - card.face_up = true; - waste.cards.push(card); - } - - self.move_count += 1; - Ok(()) - } - - /// Move `count` cards from pile `from` to pile `to`. - pub fn move_cards(&mut self, from: PileType, to: PileType, count: usize) -> Result<(), MoveError> { - if self.is_won { - return Err(MoveError::GameAlreadyWon); - } - if from == to { - return Err(MoveError::RuleViolation("source and destination must differ".into())); - } - - // Validate (immutable borrows scoped here) - let move_start = { - let from_pile = self.piles.get(&from).ok_or(MoveError::InvalidSource)?; - if from_pile.cards.is_empty() { - return Err(MoveError::EmptySource); - } - if count == 0 || count > from_pile.cards.len() { - return Err(MoveError::RuleViolation("invalid card count".into())); - } - let start = from_pile.cards.len() - count; - for card in &from_pile.cards[start..] { - if !card.face_up { - return Err(MoveError::RuleViolation("cannot move face-down card".into())); - } - } - let bottom_card = from_pile.cards[start].clone(); - - match &to { - PileType::Foundation(suit) => { - if count != 1 { - return Err(MoveError::RuleViolation( - "only one card can move to foundation at a time".into(), - )); - } - let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; - if !can_place_on_foundation(&bottom_card, dest, *suit) { - return Err(MoveError::RuleViolation("invalid foundation placement".into())); - } - } - PileType::Tableau(_) => { - let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; - if !can_place_on_tableau(&bottom_card, dest) { - return Err(MoveError::RuleViolation("invalid tableau placement".into())); - } - } - _ => return Err(MoveError::InvalidDestination), - } - start - }; - - let score_delta = score_move(&from, &to); - self.push_snapshot(); - - // Execute move - let mut moved: Vec = self.piles - .get_mut(&from) - .unwrap() - .cards - .split_off(move_start); - - // Flip the newly exposed top card of the source pile - if let Some(top) = self.piles.get_mut(&from).unwrap().cards.last_mut() { - if !top.face_up { - top.face_up = true; - } - } - - self.piles.get_mut(&to).unwrap().cards.append(&mut moved); - - self.score = (self.score + score_delta).max(0); - self.move_count += 1; - - self.is_won = self.check_win(); - if !self.is_won { - self.is_auto_completable = self.check_auto_complete(); - } - - Ok(()) - } - - /// Restore the most recent snapshot and apply the undo score penalty. - pub fn undo(&mut self) -> Result<(), MoveError> { - if self.is_won { - return Err(MoveError::GameAlreadyWon); - } - let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?; - self.piles = snapshot.piles; - self.score = (snapshot.score + scoring_undo()).max(0); - self.move_count = snapshot.move_count; - self.is_won = false; - self.is_auto_completable = false; - Ok(()) - } - - /// Returns true when all four foundations have 13 cards. - pub fn check_win(&self) -> bool { - [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] - .iter() - .all(|&suit| { - self.piles - .get(&PileType::Foundation(suit)) - .map_or(false, |p| p.cards.len() == 13) - }) - } - - /// Returns true when stock and waste are empty AND all tableau cards are face-up. - /// At that point the player can auto-complete without any input. - pub fn check_auto_complete(&self) -> bool { - if !self.piles[&PileType::Stock].cards.is_empty() { - return false; - } - if !self.piles[&PileType::Waste].cards.is_empty() { - return false; - } - (0..7).all(|i| { - self.piles[&PileType::Tableau(i)] - .cards - .iter() - .all(|c| c.face_up) - }) - } - - /// Time bonus added to score on win: 700_000 / elapsed_seconds (0 if elapsed is 0). - pub fn compute_time_bonus(&self) -> i32 { - scoring_time_bonus(self.elapsed_seconds) - } -} -``` - -- [ ] **Step 4: Add to lib.rs** - -```rust -pub mod card; -pub mod deck; -pub mod error; -pub mod game_state; -pub mod pile; -pub mod rules; -pub mod scoring; -``` - -- [ ] **Step 5: Run all tests** - -```bash -cargo test -p solitaire_core -``` -Expected: all tests in `card`, `pile`, `error`, `deck`, `rules`, `scoring`, and `game_state` modules pass. - -- [ ] **Step 6: Run clippy** - -```bash -cargo clippy -p solitaire_core -- -D warnings -``` -Expected: zero warnings. - ---- - -## Task 17: Phase 2 Full Workspace Gate - -- [ ] **Step 1: Run full workspace test suite** - -```bash -cargo test --workspace -``` -Expected: all tests pass. The non-core crates have no tests yet so the count is small — that is fine. - -- [ ] **Step 2: Run full workspace clippy** - -```bash -cargo clippy --workspace -- -D warnings -``` -Expected: zero warnings across all seven crates. - -- [ ] **Step 3: Verify blank Bevy window still opens** - -```bash -cargo run -p solitaire_app --features bevy/dynamic_linking -``` -Expected: window opens, no panics. - -- [ ] **Step 4: Commit Phase 2** - -```bash -git add solitaire_core/src/ -git commit -m "feat(core): complete Klondike game logic with full test coverage" -``` - ---- - -## Self-Review Checklist - -### Spec coverage - -| Spec requirement | Covered by task | -|---|---| -| 7-crate workspace | Tasks 1–8 | -| Fast compile settings in Cargo.toml | Task 1 | -| assets/ directory structure | Task 9 | -| Blank Bevy window | Task 8 | -| cargo run opens window | Task 8 step 3 | -| GPGS compile-time stub | Task 7 | -| GpgsClient implements SyncProvider | Task 7 step 3 | -| .env.example | Task 9 step 2 | -| Suit, Rank, Card types | Task 10 | -| PileType, Pile types | Task 11 | -| MoveError enum | Task 12 | -| Deck::new(), Deck::shuffle(seed) | Task 13 | -| deal_klondike() Klondike layout | Task 13 | -| Move validation (legal + illegal) | Tasks 14, 16 | -| Scoring per move type | Task 15 | -| Time bonus formula | Task 15 | -| Undo (restore state, -15 penalty) | Task 16 | -| Undo stack capped at 64 | Task 16 | -| Win detection | Task 16 | -| Auto-complete detection | Task 16 | -| Seeded deal reproducibility | Tasks 13, 16 | -| cargo test --workspace passes | Task 17 | -| cargo clippy --workspace -D warnings passes | Task 17 | - -### Gaps / Notes - -- `apply_auto_complete()` (iterates foundations to completion) is not implemented — it is used by Phase 3 (Bevy rendering). Adding it now would require borrow complexity with no test driver. It belongs in the Phase 3 plan. -- `solitaire_sync` types are minimal stubs. Full fields (`StatsSnapshot`, `PlayerProgress`, etc.) are added in Phase 8. -- `solitaire_data` has `SyncProvider` trait only. `StatsSnapshot`, `PlayerProgress`, persistence code are added in Phase 4. -- Bevy version numbers in `Cargo.toml` may need updating to current stable — check `crates.io/crates/bevy` at implementation time. diff --git a/docs/superpowers/plans/2026-04-23-phase3-bevy-rendering.md b/docs/superpowers/plans/2026-04-23-phase3-bevy-rendering.md deleted file mode 100644 index 534b837..0000000 --- a/docs/superpowers/plans/2026-04-23-phase3-bevy-rendering.md +++ /dev/null @@ -1,172 +0,0 @@ -# Phase 3 — Bevy Rendering & Interaction - -> Status: In progress (started 2026-04-23) -> Crate: `solitaire_engine` -> Depends on: `solitaire_core` (complete), `bevy = 0.15` (includes `bevy::ui`), `kira = 0.9` (audio — Phase 3F+) - ---- - -## Scope - -Make the game playable with a graphical interface. This phase takes `solitaire_engine` from an empty stub to a full Bevy rendering + input layer wired to `solitaire_core::GameState`. - -Out of scope (later phases): - -- Persistence (`StatsSnapshot`, file I/O) — Phase 4 -- Achievements toast content — Phase 5 -- Audio — Phase 7 -- Sync — Phase 8 - ---- - -## Sub-phases - -### 3A — Plumbing & event wiring - -**Modules under `solitaire_engine/src/`:** - -- `lib.rs` — re-exports plugins, types -- `resources.rs` - - `GameStateResource(pub GameState)` — wraps `solitaire_core::GameState` directly (no `solitaire_data` layer yet) - - `DragState { cards: Vec, origin_pile: PileType, cursor_offset: Vec2, origin_z: f32 }` (starts empty) - - `SyncStatusResource(pub SyncStatus)` where `SyncStatus` is `Idle|Syncing|LastSynced(DateTime)|Error(String)` -- `events.rs` - - `MoveRequestEvent { from: PileType, to: PileType, count: usize }` - - `DrawRequestEvent` - - `UndoRequestEvent` - - `NewGameRequestEvent { seed: Option }` - - `StateChangedEvent` - - `GameWonEvent { score: i32, time_seconds: u64 }` - - `CardFlippedEvent(pub u32)` - - `AchievementUnlockedEvent(pub AchievementRecord)` — placeholder, unused until Phase 5 -- `game_plugin.rs` — `GamePlugin`: - - On `Startup`: init `GameStateResource::new(system_time_seed, DrawMode::DrawOne)` - - Systems: `handle_draw`, `handle_move`, `handle_undo`, `handle_new_game` - - Each fires `StateChangedEvent` on success; `GameWonEvent` when `check_win()` flips to true - - Errors: log via `tracing`, do not panic -- Register in [solitaire_app/src/main.rs](../../../solitaire_app/src/main.rs) - -**Tests:** event-routing unit tests that drive `GamePlugin` in a headless `App::new()` and verify resource mutations. - -**Exit:** `cargo test --workspace` green, `cargo clippy --workspace -- -D warnings` clean. Running the app still shows a blank window (no rendering yet), but pressing nothing crashes anything. - -Commit: `feat(engine): add resources, events, and GamePlugin event routing` - ---- - -### 3B — Layout + TablePlugin - -**Modules:** - -- `layout.rs` — pure function `compute_layout(window: Vec2) -> Layout` - - `Layout { card_size: Vec2, pile_positions: HashMap }` - - card_width = window.x / 9.0 - - card_height = card_width * 1.4 - - Row 1: stock, waste, [gap], 4 foundations - - Row 2: 7 tableau columns below -- `LayoutResource(pub Layout)` — a Bevy resource -- `table_plugin.rs` — `TablePlugin`: - - Spawns background rectangle (dark green `#0f5132`) - - Spawns 13 `PileMarker` sprite entities for empty-pile placeholders - - System `on_window_resized`: recompute `LayoutResource`, reposition pile markers - -**Tests:** `compute_layout` at 800×600, 1280×800, 1920×1080 — all 13 piles within bounds, non-overlapping. - -**Exit:** Window shows a green table with 13 translucent pile outlines that resize with the window. - -Commit: `feat(engine): add layout, LayoutResource, and TablePlugin` - ---- - -### 3C — CardPlugin rendering (procedural) - -**Decision:** Phase 3 uses procedural cards (rounded white rectangle + rank/suit text). Real PNG assets can be slotted in later by replacing the sprite setup; API shape stays stable. - -**Modules:** - -- `card_plugin.rs` — `CardPlugin`: - - Component `CardEntity { card_id: u32 }` - - `StateChangedEvent` handler: sync entities with `GameStateResource` — spawn missing, despawn removed, reposition all - - Position: `LayoutResource.pile_positions[pile] + Vec3::Z * stack_index` - - Face-up: white rect + text of rank+suit glyph (red for hearts/diamonds, black for clubs/spades) - - Face-down: blue rect with a subtle pattern overlay - - No assets loaded — text uses Bevy's default font (or shipped system font if needed) - -**Exit:** A freshly dealt game renders — stock (24 cards face-down), 7 tableau columns in standard 1/2/3/.../7 face-down + 1 face-up, empty foundations. - -Commit: `feat(engine): add CardPlugin with procedural card rendering` - ---- - -### 3D — Keyboard input & click-to-draw - -**Modules:** - -- `input_plugin.rs` — `InputPlugin`: - - Keyboard system: `KeyCode::KeyU` → `UndoRequestEvent`, `KeyN` → `NewGameRequestEvent{seed: None}`, `KeyD` → `DrawRequestEvent`, `Escape` → pause-stub event - - Mouse system: on left-click, if cursor over stock pile → `DrawRequestEvent` - -**Exit:** Pressing D cycles stock↔waste on-screen; N deals a new game; U undoes. - -Commit: `feat(engine): add InputPlugin with keyboard and stock-click` - ---- - -### 3E — Drag & drop - -**Modules:** - -- Extend `input_plugin.rs` with drag systems: - - `start_drag`: on left mouse-down, ray-hit the top card (or run of face-up cards) of a pile; populate `DragState`; elevate z - - `follow_cursor`: while `DragState.cards` non-empty, move those entities to cursor position + per-card stack offset - - `end_drag`: on mouse-up, determine target pile; early-validate with `can_place_on_tableau` / `can_place_on_foundation`; fire `MoveRequestEvent` (backend also validates) - - On `MoveError` via `StateChangedEvent` non-emission: snap cards back with a short lerp (uses `CardAnim` from 3F) -- Multi-card tableau drag: grabbing card N pulls N..=top if all face-up - -**Exit:** Full game playable with mouse. `GameWonEvent` fires on a win. No animations yet on invalid drop (just snap back instantly in 3E, smooth in 3F). - -Commit: `feat(engine): add drag-and-drop input with multi-card tableau support` - ---- - -### 3F — AnimationPlugin (polish) - -**Modules:** - -- `animation_plugin.rs` — `AnimationPlugin`: - - Component `CardAnim { start: Vec3, target: Vec3, elapsed: f32, duration: f32 }` — linear lerp 0.15s for moves - - Flip: `CardFlip { elapsed: f32, duration: f32, flips_to_face_up: bool }` — scale-X 1→0→1 over 0.2s, toggle `face_up` at midpoint, fire `CardFlippedEvent` - - Win cascade: on `GameWonEvent`, iterate foundation cards and schedule `CardAnim` to random off-screen targets with staggered 0.05s starts - - Toast component scaffold: bevy_ui `Node`/`Text` overlay, wired to `AchievementUnlockedEvent` (no content yet) - -**Exit:** Valid moves animate smoothly; flipping a tableau card shows a flip; winning plays a cascade. - -Commit: `feat(engine): add AnimationPlugin with slide, flip, and win cascade` - ---- - -## Cross-cutting rules - -- `solitaire_core` and `solitaire_sync` gain NO new dependencies. -- No `unwrap()` / `panic!()` in new Bevy systems — log errors via `tracing::warn!` and continue. -- `cargo test --workspace` and `cargo clippy --workspace -- -D warnings` green after EVERY sub-phase. -- Every commit follows `type(scope): description` convention. -- One `Plugin` per responsibility; cross-system communication is Events only. - ---- - -## Open questions resolved - -- **Procedural vs. sourced card art**: procedural for Phase 3. -- **`GameStateResource` layer**: wraps `solitaire_core::GameState` directly. -- **Phases 4–8 plugins** (Audio/UI/Achievement/Sync): not in Phase 3. -- **New-game seed**: system time when `None`, explicit when `Some(u64)`. -- **Commit cadence**: one per sub-phase. - ---- - -## Risks - -- Bevy 0.15 API drift from older tutorials — verify each API call as written. -- Procedural card text depends on Bevy's default font; if rendering is unreadable, embed a `.ttf` via `include_bytes!()` as a follow-up (still Phase 3, not 3F). -- `kira` audio API is async-friendly but requires careful thread management — initialise the `AudioManager` once at startup and store it in a Bevy `NonSend` resource. diff --git a/docs/superpowers/plans/2026-04-23-phase4-statistics.md b/docs/superpowers/plans/2026-04-23-phase4-statistics.md deleted file mode 100644 index 210b50d..0000000 --- a/docs/superpowers/plans/2026-04-23-phase4-statistics.md +++ /dev/null @@ -1,1304 +0,0 @@ -# Phase 4 — Statistics Persistence & Stats Screen - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Persist game statistics to disk and display them in a toggleable bevy_ui overlay. - -**Architecture:** `StatsSnapshot` is defined and serialized in `solitaire_data`; `StatsPlugin` in `solitaire_engine` loads it on startup, updates it on game events, and saves it atomically. A lightweight bevy_ui overlay (toggled with `S`) shows the player's stats. - -**Tech Stack:** `solitaire_data` (stats type + file I/O), `solitaire_engine` (Bevy plugin + UI), `serde_json` (serialization), `dirs` (platform data dir), `chrono` (timestamps), `bevy::ui` (overlay screen). - ---- - -## File Map - -| File | Action | Responsibility | -|---|---|---| -| `solitaire_data/src/stats.rs` | **Create** | `StatsSnapshot` struct + `update_on_win` + `record_abandoned` | -| `solitaire_data/src/storage.rs` | **Create** | `stats_file_path`, `load_stats_from`, `save_stats_to`, public wrappers | -| `solitaire_data/src/lib.rs` | **Modify** | Re-export `stats` and `storage` modules | -| `solitaire_engine/src/stats_plugin.rs` | **Create** | `StatsResource`, `StatsPlugin` (load/update/save + UI toggle) | -| `solitaire_engine/src/lib.rs` | **Modify** | Export `StatsPlugin`, `StatsResource` | -| `solitaire_app/src/main.rs` | **Modify** | Register `StatsPlugin` | - ---- - -## Task 1 — `StatsSnapshot` in `solitaire_data` - -**Files:** -- Create: `solitaire_data/src/stats.rs` -- Modify: `solitaire_data/src/lib.rs` - -### Step 1: Write failing tests - -Add to a new file `solitaire_data/src/stats.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use solitaire_core::game_state::DrawMode; - - #[test] - fn default_stats_are_all_zero() { - let s = StatsSnapshot::default(); - assert_eq!(s.games_played, 0); - assert_eq!(s.games_won, 0); - assert_eq!(s.win_streak_current, 0); - assert_eq!(s.win_streak_best, 0); - assert_eq!(s.lifetime_score, 0); - assert_eq!(s.best_single_score, 0); - assert_eq!(s.fastest_win_seconds, u64::MAX); - } - - #[test] - fn first_win_sets_all_fields() { - let mut s = StatsSnapshot::default(); - s.update_on_win(1500, 120, &DrawMode::DrawOne); - assert_eq!(s.games_played, 1); - assert_eq!(s.games_won, 1); - assert_eq!(s.win_streak_current, 1); - assert_eq!(s.win_streak_best, 1); - assert_eq!(s.lifetime_score, 1500); - assert_eq!(s.best_single_score, 1500); - assert_eq!(s.fastest_win_seconds, 120); - assert_eq!(s.avg_time_seconds, 120); - assert_eq!(s.draw_one_wins, 1); - assert_eq!(s.draw_three_wins, 0); - } - - #[test] - fn streak_tracks_across_wins() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); - assert_eq!(s.win_streak_current, 3); - assert_eq!(s.win_streak_best, 3); - } - - #[test] - fn record_abandoned_resets_streak_and_increments_played() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); - assert_eq!(s.win_streak_current, 2); - s.record_abandoned(); - assert_eq!(s.games_played, 3); - assert_eq!(s.games_lost, 1); - assert_eq!(s.win_streak_current, 0); - assert_eq!(s.win_streak_best, 2, "best streak must not drop"); - } - - #[test] - fn fastest_win_takes_minimum() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 300, &DrawMode::DrawOne); - s.update_on_win(100, 120, &DrawMode::DrawOne); - s.update_on_win(100, 500, &DrawMode::DrawOne); - assert_eq!(s.fastest_win_seconds, 120); - } - - #[test] - fn avg_time_is_correct_rolling_average() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 100, &DrawMode::DrawOne); - s.update_on_win(100, 200, &DrawMode::DrawOne); - s.update_on_win(100, 300, &DrawMode::DrawOne); - // (100 + 200 + 300) / 3 = 200 - assert_eq!(s.avg_time_seconds, 200); - } - - #[test] - fn best_score_updates_only_on_higher_score() { - let mut s = StatsSnapshot::default(); - s.update_on_win(500, 60, &DrawMode::DrawOne); - s.update_on_win(300, 60, &DrawMode::DrawOne); - assert_eq!(s.best_single_score, 500); - s.update_on_win(800, 60, &DrawMode::DrawOne); - assert_eq!(s.best_single_score, 800); - } - - #[test] - fn negative_score_treated_as_zero() { - let mut s = StatsSnapshot::default(); - s.update_on_win(-50, 60, &DrawMode::DrawOne); - assert_eq!(s.best_single_score, 0); - assert_eq!(s.lifetime_score, 0); - } - - #[test] - fn draw_three_wins_tracked_separately() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawThree); - assert_eq!(s.draw_one_wins, 1); - assert_eq!(s.draw_three_wins, 1); - } -} -``` - -- [ ] **Step 2: Verify tests fail** - -```bash -cargo test -p solitaire_data 2>&1 | tail -5 -``` - -Expected: compile error — `stats.rs` does not exist. - -- [ ] **Step 3: Implement `StatsSnapshot`** - -Create `solitaire_data/src/stats.rs` with the full struct and methods: - -```rust -//! Player statistics — persisted to `stats.json` between sessions. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use solitaire_core::game_state::DrawMode; - -/// Cumulative game statistics. Stored as `stats.json` in the platform data dir. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StatsSnapshot { - pub games_played: u32, - pub games_won: u32, - pub games_lost: u32, - pub win_streak_current: u32, - pub win_streak_best: u32, - /// Rolling average of win times in seconds. - pub avg_time_seconds: u64, - /// Fastest win time. `u64::MAX` means no wins yet. - pub fastest_win_seconds: u64, - /// Sum of all winning scores. - pub lifetime_score: u64, - pub best_single_score: u32, - pub draw_one_wins: u32, - pub draw_three_wins: u32, - pub last_modified: DateTime, -} - -impl Default for StatsSnapshot { - fn default() -> Self { - Self { - games_played: 0, - games_won: 0, - games_lost: 0, - win_streak_current: 0, - win_streak_best: 0, - avg_time_seconds: 0, - fastest_win_seconds: u64::MAX, - lifetime_score: 0, - best_single_score: 0, - draw_one_wins: 0, - draw_three_wins: 0, - last_modified: DateTime::UNIX_EPOCH, - } - } -} - -impl StatsSnapshot { - /// Record a completed win. Updates all relevant counters and rolling averages. - pub fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) { - let prev_wins = self.games_won; // capture BEFORE increment - self.games_played += 1; - self.games_won += 1; - self.win_streak_current += 1; - if self.win_streak_current > self.win_streak_best { - self.win_streak_best = self.win_streak_current; - } - - let score_u32 = score.max(0) as u32; - self.lifetime_score = self.lifetime_score.saturating_add(score_u32 as u64); - if score_u32 > self.best_single_score { - self.best_single_score = score_u32; - } - - if time_seconds < self.fastest_win_seconds { - self.fastest_win_seconds = time_seconds; - } - - // Rolling average using u128 to avoid overflow on the intermediate product. - self.avg_time_seconds = if prev_wins == 0 { - time_seconds - } else { - ((self.avg_time_seconds as u128 * prev_wins as u128 + time_seconds as u128) - / self.games_won as u128) as u64 - }; - - match draw_mode { - DrawMode::DrawOne => self.draw_one_wins += 1, - DrawMode::DrawThree => self.draw_three_wins += 1, - } - - self.last_modified = Utc::now(); - } - - /// Record an abandoned game (player started a new game without winning). - /// Increments `games_played` and `games_lost`, resets `win_streak_current`. - pub fn record_abandoned(&mut self) { - self.games_played += 1; - self.games_lost += 1; - self.win_streak_current = 0; - self.last_modified = Utc::now(); - } - - /// Win percentage as 0–100, or `None` if no games played. - pub fn win_rate(&self) -> Option { - if self.games_played == 0 { - None - } else { - Some(self.games_won as f32 / self.games_played as f32 * 100.0) - } - } -} - -#[cfg(test)] -mod tests { - // (test code from Step 1 goes here) - use super::*; - use solitaire_core::game_state::DrawMode; - - #[test] - fn default_stats_are_all_zero() { - let s = StatsSnapshot::default(); - assert_eq!(s.games_played, 0); - assert_eq!(s.games_won, 0); - assert_eq!(s.win_streak_current, 0); - assert_eq!(s.win_streak_best, 0); - assert_eq!(s.lifetime_score, 0); - assert_eq!(s.best_single_score, 0); - assert_eq!(s.fastest_win_seconds, u64::MAX); - } - - #[test] - fn first_win_sets_all_fields() { - let mut s = StatsSnapshot::default(); - s.update_on_win(1500, 120, &DrawMode::DrawOne); - assert_eq!(s.games_played, 1); - assert_eq!(s.games_won, 1); - assert_eq!(s.win_streak_current, 1); - assert_eq!(s.win_streak_best, 1); - assert_eq!(s.lifetime_score, 1500); - assert_eq!(s.best_single_score, 1500); - assert_eq!(s.fastest_win_seconds, 120); - assert_eq!(s.avg_time_seconds, 120); - assert_eq!(s.draw_one_wins, 1); - assert_eq!(s.draw_three_wins, 0); - } - - #[test] - fn streak_tracks_across_wins() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); - assert_eq!(s.win_streak_current, 3); - assert_eq!(s.win_streak_best, 3); - } - - #[test] - fn record_abandoned_resets_streak_and_increments_played() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); - assert_eq!(s.win_streak_current, 2); - s.record_abandoned(); - assert_eq!(s.games_played, 3); - assert_eq!(s.games_lost, 1); - assert_eq!(s.win_streak_current, 0); - assert_eq!(s.win_streak_best, 2, "best streak must not drop"); - } - - #[test] - fn fastest_win_takes_minimum() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 300, &DrawMode::DrawOne); - s.update_on_win(100, 120, &DrawMode::DrawOne); - s.update_on_win(100, 500, &DrawMode::DrawOne); - assert_eq!(s.fastest_win_seconds, 120); - } - - #[test] - fn avg_time_is_correct_rolling_average() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 100, &DrawMode::DrawOne); - s.update_on_win(100, 200, &DrawMode::DrawOne); - s.update_on_win(100, 300, &DrawMode::DrawOne); - assert_eq!(s.avg_time_seconds, 200); - } - - #[test] - fn best_score_updates_only_on_higher_score() { - let mut s = StatsSnapshot::default(); - s.update_on_win(500, 60, &DrawMode::DrawOne); - s.update_on_win(300, 60, &DrawMode::DrawOne); - assert_eq!(s.best_single_score, 500); - s.update_on_win(800, 60, &DrawMode::DrawOne); - assert_eq!(s.best_single_score, 800); - } - - #[test] - fn negative_score_treated_as_zero() { - let mut s = StatsSnapshot::default(); - s.update_on_win(-50, 60, &DrawMode::DrawOne); - assert_eq!(s.best_single_score, 0); - assert_eq!(s.lifetime_score, 0); - } - - #[test] - fn draw_three_wins_tracked_separately() { - let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawThree); - assert_eq!(s.draw_one_wins, 1); - assert_eq!(s.draw_three_wins, 1); - } -} -``` - -- [ ] **Step 4: Expose the module from `solitaire_data/src/lib.rs`** - -Append to the existing `lib.rs` (after the `SyncProvider` trait): - -```rust -pub mod stats; -pub use stats::StatsSnapshot; -``` - -- [ ] **Step 5: Run tests and verify they pass** - -```bash -cargo test -p solitaire_data 2>&1 | tail -10 -``` - -Expected output: -``` -test stats::tests::avg_time_is_correct_rolling_average ... ok -test stats::tests::best_score_updates_only_on_higher_score ... ok -test stats::tests::default_stats_are_all_zero ... ok -test stats::tests::draw_three_wins_tracked_separately ... ok -test stats::tests::fastest_win_takes_minimum ... ok -test stats::tests::first_win_sets_all_fields ... ok -test stats::tests::negative_score_treated_as_zero ... ok -test stats::tests::record_abandoned_resets_streak_and_increments_played ... ok -test stats::tests::streak_tracks_across_wins ... ok -test result: ok. 9 passed; 0 failed; ... -``` - -- [ ] **Step 6: Clippy** - -```bash -cargo clippy -p solitaire_data -- -D warnings 2>&1 | tail -5 -``` - -Expected: `Finished ... 0 warnings` - -- [ ] **Step 7: Commit** - -```bash -git add solitaire_data/src/stats.rs solitaire_data/src/lib.rs -git commit -m "feat(data): add StatsSnapshot with update_on_win and record_abandoned" -``` - ---- - -## Task 2 — File Persistence in `solitaire_data` - -**Files:** -- Create: `solitaire_data/src/storage.rs` -- Modify: `solitaire_data/src/lib.rs` - -- [ ] **Step 1: Write failing tests** - -Add to bottom of `solitaire_data/src/storage.rs` (new file, just the test module first): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::stats::StatsSnapshot; - use solitaire_core::game_state::DrawMode; - use std::env; - - fn tmp_path(name: &str) -> std::path::PathBuf { - env::temp_dir().join(format!("solitaire_test_{name}.json")) - } - - #[test] - fn round_trip_save_and_load() { - let path = tmp_path("round_trip"); - let _ = std::fs::remove_file(&path); // clean up from prior runs - - let mut stats = StatsSnapshot::default(); - stats.update_on_win(1000, 180, &DrawMode::DrawOne); - save_stats_to(&path, &stats).expect("save"); - - let loaded = load_stats_from(&path); - assert_eq!(loaded.games_won, 1); - assert_eq!(loaded.best_single_score, 1000); - assert_eq!(loaded.fastest_win_seconds, 180); - } - - #[test] - fn load_from_missing_file_returns_default() { - let path = tmp_path("missing_file_abc123"); - let _ = std::fs::remove_file(&path); - let stats = load_stats_from(&path); - assert_eq!(stats, StatsSnapshot::default()); - } - - #[test] - fn save_is_atomic_no_half_written_file() { - let path = tmp_path("atomic_write"); - let stats = StatsSnapshot::default(); - save_stats_to(&path, &stats).expect("save"); - - // Verify the .tmp file was cleaned up after the rename. - let tmp_path = path.with_extension("json.tmp"); - assert!( - !tmp_path.exists(), - ".tmp file should not exist after successful save" - ); - } - - #[test] - fn load_from_corrupt_file_returns_default() { - let path = tmp_path("corrupt"); - std::fs::write(&path, b"not valid json!!!").expect("write corrupt"); - let stats = load_stats_from(&path); - assert_eq!(stats, StatsSnapshot::default()); - } -} -``` - -- [ ] **Step 2: Verify tests fail** - -```bash -cargo test -p solitaire_data storage 2>&1 | tail -5 -``` - -Expected: compile error — `storage.rs` not found. - -- [ ] **Step 3: Implement `storage.rs`** - -Create `solitaire_data/src/storage.rs`: - -```rust -//! Atomic file I/O for `StatsSnapshot` persistence. -//! -//! All saves go through `filename.json.tmp` → `rename()` so a crash or power -//! loss during a write never corrupts the saved data. - -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; - -use crate::stats::StatsSnapshot; - -const APP_DIR_NAME: &str = "solitaire_quest"; -const STATS_FILE_NAME: &str = "stats.json"; - -/// Returns the platform-specific path to `stats.json`, or `None` if -/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). -pub fn stats_file_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME)) -} - -/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if -/// the file is missing or cannot be deserialized (corrupt/truncated). -pub fn load_stats_from(path: &Path) -> StatsSnapshot { - let data = match fs::read(path) { - Ok(d) => d, - Err(_) => return StatsSnapshot::default(), - }; - serde_json::from_slice(&data).unwrap_or_default() -} - -/// Save stats to an explicit path using an atomic write (`.tmp` → rename). -pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> { - // Ensure the parent directory exists. - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let json = serde_json::to_string_pretty(stats) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - // Write to a temporary file alongside the target. - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, json.as_bytes())?; - - // Atomic rename — on POSIX this is guaranteed atomic. - fs::rename(&tmp, path)?; - Ok(()) -} - -/// Load stats from the platform default path. Returns default if the path -/// is unavailable or the file is missing/corrupt. -pub fn load_stats() -> StatsSnapshot { - stats_file_path() - .map(|p| load_stats_from(&p)) - .unwrap_or_default() -} - -/// Save stats to the platform default path. Logs a warning if the path is -/// unavailable or the write fails — never panics. -pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> { - let path = stats_file_path().ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable") - })?; - save_stats_to(&path, stats) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::stats::StatsSnapshot; - use solitaire_core::game_state::DrawMode; - use std::env; - - fn tmp_path(name: &str) -> PathBuf { - env::temp_dir().join(format!("solitaire_test_{name}.json")) - } - - #[test] - fn round_trip_save_and_load() { - let path = tmp_path("round_trip"); - let _ = fs::remove_file(&path); - - let mut stats = StatsSnapshot::default(); - stats.update_on_win(1000, 180, &DrawMode::DrawOne); - save_stats_to(&path, &stats).expect("save"); - - let loaded = load_stats_from(&path); - assert_eq!(loaded.games_won, 1); - assert_eq!(loaded.best_single_score, 1000); - assert_eq!(loaded.fastest_win_seconds, 180); - } - - #[test] - fn load_from_missing_file_returns_default() { - let path = tmp_path("missing_file_abc123"); - let _ = fs::remove_file(&path); - let stats = load_stats_from(&path); - assert_eq!(stats, StatsSnapshot::default()); - } - - #[test] - fn save_is_atomic_no_half_written_file() { - let path = tmp_path("atomic_write"); - let stats = StatsSnapshot::default(); - save_stats_to(&path, &stats).expect("save"); - - let tmp = path.with_extension("json.tmp"); - assert!(!tmp.exists(), ".tmp file must be cleaned up after rename"); - } - - #[test] - fn load_from_corrupt_file_returns_default() { - let path = tmp_path("corrupt"); - fs::write(&path, b"not valid json!!!").expect("write corrupt"); - let stats = load_stats_from(&path); - assert_eq!(stats, StatsSnapshot::default()); - } -} -``` - -- [ ] **Step 4: Update `solitaire_data/src/lib.rs`** - -Add storage module and re-exports after the stats module lines: - -```rust -pub mod storage; -pub use storage::{load_stats, save_stats, stats_file_path}; -``` - -The full `solitaire_data/src/lib.rs` should now be: - -```rust -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 { - /// Fetch the remote sync payload. Returns the latest server state for merging. - async fn pull(&self) -> Result; - /// Push the local payload to the backend. Returns the merged server response. - async fn push(&self, payload: &SyncPayload) -> Result; - /// Human-readable name of this backend, used in settings UI and logs. - fn backend_name(&self) -> &'static str; - /// Returns true if the user is currently authenticated with this backend. - 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(()) - } -} - -pub mod stats; -pub use stats::StatsSnapshot; - -pub mod storage; -pub use storage::{load_stats, save_stats, stats_file_path}; -``` - -- [ ] **Step 5: Run tests and verify they pass** - -```bash -cargo test -p solitaire_data 2>&1 | tail -10 -``` - -Expected: 13 tests all passing (9 stats + 4 storage). - -- [ ] **Step 6: Clippy** - -```bash -cargo clippy -p solitaire_data -- -D warnings 2>&1 | tail -5 -``` - -Expected: 0 warnings. - -- [ ] **Step 7: Commit** - -```bash -git add solitaire_data/src/storage.rs solitaire_data/src/lib.rs -git commit -m "feat(data): add atomic stats persistence (load_stats_from, save_stats_to)" -``` - ---- - -## Task 3 — `StatsPlugin` in `solitaire_engine` - -**Files:** -- Create: `solitaire_engine/src/stats_plugin.rs` -- Modify: `solitaire_engine/src/lib.rs` - -- [ ] **Step 1: Write failing tests** - -Write the test module at the bottom of the (not-yet-existing) `solitaire_engine/src/stats_plugin.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::game_plugin::GamePlugin; - use crate::table_plugin::TablePlugin; - use solitaire_data::StatsSnapshot; - - fn headless_app() -> App { - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(StatsPlugin); - app.update(); - app - } - - #[test] - fn stats_resource_exists_after_startup() { - let app = headless_app(); - assert!(app.world().get_resource::().is_some()); - } - - #[test] - fn win_event_increments_games_won() { - let mut app = headless_app(); - assert_eq!( - app.world().resource::().0.games_won, - 0 - ); - app.world_mut().send_event(GameWonEvent { - score: 1000, - time_seconds: 120, - }); - // Override draw_mode so handle_move picks DrawOne (default is DrawOne). - app.update(); - assert_eq!( - app.world().resource::().0.games_won, - 1 - ); - assert_eq!( - app.world().resource::().0.games_played, - 1 - ); - } - - #[test] - fn new_game_after_moves_records_abandoned() { - let mut app = headless_app(); - - // Simulate move_count > 0 by directly mutating the resource. - app.world_mut() - .resource_mut::() - .0 - .move_count = 3; - - app.world_mut() - .send_event(NewGameRequestEvent { seed: Some(999) }); - app.update(); - - let stats = &app.world().resource::().0; - assert_eq!(stats.games_played, 1, "abandoned game counted as played"); - assert_eq!(stats.games_lost, 1); - assert_eq!(stats.win_streak_current, 0); - } - - #[test] - fn new_game_without_moves_does_not_record_abandoned() { - let mut app = headless_app(); - // move_count is 0 by default after new game - app.world_mut() - .send_event(NewGameRequestEvent { seed: Some(42) }); - app.update(); - - let stats = &app.world().resource::().0; - assert_eq!(stats.games_played, 0, "no moves = no abandoned game"); - } -} -``` - -- [ ] **Step 2: Verify tests fail** - -```bash -cargo test -p solitaire_engine stats_plugin 2>&1 | tail -5 -``` - -Expected: compile error — `stats_plugin` module not found. - -- [ ] **Step 3: Implement `stats_plugin.rs`** - -Create `solitaire_engine/src/stats_plugin.rs`: - -```rust -//! Loads, updates, and persists `StatsSnapshot` in response to game events. -//! -//! Stats are loaded from disk in `Startup` and saved after every event that -//! modifies them. File I/O is synchronous (stats.json is tiny, <1 KB). - -use bevy::prelude::*; -use solitaire_data::{load_stats, save_stats, StatsSnapshot}; - -use crate::events::{GameWonEvent, NewGameRequestEvent}; -use crate::game_plugin::GameMutation; -use crate::resources::GameStateResource; - -/// Bevy resource wrapping the current stats. -#[derive(Resource, Debug, Clone)] -pub struct StatsResource(pub StatsSnapshot); - -/// Registers stats resources and the systems that keep them in sync. -pub struct StatsPlugin; - -impl Plugin for StatsPlugin { - fn build(&self, app: &mut App) { - app.insert_resource(StatsResource(load_stats())) - .add_event::() - .add_event::() - .add_systems( - Update, - (update_stats_on_win, update_stats_on_new_game).after(GameMutation), - ); - } -} - -fn update_stats_on_win( - mut events: EventReader, - game: Res, - mut stats: ResMut, -) { - for ev in events.read() { - stats.0.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode); - if let Err(e) = save_stats(&stats.0) { - warn!("failed to save stats after win: {e}"); - } - } -} - -fn update_stats_on_new_game( - mut events: EventReader, - game: Res, - mut stats: ResMut, -) { - for _ in events.read() { - // Only count as abandoned if the player made at least one move and did - // not win — a re-deal from a brand-new untouched game is not a loss. - if game.0.move_count > 0 && !game.0.is_won { - stats.0.record_abandoned(); - if let Err(e) = save_stats(&stats.0) { - warn!("failed to save stats after abandoned game: {e}"); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::events::GameWonEvent; - use crate::game_plugin::GamePlugin; - use crate::table_plugin::TablePlugin; - - fn headless_app() -> App { - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(StatsPlugin); - app.update(); - app - } - - #[test] - fn stats_resource_exists_after_startup() { - let app = headless_app(); - assert!(app.world().get_resource::().is_some()); - } - - #[test] - fn win_event_increments_games_won() { - let mut app = headless_app(); - assert_eq!(app.world().resource::().0.games_won, 0); - - app.world_mut() - .send_event(GameWonEvent { score: 1000, time_seconds: 120 }); - app.update(); - - assert_eq!(app.world().resource::().0.games_won, 1); - assert_eq!(app.world().resource::().0.games_played, 1); - } - - #[test] - fn new_game_after_moves_records_abandoned() { - let mut app = headless_app(); - - app.world_mut() - .resource_mut::() - .0 - .move_count = 3; - - app.world_mut() - .send_event(NewGameRequestEvent { seed: Some(999) }); - app.update(); - - let stats = &app.world().resource::().0; - assert_eq!(stats.games_played, 1); - assert_eq!(stats.games_lost, 1); - assert_eq!(stats.win_streak_current, 0); - } - - #[test] - fn new_game_without_moves_does_not_record_abandoned() { - let mut app = headless_app(); - app.world_mut() - .send_event(NewGameRequestEvent { seed: Some(42) }); - app.update(); - - let stats = &app.world().resource::().0; - assert_eq!(stats.games_played, 0); - } -} -``` - -- [ ] **Step 4: Run tests** - -```bash -cargo test -p solitaire_engine stats_plugin 2>&1 | tail -10 -``` - -Expected: 4 tests passing. - -- [ ] **Step 5: Clippy** - -```bash -cargo clippy -p solitaire_engine -- -D warnings 2>&1 | tail -5 -``` - -Expected: 0 warnings. - -- [ ] **Step 6: Commit** - -```bash -git add solitaire_engine/src/stats_plugin.rs -git commit -m "feat(engine): add StatsPlugin with persistent StatsResource" -``` - ---- - -## Task 4 — Stats Screen (bevy_ui overlay) - -**Files:** -- Modify: `solitaire_engine/src/stats_plugin.rs` — add UI toggle systems -- Modify: `solitaire_engine/src/lib.rs` — export `StatsPlugin`, `StatsResource` -- Modify: `solitaire_app/src/main.rs` — register `StatsPlugin` - -The stats screen is a full-window overlay spawned on demand. It reuses `StatsPlugin` — no separate plugin needed. - -- [ ] **Step 1: Write failing tests** - -Add these tests to `stats_plugin.rs` (inside the existing `tests` module): - -```rust - #[test] - fn pressing_s_spawns_stats_screen() { - let mut app = headless_app(); - assert_eq!( - app.world_mut().query::<&StatsScreen>().iter(app.world()).count(), - 0, - "screen must not exist before toggle" - ); - - // Simulate pressing S. - app.world_mut() - .resource_mut::>() - .press(KeyCode::KeyS); - app.update(); - - assert_eq!( - app.world_mut().query::<&StatsScreen>().iter(app.world()).count(), - 1, - "screen must appear after first S press" - ); - } - - #[test] - fn pressing_s_twice_closes_stats_screen() { - let mut app = headless_app(); - - app.world_mut() - .resource_mut::>() - .press(KeyCode::KeyS); - app.update(); - - // Release and re-press so just_pressed fires again. - app.world_mut() - .resource_mut::>() - .release(KeyCode::KeyS); - app.update(); - - app.world_mut() - .resource_mut::>() - .press(KeyCode::KeyS); - app.update(); - - assert_eq!( - app.world_mut().query::<&StatsScreen>().iter(app.world()).count(), - 0, - "screen must close after second S press" - ); - } -``` - -- [ ] **Step 2: Verify tests fail** - -```bash -cargo test -p solitaire_engine pressing_s 2>&1 | tail -5 -``` - -Expected: compile error — `StatsScreen` not found. - -- [ ] **Step 3: Implement stats screen toggle** - -Add the following to `solitaire_engine/src/stats_plugin.rs` — insert after the `update_stats_on_new_game` function and before the `tests` module: - -First add imports at the top of the file: -```rust -use bevy::input::ButtonInput; -use solitaire_data::{load_stats, save_stats, StatsSnapshot}; -``` -(replace the existing `use solitaire_data::{load_stats, save_stats, StatsSnapshot};` import) - -Add the full import block at the top: -```rust -use bevy::input::ButtonInput; -use bevy::prelude::*; -use solitaire_data::{load_stats, save_stats, StatsSnapshot}; - -use crate::events::{GameWonEvent, NewGameRequestEvent}; -use crate::game_plugin::GameMutation; -use crate::resources::GameStateResource; -``` - -Add the `StatsScreen` marker and `StatsPlugin::build` update: - -```rust -/// Marker component on the stats overlay root node. -#[derive(Component, Debug)] -pub struct StatsScreen; -``` - -Update `StatsPlugin::build` to also register the UI system: - -```rust -impl Plugin for StatsPlugin { - fn build(&self, app: &mut App) { - app.insert_resource(StatsResource(load_stats())) - .add_event::() - .add_event::() - .add_systems( - Update, - ( - update_stats_on_win, - update_stats_on_new_game, - toggle_stats_screen, - ) - .after(GameMutation), - ); - } -} -``` - -Add the toggle and spawn/despawn functions after `update_stats_on_new_game`: - -```rust -fn toggle_stats_screen( - mut commands: Commands, - keys: Res>, - stats: Res, - screens: Query>, -) { - if !keys.just_pressed(KeyCode::KeyS) { - return; - } - if let Ok(entity) = screens.get_single() { - commands.entity(entity).despawn_recursive(); - } else { - spawn_stats_screen(&mut commands, &stats.0); - } -} - -fn spawn_stats_screen(commands: &mut Commands, stats: &StatsSnapshot) { - let win_rate = stats - .win_rate() - .map_or("N/A".to_string(), |r| format!("{r:.1}%")); - let fastest = if stats.fastest_win_seconds == u64::MAX { - "N/A".to_string() - } else { - format_duration(stats.fastest_win_seconds) - }; - let avg = if stats.games_won == 0 { - "N/A".to_string() - } else { - format_duration(stats.avg_time_seconds) - }; - - let lines = vec![ - "=== Statistics ===".to_string(), - format!("Games Played: {}", stats.games_played), - format!("Games Won: {}", stats.games_won), - format!("Win Rate: {win_rate}"), - format!("Win Streak: {} (Best: {})", stats.win_streak_current, stats.win_streak_best), - format!("Best Score: {}", stats.best_single_score), - format!("Fastest Win: {fastest}"), - format!("Avg Win Time: {avg}"), - String::new(), - "Press S to close".to_string(), - ]; - - commands - .spawn(( - StatsScreen, - Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.0), - top: Val::Percent(0.0), - width: Val::Percent(100.0), - height: Val::Percent(100.0), - flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - row_gap: Val::Px(6.0), - ..default() - }, - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)), - ZIndex(200), - )) - .with_children(|b| { - for line in lines { - b.spawn(( - Text::new(line), - TextFont { font_size: 24.0, ..default() }, - TextColor(Color::srgb(0.95, 0.95, 0.90)), - )); - } - }); -} - -fn format_duration(secs: u64) -> String { - let m = secs / 60; - let s = secs % 60; - format!("{m}m {s:02}s") -} -``` - -The headless app needs `ButtonInput` registered. Add to `headless_app()` in tests: - -```rust -fn headless_app() -> App { - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(StatsPlugin); - app.init_resource::>(); - app.update(); - app -} -``` - -- [ ] **Step 4: Run tests** - -```bash -cargo test -p solitaire_engine stats_plugin 2>&1 | tail -10 -``` - -Expected: all 6 stats_plugin tests passing. - -- [ ] **Step 5: Update `solitaire_engine/src/lib.rs`** - -Add `stats_plugin` module and exports. The full updated section: - -```rust -pub mod animation_plugin; -pub mod card_plugin; -pub mod events; -pub mod game_plugin; -pub mod input_plugin; -pub mod layout; -pub mod resources; -pub mod stats_plugin; -pub mod table_plugin; - -pub use animation_plugin::{AnimationPlugin, CardAnim}; -pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; -pub use events::{ - AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, - NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, -}; -pub use game_plugin::{GameMutation, GamePlugin}; -pub use input_plugin::InputPlugin; -pub use layout::{compute_layout, Layout, LayoutResource}; -pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource}; -pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen}; -pub use table_plugin::{PileMarker, TableBackground, TablePlugin}; -``` - -- [ ] **Step 6: Update `solitaire_app/src/main.rs`** - -```rust -use bevy::prelude::*; -use solitaire_engine::{AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, StatsPlugin, TablePlugin}; - -fn main() { - App::new() - .add_plugins( - DefaultPlugins.set(WindowPlugin { - primary_window: Some(Window { - title: "Ferrous Solitaire".into(), - resolution: (1280.0, 800.0).into(), - ..default() - }), - ..default() - }), - ) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(CardPlugin) - .add_plugins(InputPlugin) - .add_plugins(AnimationPlugin) - .add_plugins(StatsPlugin) - .run(); -} -``` - -- [ ] **Step 7: Full workspace test + clippy** - -```bash -cargo test --workspace 2>&1 | grep -E "FAILED|test result" -cargo clippy --workspace -- -D warnings 2>&1 | tail -5 -``` - -Expected: all tests passing, 0 clippy warnings. - -- [ ] **Step 8: Commit** - -```bash -git add solitaire_engine/src/stats_plugin.rs solitaire_engine/src/lib.rs solitaire_app/src/main.rs -git commit -m "feat(engine): add stats screen overlay toggled with S key (Phase 4)" -``` - ---- - -## Task 5 — Final Gate - -**Files:** none new — just verification. - -- [ ] **Step 1: Full workspace test** - -```bash -cargo test --workspace 2>&1 | grep -E "test result|FAILED" -``` - -Expected: all test results show `ok`, no `FAILED` lines. Total passing count should be ≥ 120 (110 existing + ~13 new). - -- [ ] **Step 2: Clippy (zero warnings)** - -```bash -cargo clippy --workspace -- -D warnings 2>&1 | tail -3 -``` - -Expected: `Finished ... 0 warnings` - -- [ ] **Step 3: Smoke-test the running game** - -```bash -cargo run -p solitaire_app --features bevy/dynamic_linking -``` - -Verify manually: -- Game window opens and cards render -- Press `S` → stats overlay appears showing zeros (or loaded stats) -- Press `S` again → overlay closes -- Play a game to completion (drag cards, press D to draw, U to undo) -- Win detection triggers cascade animation -- Press `S` → games_played = 1, games_won = 1 displayed - -- [ ] **Step 4: Update SESSION_HANDOFF.md** - -Update `docs/SESSION_HANDOFF.md`: -- Mark Phase 4 complete in the commit history table -- Update "What Is Next" to point to Phase 5 (Achievements) -- Update the running test count - -- [ ] **Step 5: Final commit (if anything changed during smoke test)** - -```bash -git add -p # review any fixes made during smoke test -git commit -m "chore: update session handoff for Phase 4 completion" -``` - ---- - -## Cross-Cutting Rules (reminder) - -- `solitaire_core` and `solitaire_sync` must NOT gain new dependencies. -- `save_stats` / `load_stats` handle `dirs::data_dir() = None` without panicking. -- No `unwrap()` in new code — use `if let`, `unwrap_or_default()`, or `?`. -- `cargo clippy --workspace -- -D warnings` must pass after every task. -- `cargo test --workspace` must pass after every task. diff --git a/docs/ui-mockups/achievements-mobile.html b/docs/ui-mockups/achievements-mobile.html deleted file mode 100644 index 02b01a4..0000000 --- a/docs/ui-mockups/achievements-mobile.html +++ /dev/null @@ -1,293 +0,0 @@ - - - - - -Rusty Solitaire - Achievements - - - - - - - - - -
-
-achievements.json -
-
- 8/19 UNLOCKED -
-
- - -
- -
-
-PROGRESS -
-8/19 -(42%) -
-
-
-
-
-
- -
- - - - -
- -
- -
-
-emoji_events -
-
-

FIRST WIN

-

Win your first game

-
-
- -
-
-
-speed -
-
-

SPEED DEMON

-

Win in under 3:00

-
-
- -
-
-bolt -
-
-

STREAK 10

-

10 wins in a row

-
-
- -
-
-calendar_today -
-
-

DAILY DEFENDER

-

Complete 7 daily seeds

-
-
- -
-
-undo -
-
-

PERFECTIONIST

-

Win without using undo

-
-
- -
-
-military_tech -
-
-

CHALLENGE BEATEN

-

Complete CHALLENGE mode

-
-
- -
-
-help_outline -
-
-

????

-

SECRET · Hidden until unlocked

-
-
- -
-
-golf_course -
-
-

PAR HUNTER

-

Beat par on 50 games

-
-
-
-
- -
-
- -NORMAL - -achievements -
-
-
[F] filter
-
[/] search
-
-
- - - -
- \ No newline at end of file diff --git a/docs/ui-mockups/achievements-mobile.png b/docs/ui-mockups/achievements-mobile.png deleted file mode 100644 index b5776ad..0000000 Binary files a/docs/ui-mockups/achievements-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/card-face-migration.md b/docs/ui-mockups/card-face-migration.md deleted file mode 100644 index e3d175e..0000000 --- a/docs/ui-mockups/card-face-migration.md +++ /dev/null @@ -1,251 +0,0 @@ -# Card-face artwork migration plan - -**Status:** planning artifact (no code changed by this document). -**Tracks:** the "Card-face / suit / card-back artwork regeneration" -item in `SESSION_HANDOFF.md` → "Visual-identity follow-ups" -(SESSION_HANDOFF Resume prompt option D). -**Companion to:** `docs/ui-mockups/design-system.md` (Game Cards -spec, lines 214–233) and `docs/ui-mockups/desktop-adaptation.md` -(rules-based companion to the mockups). - -## Why this is a multi-session arc - -Every post-v0.20.0 visual-identity port to date (modal scaffold, -toasts, table chrome, splash boot screen, replay overlay) was a -**single rendering path** — change tokens, change comments, ship. -Cards have **two** rendering paths that are visually identical -today and would visually disagree the moment one moves: - -1. **PNG path (production).** `assets/cards/faces/.png` - loaded into `CardImageSet.faces[suit][rank]` at startup; card - sprites blit the texture. 52 face PNGs + 5 back PNGs already - in `assets/`, all the legacy white-card aesthetic from the - pre-Terminal design system. -2. **Constant fallback (tests + asset-missing edge).** When - `CardImageSet` isn't a registered resource (the case under - `MinimalPlugins` test fixtures, and the bare-bones path the - first-frame of production hits before assets resolve), the - renderer falls back to solid-colour sprites driven by the - `card_plugin` constants: - - `CARD_FACE_COLOUR` — `(0.98, 0.98, 0.95)` cream-ish white. - - `RED_SUIT_COLOUR` — `(0.78, 0.12, 0.15)` warm red. - - `BLACK_SUIT_COLOUR` — `(0.08, 0.08, 0.08)` near-black. - - `CARD_FACE_COLOUR_RED_CBM` — `(0.85, 0.92, 1.0, 1.0)` light - blue (the legacy color-blind tint). - - `card_back_colour(idx)` — five legacy back themes. - -A single-path migration leaves a known-broken state where tests -pass against Terminal constants while a human sees legacy artwork -on screen — the exact bisection-hostile drift the handoff's -"in lockstep" warning preempts. - -## Target state — Terminal aesthetic - -Per `design-system.md` § Game Cards (lines 214–233): - -### Card face - -| Element | Spec | -|---|---| -| Background | `#1a1a1a` | -| Border | 1 px solid in **suit colour** (pink for ♥/♦, foreground gray for ♠/♣) | -| Corner radius | 8 px | -| Top-left | rank in JetBrains Mono **Bold 18 px** + small suit glyph (10 px) | -| Bottom-right | large suit glyph (32 px), rotated 180° | -| Glyph fill rule | ♥ ♠ filled; ♦ ♣ outlined (1.5 px stroke). Always on, not a toggle. | - -### Suit colours (always-on glyph differentiation is the *primary* -distinguishing mechanism; colour is supplementary): - -| Suit | Default | Color-blind mode | -|---|---|---| -| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | -| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | -| Spades | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) | -| Clubs | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) | - -### Card back ("Terminal" theme) - -| Element | Spec | -|---|---| -| Background | `#151515` | -| Pattern | horizontal scanlines at 2 px pitch in `#1a1a1a` (1 px line, 1 px gap), full bleed | -| Border | 1 px solid `#353535` | -| Top-left badge | 12×16 px solid `#6fc2ef` block, 6 px from corner | -| Bottom-right monogram | `▌RS` in JetBrains Mono 12 px `#505050`, 6 px from corner | -| Corner radius | 8 px | -| Theme name / author | `"Terminal"` / `"Rusty Solitaire"` | - -## Generation pipeline — programmatic SVG via the existing -`resvg` stack - -### Why this path (vs. external tooling or direct `tiny_skia`) - -The codebase already ships an SVG-to-PNG rasteriser at -`solitaire_engine/src/assets/svg_loader.rs`: - -- Public `rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result` -- Backed by `usvg` (parser) + `resvg` (renderer) + `tiny_skia` - (CPU pixmap) -- Bundled font db includes JetBrains-style mono (FiraMono — same - face the splash uses; close enough to JetBrains Mono for - rasterisation purposes, and identical to what the Bevy UI - consumes in the rest of the app) -- `RenderAssetUsages::default()` is the call-site convention here - -This means: **generating new card PNGs is one new file -(`solitaire_engine/examples/card_face_generator.rs`) calling an -existing public function.** No new dependencies, no asset-pipeline -changes, no build-script machinery. Anyone who runs the example -gets bit-identical artwork. - -The two alternatives are weaker: - -- **External tool (Inkscape / Figma / hand-design)** — produces - one-off PNGs that can't be re-generated reproducibly without - re-opening the source files in a specific tool. Iteration cost - is high; design tweaks (e.g. "make the suit glyph 2 px larger") - require a designer-in-the-loop. -- **Direct `tiny_skia` painting calls** — bypasses SVG entirely, - but loses the readability of "open the SVG to see exactly what - the card looks like." Also reinvents primitives (rounded - rectangles, text layout) that `usvg` already handles. - -### Output format - -PNG, RGBA8 sRGB, **dimensions 256 × 384** (2:3 aspect, half the -default `SvgLoaderSettings` of 512 × 768). - -Rationale: cards never exceed ~250 px wide on desktop windows -today, and 256 × 384 PNGs are ~6 KB each at this content density -(13.4 KB total for a full deck of 52 + 5 backs). The default 512 × -768 is 2× what's needed and quadruples the on-disk asset weight. -The existing legacy PNGs are 512 × 768 — reducing the new ones -halves the runtime asset size. - -## Lockstep migration — recommended order - -Each step is a separate commit; the constraint is that **steps 4 -and 5 must land in the same commit** (or at most adjacent commits -on the same branch) so the rendered output never diverges between -the two paths. - -1. **(Done — this commit)** Land the migration plan doc. -2. **Land the SVG generator example.** New - `solitaire_engine/examples/card_face_generator.rs`. Output - goes to `assets/cards/faces/` and `assets/cards/backs/`. Run - once locally to seed the new artwork. The example file stays - in-tree as a regenerator for future tweaks. -3. **(Optional — can land separately)** Add a one-shot regression - test that re-runs the generator into a `tempdir` and compares - the resulting bytes against the on-disk artwork; pinning the - generator output prevents silent drift if `usvg`/`resvg` ever - tweak rendering. Skip if the test runtime cost is unacceptable. -4. **Land the new artwork** (PNG bytes from step 2 committed to - `assets/cards/`) **and** the constant migration in the *same - commit*: - - `CARD_FACE_COLOUR` → `Color::srgb(0.102, 0.102, 0.102)` (`#1a1a1a`) - - `RED_SUIT_COLOUR` → `Color::srgb(0.984, 0.624, 0.694)` (`#fb9fb1`) - - `BLACK_SUIT_COLOUR` → `Color::srgb(0.816, 0.816, 0.816)` (`#d0d0d0`) - - `CARD_FACE_COLOUR_RED_CBM` → `Color::srgb(0.435, 0.761, 0.937)` (`#6fc2ef`) — note this is now the colour-blind *suit* colour, not a face tint; semantics shift slightly. - - `card_back_colour(idx)` — re-author for the Terminal palette; - index 0 stays the canonical "Terminal" back from `design-system.md`. -5. **Test updates land in step 4's commit.** The pinning tests at - `card_plugin.rs` lines 1749, 1750, 1767, 1768, 2057, 2063, - 2071, 2081 all assert against the old constants. New - assertions update in lockstep with the constant changes. - -## CBM (color-blind mode) semantics shift — flag - -The **legacy** `CARD_FACE_COLOUR_RED_CBM` was a *face tint* — red -suits got a light-blue background wash. The **Terminal** spec -moves CBM into the *suit colour* itself (red glyphs swap to cyan). -Step 4 will rename / repurpose this constant; it's not a 1:1 -replacement. - -Two options: - -- **Rename + repurpose:** `CARD_FACE_COLOUR_RED_CBM` → - `RED_SUIT_COLOUR_CBM`. Communicates the semantic shift in the - symbol name. Requires touching every callsite. -- **Keep the name, change the meaning:** less code churn but - worse for greppability — a future reader hitting the legacy - name will assume face-tint behaviour. - -Recommendation: **rename**. The CBM swap is a one-frame operation -even if it touches every existing callsite (currently lines 642, -2071, 2081 per `grep -n CARD_FACE_COLOUR_RED_CBM`). - -## Theme system — out of scope here - -The card-theme system (`docs/CARD_PLAN.md`, `theme/plugin.rs`) -already supports user-supplied themes via `assets/themes//` -SVG files rasterised by `svg_loader.rs`. The new Terminal artwork -is the **default theme**, not a new entry in the theme picker — -the theme system continues to overlay user themes on top of the -default at runtime. - -If the next session wants to also ship Terminal as a *named theme -slot* (so a user can switch back to the legacy artwork via the -theme picker), that's an additive change after step 4 and lives -in `theme::plugin::apply_theme_to_card_image_set`. - -## Test impact summary - -`grep -n CARD_FACE_COLOUR\\b\|RED_SUIT_COLOUR\\b\|BLACK_SUIT_COLOUR\\b` in -`card_plugin.rs`: - -- Line 1749–1750: red-suit text colour assertions (♥ + ♦). -- Line 1767–1768: black-suit text colour assertions (♠ + ♣). -- Line 2057, 2063: face-colour assertion in default mode. -- Line 2071, 2081: face-colour assertion in CBM. - -The four suit-colour and two face-colour tests are **invariant -guards** — they exist precisely so a constant tweak surfaces here -rather than in a visual review. Step 4 updates each in lockstep -with the constant value change. No new test infrastructure -needed. - -## Open questions to resolve before step 4 - -1. **Border colour conflict.** The spec (line 218) says "Border: - 1 px solid in suit colour." The fallback path doesn't draw a - border today — it draws solid-colour sprites. Step 4 either: - (a) leaves the fallback as solid-colour squares (the test - environment doesn't visually validate borders anyway), or - (b) extends the fallback renderer to paint a 1 px outline. - Recommend (a) — fallback fidelity isn't load-bearing. -2. **Glyph rendering in the constant fallback.** The fallback - today doesn't render suit glyphs at all — it's a coloured - square. The spec's filled-vs-outlined glyph differentiation - only matters in the PNG path. No change to the constant - fallback for glyphs. -3. **High-contrast mode.** `design-system.md` line 274 mentions - a high-contrast accessibility mode (boosts foreground from - `#d0d0d0` to `#f5f5f5`, suit-red from `#fb9fb1` to `#ff8aa0`). - Not currently implemented anywhere; out of scope for this - migration but worth flagging for a future accessibility pass. - -## Post-migration — what's still open - -- **High-contrast mode** (above). -- **Reduced-motion mode** for card lift / drop transitions - (also a `design-system.md` accessibility item, separate from - artwork). -- **The 9 missing-plugin screens** (splash, challenge, - time-attack, weekly-goals, leaderboard, sync, level-up, - replay, radial-menu) per `project_ui_overhaul` memory still - need their plugin ports — separate from the cards arc. - -## Sign-off criteria for "D closed" - -D from the SESSION_HANDOFF Resume prompt is closed when **all of -the following hold simultaneously**: - -- The 52 face PNGs + 5 back PNGs in `assets/cards/` are the - Terminal-aesthetic artwork (regeneratable via the example). -- The five `card_plugin` constants reflect the Terminal palette. -- All pinning tests pass against the new values. -- A human boots the game and sees Terminal cards (not white - cards). This sign-off needs a real `cargo run`, not just - `cargo test`. diff --git a/docs/ui-mockups/challenge-mode-mobile.html b/docs/ui-mockups/challenge-mode-mobile.html deleted file mode 100644 index def5a11..0000000 --- a/docs/ui-mockups/challenge-mode-mobile.html +++ /dev/null @@ -1,219 +0,0 @@ - - - - - -Challenge Mode Menu - - - - - - - - - - -
- -
-▌challenge.tsx -LV 12 · UNLOCKED -
- -
-

CHALLENGE MODE

-

Curated puzzles · Beat par for bonus XP

-
- -
-
-DONE 8/24 -(33%) -
- -
BEST AVG 03:42
- -
+1240 XP
-
- -
- -
-
-
-
-DEEP STACK -Win with 0 stock · ★★★☆☆ -
-
- ✓ DONE -
-
-
- -
-
-
-
-SPEED RUN -Win under 2:30 · ★★☆☆☆ -
-
- ▶ ACTIVE -
-
-
- -
-
-
-
-NO UNDO -Win without undo · ★★★★☆ -
-
- ▶ ACTIVE -
-
-
- -
-
-
-
-FOUR SUITS -1 card per suit · ★☆☆☆☆ -
-
- ✓ DONE -
-
-
- -
-
-
-
-PERFECT RUN -Below par moves · ★★★★★ -
-
- 🔒 LOCKED -
-
-
- -
-
-END OF LIST -
-
-
- -
-
-▌ NORMAL - -challenge -
-
-[ENTER] select -[F] filter -[ESC] back -
-
- -
-
- \ No newline at end of file diff --git a/docs/ui-mockups/challenge-mode-mobile.png b/docs/ui-mockups/challenge-mode-mobile.png deleted file mode 100644 index b8ed516..0000000 Binary files a/docs/ui-mockups/challenge-mode-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/daily-challenge-mobile.html b/docs/ui-mockups/daily-challenge-mobile.html deleted file mode 100644 index a43d83b..0000000 --- a/docs/ui-mockups/daily-challenge-mobile.html +++ /dev/null @@ -1,258 +0,0 @@ - - - - - -Rusty Solitaire - Daily Challenge - - - - - - - - - -
-▌daily/2024-127.json -
-EXPIRES 11:42:30 -
-
- -
- -
-
-MAY 07 · 2026 -#2024-127 -
-DRAW-3 · DIFFICULTY ★★★☆☆ · PAR 04:30 -
- - - -
-YOUR ATTEMPTS -
-
-BEST 04:12 -
-WIN -RANK 17/2843 -
-
-LAST: FAILED at move 47 -
-
- -
-TOP TODAY · 2,843 PLAYERS -
- -
-
-01 -swift_jaguar -
-02:47 -
- -
-
-02 -base16_fan -
-03:12 -
- -
-
-03 -cli_player -
-03:54 -
- -
-
-04 -tablejockey -
-04:01 -
- -
-
-05 -vim_motions -
-04:05 -
- -
-
-17 -(YOU) anonymous -
-04:12 -
-
-
-terminal -END OF VISIBLE LOG -
-
-
- -
-
-▌ NORMAL │ daily -
-
-[ENTER] attempt -[L] full leaderboard -[ESC] back -
-
- - - - \ No newline at end of file diff --git a/docs/ui-mockups/daily-challenge-mobile.png b/docs/ui-mockups/daily-challenge-mobile.png deleted file mode 100644 index 3c65fc0..0000000 Binary files a/docs/ui-mockups/daily-challenge-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/design-system.md b/docs/ui-mockups/design-system.md deleted file mode 100644 index 8f20fa2..0000000 --- a/docs/ui-mockups/design-system.md +++ /dev/null @@ -1,285 +0,0 @@ ---- -name: Terminal -colors: - surface: '#151515' - surface-dim: '#0d0d0d' - surface-bright: '#2a2a2a' - surface-container-lowest: '#0a0a0a' - surface-container-low: '#1a1a1a' - surface-container: '#202020' - surface-container-high: '#2a2a2a' - surface-container-highest: '#353535' - on-surface: '#d0d0d0' - on-surface-variant: '#a0a0a0' - inverse-surface: '#d0d0d0' - inverse-on-surface: '#151515' - outline: '#505050' - outline-variant: '#353535' - surface-tint: '#a54242' - primary: '#a54242' - on-primary: '#151515' - primary-container: '#3a1f1f' - on-primary-container: '#d5a8a8' - inverse-primary: '#993e3e' - secondary: '#acc267' - on-secondary: '#151515' - secondary-container: '#2a3320' - on-secondary-container: '#c5d585' - tertiary: '#e1a3ee' - on-tertiary: '#151515' - tertiary-container: '#3a2a40' - on-tertiary-container: '#eec3f5' - error: '#fb9fb1' - on-error: '#151515' - error-container: '#4a2530' - on-error-container: '#fdc3ce' - background: '#151515' - on-background: '#d0d0d0' - surface-variant: '#353535' - suit-red: '#fb9fb1' - suit-black: '#d0d0d0' - suit-red-cb: '#acc267' - highlight-valid: '#acc267' - highlight-celebration: '#e1a3ee' - highlight-warning: '#ddb26f' - highlight-info: '#12cfc0' -typography: - hud-score: - fontFamily: JetBrains Mono - fontSize: 24px - fontWeight: '700' - lineHeight: 32px - letterSpacing: '-0.02em' - hud-timer: - fontFamily: JetBrains Mono - fontSize: 16px - fontWeight: '400' - lineHeight: 24px - card-rank: - fontFamily: JetBrains Mono - fontSize: 18px - fontWeight: '700' - lineHeight: 18px - body-md: - fontFamily: Inter - fontSize: 16px - fontWeight: '400' - lineHeight: 24px - label-caps: - fontFamily: JetBrains Mono - fontSize: 12px - fontWeight: '500' - lineHeight: 16px - letterSpacing: '0.08em' - headline: - fontFamily: JetBrains Mono - fontSize: 28px - fontWeight: '700' - lineHeight: 32px - letterSpacing: '-0.01em' -rounded: - sm: 0.125rem - DEFAULT: 0.25rem - md: 0.5rem - lg: 0.75rem - xl: 1rem - full: 9999px -spacing: - margin-edge: 1rem - gutter-card: 0.375rem - stack-overlap: 2rem - touch-target-min: 48dp ---- - -## Brand & Style - -The "Terminal" design system replaces the previous "Premium Solitaire" calm-indie aesthetic with a **retro-terminal / synthwave** identity. The intent is the visual confidence of a well-tuned terminal emulator (think Berkeley Mono dotfiles, base16-eighties, CRT phosphor): monospaced, dense, legible, snappy. It is *not* casino-glitz, *not* skeuomorphic felt, and *not* whimsical. - -The personality is **technical, deliberate, slightly playful**. Cards are flat with thin colored strokes; the HUD reads like a status bar; modals look like terminal panes. Motion is short and snap-easing — no bouncy springs. Long-session calm is preserved by keeping the chroma low and reserving saturated accents for *meaning* (CTAs, feedback, celebrations) rather than decoration. - -Influences: base16-eighties (Chris Kempson), Berkeley Mono, Vim/Neovim status lines, the iA Writer aesthetic, classic CRT phosphor with no chromatic aberration. - -## Palette - -The palette is base16-eighties — a 16-slot terminal palette where indices 00–07 form a monochrome ramp and 08–0F provide saturated accents. We map base16 slots to Material Design 3 token roles below. - -### Source palette (base16-eighties) - -| Slot | Hex | Role | -|---|---|---| -| base00 | `#151515` | background | -| base01 | `#202020` | surface-container | -| base02 | `#303030` | line-highlight (subtle) | -| base03 | `#505050` | outline / muted text | -| base04 | `#b0b0b0` | secondary text | -| base05 | `#d0d0d0` | foreground / on-surface | -| base06 | `#e0e0e0` | bright text | -| base07 | `#f5f5f5` | brightest highlight | -| base08 | `#fb9fb1` | red — used for `error`, `suit-red` | -| base09 | `#ddb26f` | orange — used for warning chips | -| base0A | `#acc267` | yellow/lime — used for `highlight-valid` (drag targets, valid moves) | -| base0B | `#12cfc0` | green/teal — used for `highlight-info` (toasts, neutral status) | -| base0C | `#6fc2ef` | cyan/sky — historically the primary CTA; now reserved for ad-hoc accents only | -| base0D | `#6fc2ef` | (alias) | -| base08 (project) | `#a54242` | brick red — primary CTA, focus ring, `selection` (project-specific extension; the base16-eighties `base08` slot is `#fb9fb1` pink which we keep as `error`/`suit-red`) | -| `suit-red-cb` slot | `#acc267` | lime — color-blind-mode swap for red suits (was `#6fc2ef` cyan before the 2026-05-08 primary-accent swap; lime is the next-best non-red base16-eighties accent) | -| base0E | `#e1a3ee` | violet — used for celebration (level-up, achievement unlock) | -| base0F | `#fb9fb1` | (alias) | - -### Semantic assignments - -- **CTA / Primary action**: brick red `#a54242`. Reserved for "Play," "New Game," "Save," "Resume," and the focus ring on selected cards. Never used decoratively. (Was cyan `#6fc2ef` before the 2026-05-08 swap.) -- **Valid-move / drag-target highlight**: lime `#acc267`. Reserved for in-game feedback only. Never appears in chrome. -- **Celebration**: lavender `#e1a3ee`. Used for level-up flashes, achievement unlock cards, and the daily-streak chip when the streak is active. Quiet otherwise. -- **Warning / soft alert**: gold `#ddb26f`. Used for "challenge expires in N minutes" chips, sync-pending status, and the daily-seed countdown. -- **Info**: teal `#12cfc0`. Used for neutral system toasts and the sync-connected indicator. -- **Error**: pink `#fb9fb1`. Used for sync conflict, server unreachable, invalid move shake. - -## Suit Colors - -**Two-color traditional pairing**, with mandatory color-blind -support. Saturated red for hearts + diamonds, near-white for clubs -+ spades — the "Microsoft Solitaire on dark mode" feel of a real -playing-card deck. (A brief 4-color-deck experiment shipped between -v0.21.0 and the next post-cut commit; reverted to traditional -2-color at the player's request.) - -| Suit | Default | Color-blind mode | Glyph differentiation | -|---|---|---|---| -| Hearts | `#e35353` (saturated red) | `#acc267` (lime) | Solid filled glyph | -| Diamonds | `#e35353` (saturated red) | `#acc267` (lime) | **Outlined glyph (1.5px stroke)** | -| Spades | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | Solid filled glyph | -| Clubs | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | **Outlined glyph (1.5px stroke)** | - -The outlined-glyph treatment is the **primary** differentiation mechanism. Color is supplementary. This means a player viewing the game on a monochrome display, or with severe red-green deficiency, can still distinguish all four suits without context. This is a hard requirement, not an optional setting. - -The "color-blind mode" toggle in Settings swaps both red suits (hearts + diamonds) from `#e35353` to `#acc267` (lime); clubs + spades stay at the near-white. The toggle does not turn the outlined glyphs on or off, because outlined glyphs are always on. (Was red→cyan before the 2026-05-08 primary-accent swap; CBM moved to lime to stay hue-distinct from the new red-family primary.) - -## Typography - -**Monospace-forward, dual-font system.** - -- **JetBrains Mono** is used for: HUD (score, timer, moves), card rank/value text, all labels, all headlines, all numerals anywhere in the app, and any chip-style component. This is the dominant face. -- **Inter** is used only for: long-form body copy (Help screen, Settings descriptions, achievement tooltips, onboarding copy). It is the *exception*, not the default. - -Weights: 400 regular, 500 medium for labels, 700 bold for HUD numbers and headlines. No 600 / no italics anywhere — the terminal aesthetic doesn't have them. - -Letter spacing: tight (`-0.02em`) on HUD score for visual mass; wide (`+0.08em`) on uppercase labels for readability at 12px. Body uses default (0). - -HUD numbers must use **tabular figures** (`font-feature-settings: 'tnum'`) so the timer and score don't reflow as digits change. - -## Layout & Spacing - -Optimized for **Android portrait, 390×844 (Pixel 6 baseline), API 34**. - -- **Margins**: 16px (1rem) edge safety margin. *Tighter than the previous system's 24px.* Eighties palettes are dense by nature; over-padding fights the aesthetic. -- **Tableau**: 7-column layout, 32px (2rem) vertical card overlap. Tighter than before to fit a longer cascade on phone screens. -- **HUD position**: top of screen, in the system safe area. Bottom 64px holds the action bar (Undo / Hint / New Game / Auto-complete). Action bar is **always visible** in-game — no hover-fade — because there is no hover on touch. -- **Touch target minimum**: 48dp on all interactive elements. Cards in the tableau may be smaller visually but use a 48dp invisible hit area centered on the visible glyph. - -## Elevation & Depth - -Depth is created through **tonal layering and 1px outlines**, not blur shadows. (Synthwave-flat, not Material-soft.) - -- **Level 0 (Background)**: the `#151515` base canvas. -- **Level 1 (Tableau slots, empty piles)**: 1px dashed outline in `#353535`. Empty foundations show a faint suit glyph at 12% opacity inside the outline. -- **Level 2 (Cards at rest)**: solid `#1a1a1a` fill, 1px solid border in the suit color (so the suit is detectable at a glance even if the card is partially obscured). -- **Level 3 (Active / dragged card)**: same border, but glow effect: 0 0 12px of `#a54242` at 40% opacity. **No scale transform** — flatness preserved. Z-index lifts above siblings. -- **Modals**: full-screen with backdrop `#151515` at 95% opacity (just enough to dim the table without blurring it). Modal panel is `#202020` with a 1px `#505050` border — like a terminal pane. -- **Toasts**: bottom of screen, `#202020` fill, 1px border in the toast's accent color (info=teal, warning=gold, error=pink, celebration=lavender). 16px monospaced caption. - -No `box-shadow` is used anywhere. **All depth is achieved with borders and tonal value.** This is a hard constraint. - -## Shapes - -The shape language is **soft-rounded but tight**: - -- **Cards**: `rounded-md` (8px) — slightly less rounded than the previous system's 16px to read more "technical." -- **Buttons / chips / inputs**: `rounded` (4px) default, `rounded-sm` (2px) for the smallest chips. -- **Modals / sheets**: `rounded-lg` (12px). -- **Avatars / circular indicators**: `rounded-full`. -- **Card-back pattern corners**: matches the card's `rounded-md`. - -Selection highlights use a **2px inset stroke** in `#a54242` following the host shape's corner radius. Never an outer stroke — the outer stroke is reserved for the suit-color hairline. - -## Motion - -**Snappy, no spring.** All transitions use `ease-out` with a 120ms duration unless specified. - -- Card lift (start drag): 80ms. -- Card place (drop): 120ms with a 16ms holdframe (no bounce). -- Modal enter: 200ms ease-out, fade + 8px translate-up. -- Modal exit: 120ms ease-in, fade only. -- Selection ring appear: 80ms. -- Win-summary stat reveal: 60ms each, staggered 40ms. -- HUD number tick: instant (no transition) — terminal counters don't ease. - -**Optional CRT effect**: a 1-frame scanline sweep across the screen on game-state transitions (start, win, restart). User-toggleable in Settings. Off by default. - -## Components - -### Game Cards - -Flat face design. -- Background: `#1a1a1a` -- Border: none. The card shape is defined by the body fill alone against the play surface. The earlier 1px suit-coloured border was removed because it produced visible anti-aliasing artifacts at the rounded corners (a "gray sliver" where the colored stroke faded through gray pixels into the dark play surface). The 5-unit brightness gap between `#1a1a1a` body and `#151515` surface is enough to read as a card edge without an explicit stroke. -- Top-left: rank in JetBrains Mono Bold 18px + small suit glyph (10px) -- Bottom-right: large suit glyph (32px), upright (same orientation as the top-left small glyph — single-orientation digital play does not benefit from the traditional 180° inverted-corner indicator) -- Corner radius: 8px -- Suit differentiation: hearts and spades have **filled** glyphs; diamonds and clubs have **outlined** glyphs (1.5px stroke) - -### Card Back ("Terminal" theme) - -- Theme name: `"Terminal"` -- Author: `"Rusty Solitaire"` -- Background: `#151515` -- Pattern: horizontal scanlines at 2px pitch in `#1a1a1a` (1px line, 1px gap), full bleed -- Border: 1px solid `#353535` -- Top-left badge: a 12×16px solid `#a54242` block (the "terminal cursor"), 6px from the corner -- Bottom-right monogram: the characters `▌RS` in JetBrains Mono 12px, color `#505050`, 6px from the corner -- Corner radius: 8px (matches face) - -### Primary Buttons - -Solid `#a54242` fill, `#151515` text, JetBrains Mono Medium 14px uppercase with `+0.08em` tracking. 4px corner radius. Pressed state: darken to `#7a3030`. Disabled: `#353535` fill, `#505050` text. - -### Secondary Buttons - -Transparent fill, 1px `#505050` border, `#d0d0d0` text. Hover/press: border becomes `#a54242`, text becomes `#a54242`. - -### HUD Chips - -`#202020` fill, no border, 4px radius. Monospaced 16px text. Score chip pulses to `#acc267` for 200ms when score increases. - -### Drag Targets - -When a card is being dragged over a valid pile, the pile's empty-slot dashed outline becomes: -- Solid 1px in `#acc267` -- Plus a 0 0 8px outer glow in `#acc267` at 30% opacity - -This is the *only* place glow effects appear in the system. - -### Modals - -Full-screen backdrop at 95% opacity. Centered panel: `#202020` fill, 1px `#505050` border, 12px corner radius. Title bar shows the screen name in monospaced 14px, color `#a0a0a0`, with a single `▌` cursor character prefix to reinforce the terminal pane motif. - -### Navigation Bar - -Fixed at the bottom of in-game screens. Height: 64px. `#202020` fill, 1px top border in `#353535`. Four icon buttons: Undo / Hint / New / Auto-complete. Icons: 24px, 1.5px stroke weight, color `#d0d0d0`. Active/pressed: icon color `#a54242`. - -### Status / Sync Indicator - -Top-right corner of the HUD: a 6px circular dot. -- Connected & synced: `#12cfc0` -- Pending: `#ddb26f` (pulsing 1.5s) -- Error: `#fb9fb1` (steady) -- Offline: `#505050` - -## Accessibility - -1. **Color-blind mode** (Settings → Gameplay): swaps the red suits' default `#e35353` for `#acc267` (lime). Outlined-glyph differentiation remains active in *all* modes. -2. **High-contrast mode** (Settings → Gameplay): boosts on-surface from `#d0d0d0` to `#f5f5f5`, outline from `#505050` to `#a0a0a0`, suit-red from `#fb9fb1` to `#ff8aa0`. -3. **Reduce-motion mode** (Settings → Gameplay): disables card-lift transition (instant z-lift), disables CRT scanline effect, disables the warning-chip pulse animation. -4. **Tabular figures** are mandatory for any number that updates live (timer, score, moves) so they don't reflow. -5. **Touch targets** are 48dp minimum even when the visual element is smaller. -6. **Text contrast**: all body text on background passes WCAG AA at minimum (`#d0d0d0` on `#151515` = 9.5:1; `#a0a0a0` on `#151515` = 5.7:1). diff --git a/docs/ui-mockups/desktop-adaptation.md b/docs/ui-mockups/desktop-adaptation.md deleted file mode 100644 index 9fd2625..0000000 --- a/docs/ui-mockups/desktop-adaptation.md +++ /dev/null @@ -1,283 +0,0 @@ -# Terminal — Desktop Adaptation Spec - -> **Why this exists.** The 24 mockups in this directory are mobile -> (390 × 844 logical, iPhone 14 Pro frame) with one exception -> (`home-menu-desktop.html`). The Stitch project that produced them -> is named "Ferrous Solitaire *Mobile* Redesign" — the mobile-first -> framing was deliberate when the new Android target opened, but -> desktop is still the primary delivery surface. Porting the mobile -> mockups 1:1 would land a 390-px-wide column floating in the middle -> of an 1800 × 1100 window. This file is the rules-based desktop -> companion — apply these adaptations whenever you port a Bevy -> plugin against a mobile mockup in this directory. - -## Status - -* **Token system.** All tokens (palette, type scale, spacing, - radii, motion) in `design-system.md` are layout-agnostic and - apply unchanged on both targets. Do **not** introduce desktop- - specific token variants — adapt geometry, not tokens. -* **Already adapted in code.** v0.20.0's port is layout-agnostic - (modal scaffold, toasts, table chrome, card chrome, gameplay- - feedback, splash cursor). Those surfaces already adapt - correctly because their Bevy UI nodes use flex / percent / - stretch sizing rather than fixed pixel widths from the - mockups. -* **Not yet adapted in code.** Any future plugin port that - copies layout from a mobile mockup must apply the rules below. - -## Viewport assumptions - -| Range | Width × height | Source | -|---|---|---| -| Mobile target | 390 × 844 | iPhone 14 Pro logical, Stitch mockup canvas | -| Desktop minimum | 1024 × 600 | Smaller windows degrade to mobile rules | -| Desktop default | ~70 % of monitor | `apply_smart_default_window_size` (since v0.19.0) | -| Desktop typical | 1600 × 900 to 2560 × 1440 | The range we tune for | -| Desktop max | 3840 × 2160 | 4K, with HiDPI scaling already applied | - -The "smart default" sizer means a 1080p monitor opens a ~1344 × 756 -window, a 1440p monitor opens ~1792 × 1008, a 4K monitor opens -~2688 × 1512. Tune for the 1600–2400 width band as the centre of -the distribution; below 1024 width, fall back to the mobile rules -verbatim. - -## Universal adaptation rules - -Apply these to every screen unless the per-screen section -overrides them. - -### 1. Edge margins - -| Mobile | Desktop | -|---|---| -| `margin-edge: 16px` (`SPACE_4`) | `SPACE_5` (24 px) for windows < 1440 wide; `SPACE_6` (32 px) for 1440–2400; `SPACE_7` (48 px) for ≥ 2400 | - -Engine: drive from `LayoutResource` based on `Window` size, not a -constant. - -### 2. Modal max-width - -| Mobile | Desktop | -|---|---| -| `100% - 2 × edge-margin` | `min(720 px, 50 % of viewport)` | - -The 720 px cap is already in `ui_modal::spawn_modal`. No code -change needed; this rule documents *why* it's there. - -### 3. Vertical content stacks - -A mobile screen often stacks `Header → Body → Footer` vertically -to fit a tall narrow column. On desktop, prefer horizontal -distribution where the content allows: - -* **Header rows that stack vertically on mobile** (title above - count above timer) → keep them in one horizontal row on - desktop. -* **Two-column flex layouts** (e.g. Settings rows: label left, - control right) — already work on both targets; no change. -* **Cards stacking with `mt-48`-style fixed gaps** — replace with - flex / percent gaps so the layout breathes. - -### 4. Touch-target minimums - -Mobile spec mandates 48 dp minimum touch targets. Desktop has no -such floor (mouse precision is finer), but **don't shrink below -mobile's 48 px** for primary actions — keyboard / gamepad focus -rings still need a visible target. - -Secondary controls (chip-style toggles, hotkey hints, etc.) can -shrink to `TYPE_BODY` (14 px) text + `SPACE_3` (12 px) padding on -desktop where they were larger on mobile. - -### 5. Bottom-anchored elements - -Mobile mockups often anchor key controls (action bar, primary CTA, -toast position) to the bottom of the viewport for thumb reach. -Desktop has no thumb-reach concern: - -* **Toasts** — keep bottom-anchored (already done in `a137607`), - the design language is consistent across targets and the - bottom is still the least-disruptive overlay zone. -* **Action bars** — top of viewport on desktop unless the - per-screen section says otherwise. The HUD already sits on - top. -* **Single primary CTA** — modals already right-align in the - actions row; no change. - -### 6. Typography rungs unchanged - -Do **not** shift `TYPE_*` tokens up a rung for desktop. The -spec's 14 / 18 / 26 / 40 progression is already calibrated for -the desktop reading distance (60–90 cm). Mobile uses the same -rungs at a closer reading distance (30–40 cm); same physical -angular size on the eye. - -### 7. Hotkey hints become full strings - -Mobile cells like `▌Esc` — the cursor block plus key letter — can -expand to `[Esc] cancel` style on desktop where horizontal -real-estate is cheap. Drives discoverability of keyboard-only -flows. Optional; only apply where horizontal space exists. - -## Per-screen adaptation rules - -### Game Table - -Mockup: `game-table-mobile.html` (390 × 844). - -| Element | Mobile | Desktop | -|---|---|---| -| HUD band | full width, 56 px tall | full width, 48 px tall | -| Foundation row | 4 piles centred, fan-tight | 4 piles centred, **gutter doubled** so the row fills ~50 % of viewport width | -| Stock + waste | left of foundations, stacked | left of foundations, **horizontal pair**: stock on the left, waste to its immediate right (the mobile vertical pair feels cramped on a wide canvas) | -| Tableau row | 7 columns, 4 % gutter | 7 columns, **6 % gutter**, total tableau block ≤ 70 % viewport width | -| Card aspect | 2 : 3 (already in `Layout::card_size`) | unchanged — card aspect is domain | -| Tableau fan | `TABLEAU_FAN_FRAC = 0.25` | unchanged — fan is in card-height units, not viewport units | -| Drag-shadow offset | small | unchanged — pinned to 0 alpha under Terminal anyway | - -**Engine impact:** `solitaire_engine/src/layout.rs::compute_layout` -already drives most of this from `Window::size()`. The mobile vs. -desktop difference is the gutter percentages — bake desktop -gutters when window width ≥ 1024. - -### Win Summary - -Mockup: `win-summary-mobile.html` (390 × 858). - -| Element | Mobile | Desktop | -|---|---|---| -| Modal width | 100 % − 2 × edge | **`min(720 px, 50 % viewport)`** (already done by `ui_modal`) | -| Score row | stacked vertically (line per metric) | **3-column grid**: Score / Time / Moves in one row, breakdown rows below in single-line per row | -| Action buttons | full-width stacked (Play Again, Continue, Stats) | **right-aligned action row** — the existing `spawn_modal_actions` already does this on both targets | - -**Engine impact:** `solitaire_engine/src/win_summary_plugin.rs`. The -score-breakdown-stagger animation (`MOTION_SCORE_BREAKDOWN_*`) is -unchanged across targets. - -### Settings - -Mockup: `settings-mobile.html` (390 × 4330 — long scroll). - -| Element | Mobile | Desktop | -|---|---|---| -| Modal width | 100 % − 2 × edge | `min(720 px, 50 % viewport)` | -| Sections | full-width labels above stacked controls | **section labels left, control widget right** — already the engine's pattern; no change | -| Long page | scroll the whole modal | **two-column layout**: nav (sections list) on left ~30 %, current section on right ~70 %. Reduces scroll distance on desktop | -| Sliders | full-width on mobile | cap at 320 px on desktop | - -**Engine impact:** if a desktop port wants the two-column nav, it's -a `settings_plugin` rewrite. Keep the existing single-column -stacked-modal layout for now — it works on both targets and the -two-column variant is a polish item, not a blocker. - -### Help & Controls - -Mockup: `help-mobile.html` (390 × 2544). - -| Element | Mobile | Desktop | -|---|---|---| -| Modal width | 100 % − 2 × edge | `min(720 px, 50 % viewport)` | -| Section list | one column of `Heading → 2-col rows` | **two columns of section blocks** for windows ≥ 1280 wide; halves vertical scroll distance | -| Hotkey rows | `key | description` 2-col flex | unchanged; 2-col already adapts | - -**Engine impact:** `help_plugin`. Single-column on mobile, 2-col -on desktop windows ≥ 1280 wide is a flex-wrap option. - -### Pause Menu - -Mockup: `pause-menu-mobile.html` (390 × 1768). - -Already a small modal; no significant geometry change. Modal -already uses `ui_modal::spawn_modal` which caps width and centres. -No desktop-specific rule. - -### Home Menu - -Mockup: `home-menu-mobile.html` and `home-menu-desktop.html` -(both already in this directory — desktop variant is the -authoritative reference). - -The desktop mockup already specifies the layout. Cross-check it -against the mobile version when porting; differences are -deliberate (more horizontal real-estate, larger primary CTA, the -secondary actions row). - -### Splash - -Mockup: `splash-mobile.html` (390 × 844). - -| Element | Mobile | Desktop | -|---|---|---| -| Full-screen overlay | `inset-0` | unchanged — splash always covers the viewport | -| Cursor block (`▌`) | 96 px JetBrains Mono | unchanged — already done in `cdcadda`. The 96 px size scales fine on desktop because the splash is a brand beat, not a layout-driven element | -| Title `RUSTY SOLITAIRE` | 32 px | scale to 40 px (`TYPE_DISPLAY`) on desktop | -| Subtitle `TERMINAL EDITION` | 12 px | unchanged | -| Boot log lines | 70 % width column | cap at 480 px so the column doesn't stretch on a wide window | -| Progress bar | 100 % − 2 × edge | cap at 720 px | -| Palette swatch row + version footer | bottom-anchored | unchanged; bottom-anchor still reads correctly on desktop | - -**Engine impact:** `splash_plugin` already has the cursor block -(`cdcadda`). The boot log / progress bar / palette swatch rows -are the next polish increment when option D is picked up. - -### Stats - -Mockup: `stats-mobile.html` (390 × 2624). - -| Element | Mobile | Desktop | -|---|---|---| -| Modal width | 100 % − 2 × edge | `min(720 px, 50 % viewport)` | -| Big-number cards | 2 × 2 grid | **4 × 1 row** for windows ≥ 1024 wide (the four headline metrics fit in a single horizontal row at desktop scale) | -| Latest-win caption | full-width line | unchanged | -| Replay clip / share row | full-width row | unchanged | - -### Profile / Achievements / Theme Picker / Daily Challenge - -These follow the **standard modal pattern** (`spawn_modal` with -header / body / actions). They already work on desktop because -`ui_modal` handles modal-width capping. Per-screen tweaks are -small and listed below; no structural changes: - -* **Profile** — avatar + level / streak chips can flow into a - single horizontal row on desktop instead of stacking. -* **Achievements** — 3 × N grid on mobile becomes 4 × N or 5 × N - on desktop where windows ≥ 1280 wide. -* **Theme Picker** — 2-col grid of theme cards on mobile becomes - 3- or 4-col on desktop. -* **Daily Challenge** — single-column scroll on both; no change. - -## Mockup parity gap - -The 9 missing-plugin screens (`splash`, `challenge`, `time-attack`, -`weekly-goals`, `leaderboard`, `sync`, `level-up`, `replay-overlay`, -`radial-menu`) have only mobile mockups. When porting any of these -plugins: - -1. Read the mobile mockup for content + visual hierarchy. -2. Apply the universal adaptation rules above. -3. Apply the closest matching per-screen rule (e.g. an info modal - uses the same shape as Win Summary or Stats). -4. **No new layout pattern without explicit user approval.** - Adapting an existing pattern is in scope; inventing a desktop- - specific component is design work and should be flagged as such. - -## Process notes - -* **Smart-default sizer is the layout's source of truth.** Before - reading the mockup, always re-read `Window::size()` — - `apply_smart_default_window_size` runs at startup and the - player can resize freely. Hardcoded breakpoints in plugin code - should reference the *current* `Window` width via a - `LayoutResource` lookup, not the launch size. -* **`WindowResized` already drives layout recomputes** (CLAUDE.md - §3.4). Any per-window-width adaptation in this file should hook - into the existing recompute path, not a new system. -* **Mobile rules win at narrow desktop windows.** A user dragging - their desktop window down to 600 px width is closer to the - mobile use-case than the desktop one. Below 1024 px width, - apply the mobile rules verbatim. -* **Run on a 4K monitor before declaring a port done.** HiDPI - scaling routes through Bevy's logical sizing, but visual - polish (border thickness, motion budgets at high refresh rate) - is worth eyeballing. diff --git a/docs/ui-mockups/game-table-mobile.html b/docs/ui-mockups/game-table-mobile.html deleted file mode 100644 index 9d8d1a3..0000000 --- a/docs/ui-mockups/game-table-mobile.html +++ /dev/null @@ -1,253 +0,0 @@ - - - - - - - - - - - - - - -
-
-terminal -

solitaire.sh

-
-
-
-settings -
-
- -
-
-SCORE -247 -
-
-TIME -12:34 -
-
-MOVES -87 -
-
- -
- -
- -
-
-
▌RS
-
STOCK · 18
-
- -
-
10
-
-
- -
- -
- -
- -
-
2
-
-
- -
- -
- -
- -
-
- -
- -
-
-
K
-
-
- -
-
-
-
Q
-
-
- -
-
-
-
-
10
-
-
- -
-
-
- -
-
9
-
-
- -
-
- -
- -
- -
-
4
-
-
-
-
-
- - - -
- \ No newline at end of file diff --git a/docs/ui-mockups/game-table-mobile.png b/docs/ui-mockups/game-table-mobile.png deleted file mode 100644 index 1e3238d..0000000 Binary files a/docs/ui-mockups/game-table-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/help-mobile.html b/docs/ui-mockups/help-mobile.html deleted file mode 100644 index bc0cdfa..0000000 --- a/docs/ui-mockups/help-mobile.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - - - - - -
- -
-▌rusty-solitaire(1) · MAN PAGE - -
- -
-

GESTURES & SHORTCUTS

-

Touch gestures and keyboard equivalents.

-
- -
- -
-

TOUCH GESTURES

-
- -
-
-square -TAP card -
-
Select / unselect for move
-
- -
-
-east -DRAG stack -
-
Move with translucent ghost preview
-
- -
-
-double_arrow -DOUBLE-TAP -
-
Auto-send to best foundation
-
- -
-
-touch_app -LONG-PRESS -
-
Highlight all legal moves for card
-
- -
-
-south -SWIPE DOWN -
-
Reveal hidden action bar
-
-
-
- -
-

KEYBOARD SHORTCUTS

-
- -
-
[U]
-
Undo last move
-
- -
-
[H]
-
Show hint
-
- -
-
[N]
-
New game
-
- -
-
[A]
-
Auto-complete (when possible)
-
- -
-
[ESC]
-
Pause / back
-
-
-
-
- -
-
-▌ NORMAL │ help -
-
-PRESS -[ESC] -OR TAP -× -TO RETURN -
-
-
- \ No newline at end of file diff --git a/docs/ui-mockups/help-mobile.png b/docs/ui-mockups/help-mobile.png deleted file mode 100644 index f706888..0000000 Binary files a/docs/ui-mockups/help-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/home-menu-desktop.html b/docs/ui-mockups/home-menu-desktop.html deleted file mode 100644 index 86ee9ea..0000000 --- a/docs/ui-mockups/home-menu-desktop.html +++ /dev/null @@ -1,343 +0,0 @@ - - - - - -RS_TERMINAL_OS - Rusty Solitaire - - - - - - - - -
-
- -

RS_TERMINAL_OS

-
- -
-
-LV 12 -| -
-XP 320/500 -
-
-
-
-
-| -
- -Synced -
-| -v0.20.0 -
-
- -
- -
-
-

▌play.tsx

-

Ready to play?

-

RESUME · 12:34 ELAPSED · DRAW-3

-
- -
- - -
-
-

Game Modes

-
- -
-spa -Zen -
- -
-timer -Time
Attack
-
- -
-lock -Challenge -
LV 5
-
-
-
- -
-
- -
-
-
- -
-
-

▌daily.json

-
-

MAY 07 · 2026

-EXPIRES 11:42:30 -
-
-
-
-

Current Seed

-

#2024-127

-
- -
-
-

Global Standings

-
-
-01 │ swift_jaguar -02:47 -
-
-02 │ pixel_drifter -03:12 -
-
-03 │ null_ptr -03:15 -
-
-04 │ core_dump_88 -03:44 -
-
-12 │ YOU (anon) ---:-- -
-
-
-
- -
-
-

▌stats.log

-
-
-
-

Games

-

247

-
-
-

Win Rate

-

61%

-
-
-

Best Time

-

01:54

-
-
-

Streak

-

7

-
-
-
-

Achievements (8/19)

-
- -
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
RS
-
-

anonymous@local

-

Session: Active

-
-
-arrow_forward -
-
-
- -
-
-▌ NORMAL - -~/rusty-solitaire/home -
-
-
[SPACE] play
-
[D] daily
-
[S] settings
-
[?] help
-
-
- 2026-05-07 17:42 EDT -
-
- -
-
-
- \ No newline at end of file diff --git a/docs/ui-mockups/home-menu-desktop.png b/docs/ui-mockups/home-menu-desktop.png deleted file mode 100644 index a0fa836..0000000 Binary files a/docs/ui-mockups/home-menu-desktop.png and /dev/null differ diff --git a/docs/ui-mockups/home-menu-mobile.html b/docs/ui-mockups/home-menu-mobile.html deleted file mode 100644 index a14520c..0000000 --- a/docs/ui-mockups/home-menu-mobile.html +++ /dev/null @@ -1,225 +0,0 @@ - - - - - -Rusty Solitaire - Main Menu - - - - - - - - - - -
- -
-
-
- -
-
-▌RUSTY SOLITAIRE -
-
-
-LV 12 -
-
-
- -
- -
-
-
-
-
- 320 / 500 XP -
-
- -
- -
- RESUME LAST GAME · 12:34 ELAPSED -
-
- -
-
-
-DAILY CHALLENGE -DRAW-3 · SEED #2024-127 -
-EXPIRES 11:42:30 -
-
-chevron_right -
-
- -
-

SPECIAL MODES

-
- - - - - - -
-
- -
- - - - -
- -
-
-SETTINGS -· -HELP -
-
- v0.20.0 — TERMINAL THEME · BUILD 2026.05 -
-
-
- \ No newline at end of file diff --git a/docs/ui-mockups/home-menu-mobile.png b/docs/ui-mockups/home-menu-mobile.png deleted file mode 100644 index 9b4d61c..0000000 Binary files a/docs/ui-mockups/home-menu-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/leaderboard-mobile.html b/docs/ui-mockups/leaderboard-mobile.html deleted file mode 100644 index 9cd5935..0000000 --- a/docs/ui-mockups/leaderboard-mobile.html +++ /dev/null @@ -1,315 +0,0 @@ - - - - - -Rusty Solitaire - Leaderboard - - - - - - - - -
- -
-
-terminal -

Rusty Solitaire

-
-
-sync -
-
-
- -
-
▌leaderboard.tsx
-
- - -SYNCED - -v0.20.0 -
-
- - -
- -
-
TOP 3 · TODAY
-
- -
-02 -base16_fan -03:12 -
- -
-star -01 -swift_jaguar -02:47 -
- -
-03 -cli_player -03:54 -
-
-
- -
-
-[ ALL TIMES ] -
-
-/ search players -
-
- -
- -
-Rank & User -Time -
- -
-
-004 -tablejockey -
-04:01 -
- -
-
-005 -vim_motions -
-04:05 -
- -
-
-006 -tmux_lover -
-04:18 -
- -
-
-007 -nvim_dotfiles -
-04:23 -
- -
-
-008 -dark_theme -
-04:31 -
- -
...
- -
-
-▶ 017 -anonymous (YOU) -
-04:12 -
- -
-
-018 -bash_brawler -
-05:01 -
- -
-
-019 -curl_master -
-05:14 -
-
-
- -
-
- NORMAL │ leaderboard -
-
-[1-4] tab -[/] search -[ESC] back -
-
- - -
- \ No newline at end of file diff --git a/docs/ui-mockups/leaderboard-mobile.png b/docs/ui-mockups/leaderboard-mobile.png deleted file mode 100644 index 05c73b0..0000000 Binary files a/docs/ui-mockups/leaderboard-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/level-up-mobile.html b/docs/ui-mockups/level-up-mobile.html deleted file mode 100644 index 2e8d4ef..0000000 --- a/docs/ui-mockups/level-up-mobile.html +++ /dev/null @@ -1,259 +0,0 @@ - - - - - -ROOT@SOLITAIRE:~ | LEVEL UP - - - - - - - - - -
-
ROOT@SOLITAIRE:~
-
-memory -settings_ethernet -wifi_tethering -
-
- -
- -
-
-SCORE -04,820 -
-
-TIMER -04:12 -
-
- -
-
-
-
-
-
-
▌RS
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
- -
- -
- -level-up.tsx -
- -
- -
-
▲ LEVEL UP
-
-13 -
-FROM 12 -
-
-
█ NEW PERKS UNLOCKED
-
- -
- -
-▢ +1 daily challenge slot -NEW -
- -
-▢ Background: Forest -UNLOCKED -
- -
-▢ Card-back: Stripes -UNLOCKED -
-
- -
-XP -+200 XP THIS LEVEL -
-
-
-
-
- - - -
-
- -
-Tap anywhere to dismiss -
-
- - - -
- \ No newline at end of file diff --git a/docs/ui-mockups/level-up-mobile.png b/docs/ui-mockups/level-up-mobile.png deleted file mode 100644 index 69493ad..0000000 Binary files a/docs/ui-mockups/level-up-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/onboarding-draw-mobile.html b/docs/ui-mockups/onboarding-draw-mobile.html deleted file mode 100644 index f686052..0000000 --- a/docs/ui-mockups/onboarding-draw-mobile.html +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - - - - - - - - - - -
- -
-
▌onboard/01-draw.tsx
-
STEP 1 OF 3
-
- -
- -
-
-

- WELCOME ▌_ -

-
- -
-

CHOOSE A DRAW MODE

-

You can change this any time in Settings.

-
- -
- -
-
-RECOMMENDED -
-
-filter_3 -
-
-

DRAW-3 (CLASSIC)

-

- Cycle 3 cards at a time. Standard solitaire rules for a tactical challenge. -

-
-
- -
-
-filter_1 -
-
-

DRAW-1 (EASY)

-

- Cycle one card at a time. More winnable, faster pace, perfect for quick sessions. -

-
-
-
- -
-
-
-
-
-
-
-[1] -[2] -[3] -
-
-
- -
- - - - -
- -
-
- \ No newline at end of file diff --git a/docs/ui-mockups/onboarding-draw-mobile.png b/docs/ui-mockups/onboarding-draw-mobile.png deleted file mode 100644 index bb71669..0000000 Binary files a/docs/ui-mockups/onboarding-draw-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/onboarding-tap-mobile.html b/docs/ui-mockups/onboarding-tap-mobile.html deleted file mode 100644 index 64553b1..0000000 --- a/docs/ui-mockups/onboarding-tap-mobile.html +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - - - - - - - - - - -
-
-terminal -▌onboard/03-demo.tsx -
-
STEP 3 OF 3
-
-
- -
-

TRY IT OUT

-

Tap a face-up card to auto-move it to the best legal pile.

-
- -
- -
-
- -
-playing_cards -
- -
-
MOVES HERE
-arrow_upward -
- -
- -
-
A
-playing_cards - -
-touch_app -
-
- -
-
K
-favorite -
- -
-
Q
-groups -
-
-
-
- -
- -TAP THE A♠ TO CONTINUE - -
- -
-
-check_circle -TAP TO AUTO-MOVE -
-
-check_circle -DRAG TO TARGET PILE -
-
-check_circle -DOUBLE-TAP TO FOUNDATION -
-
- -
-
-
-
-
-
- -
- - -
- \ No newline at end of file diff --git a/docs/ui-mockups/onboarding-tap-mobile.png b/docs/ui-mockups/onboarding-tap-mobile.png deleted file mode 100644 index 424f454..0000000 Binary files a/docs/ui-mockups/onboarding-tap-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/onboarding-theme-mobile.html b/docs/ui-mockups/onboarding-theme-mobile.html deleted file mode 100644 index f081142..0000000 --- a/docs/ui-mockups/onboarding-theme-mobile.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - - - - - - - - - - - - -
-▌onboard/02-theme.tsx -STEP 2 OF 3 -
- -
-
- -
-
-
-
-
-
-

PICK YOUR DECK

-
- -
-

CHOOSE A CARD-BACK

-

- You can swap or import more themes from Settings later. -

-
- -
- -
-
-
-
-
▌RS
-
-
-check -
-
-TERMINAL -
- -
-
-
-
-CLASSIC -
- -
-
-
-
-STRIPES -
-
- -
- -+ MORE IN SETTINGS - -
- -
-
-
-
-
-
-
-[1] -[2] -[3] -
-
- -
- - -
- - - \ No newline at end of file diff --git a/docs/ui-mockups/onboarding-theme-mobile.png b/docs/ui-mockups/onboarding-theme-mobile.png deleted file mode 100644 index a3fe341..0000000 Binary files a/docs/ui-mockups/onboarding-theme-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/pause-menu-mobile.html b/docs/ui-mockups/pause-menu-mobile.html deleted file mode 100644 index 7f50996..0000000 --- a/docs/ui-mockups/pause-menu-mobile.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - -Rouge Solitaire - Pause - - - - - - - - - - -
-Game Tableau Background - -
- -
-
- -
- -
- -
-
- -pause.tsx -
- -
- -
- -

- GAME PAUSED -

- -

- 12:34 ELAPSED · 87 MOVES · DRAW-3 -

- -
-
-SCORE 247 -
-
-STOCK 18 -
-
-MOVES 87 -
-
- -
- - - - - - -
-
- -
-
- -NORMAL - -pause -
-
-[ESC] -resume -
-
-
-
- - - - - \ No newline at end of file diff --git a/docs/ui-mockups/pause-menu-mobile.png b/docs/ui-mockups/pause-menu-mobile.png deleted file mode 100644 index ff5594c..0000000 Binary files a/docs/ui-mockups/pause-menu-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/profile-mobile.html b/docs/ui-mockups/profile-mobile.html deleted file mode 100644 index 482ea4f..0000000 --- a/docs/ui-mockups/profile-mobile.html +++ /dev/null @@ -1,274 +0,0 @@ - - - - - - - - - - - - - -
- -
-
- ▌profile.tsx -
-
- -● SYNCED -
-
- -
- -
-
-RS -
-
-

anonymous@local

-

MEMBER SINCE 2026-04-22

-
-247 GAMES -61% WR -12 STREAK -
-
-
- -
-
-LEVEL 12 -320/500 XP -
-
-
-
-
- -180 XP TO LEVEL 13 -
-
- -
-

▌ unlocked.cards

-
- -
-
-
-
-
▌RS
-
-
-ACTIVE -
- -
-
-
-
-TAP TO USE -
- -
-
-
-
-TAP TO USE -
- -
-
-
-
-TAP TO USE -
-
-
- -
-

▌ unlocked.backgrounds

-
- -
-
-ACTIVE -
- -
-
-FOREST -
- -
-
-SLATE -
- -
-
-MIDNIGHT -
-
-
- -
- -
-
- -
-
-terminal -~/root/usr/settings -
-
- -
-
- - - -
- \ No newline at end of file diff --git a/docs/ui-mockups/profile-mobile.png b/docs/ui-mockups/profile-mobile.png deleted file mode 100644 index 1986d1b..0000000 Binary files a/docs/ui-mockups/profile-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/radial-menu-mobile.html b/docs/ui-mockups/radial-menu-mobile.html deleted file mode 100644 index 878ce7c..0000000 --- a/docs/ui-mockups/radial-menu-mobile.html +++ /dev/null @@ -1,271 +0,0 @@ - - - - - - - - - - - - - - - -
- -
-terminal -
-
-
-
-favorite -
-
-backspace -
-
-diamond -
-
-spa -
- -
-
-
-K -diamond -
-
-
-
-
-▌RS -
-
-Q -spa -
-
-
-
-
-
-J -favorite -
-
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - - -
- -
- -
-undo -UNDO -
- -
-redo -REDO -
- -
-lightbulb -HINT -
- -
-double_arrow -AUTO -
- -
-add -NEW -
- -
-pause -PAUSE -
- -
-bar_chart -STATS -
- -
-settings -SETTINGS -
-
- -
-
-
RADIAL
-
-
- -
-
- DRAG TO SELECT · RELEASE TO ACTIVATE -
- -
- - NORMAL │ radial · 1/8 selected - -
-
-
- - - \ No newline at end of file diff --git a/docs/ui-mockups/radial-menu-mobile.png b/docs/ui-mockups/radial-menu-mobile.png deleted file mode 100644 index 3a40f5a..0000000 Binary files a/docs/ui-mockups/radial-menu-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/replay-overlay-mobile.html b/docs/ui-mockups/replay-overlay-mobile.html deleted file mode 100644 index 4864791..0000000 --- a/docs/ui-mockups/replay-overlay-mobile.html +++ /dev/null @@ -1,284 +0,0 @@ - - - - - -Solitaire Replay Overlay - - - - - - - - - -
-
-▌replay.tsx -
-
- GAME #2024-127 · 87 MOVES -
-
- -
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
- -
-
-4 -diamond -
-
-4 -diamond -
-
- -
-MOVE 47/87 -
-
-
-
-
-
- -
- -
-00:42 -/ 02:18 -
- -
- - - - - -
- -
-1.0x -unfold_more -
-
- -
-
- -
- -
-
-
-0% -
-
-
-25% -
-
-
-50% -
-
-
-75% -
-
-
-100% -
-
- -
-
47/87
- -
-
WIN MOVE
-
-
- -
-

- - MOVE LOG · 47/87 -

-
- -
-44 | -5♥ → tableau col 3 -
-
-45 | -8♣ → tableau col 1 -
-
-46 | -stock cycle -
- -
-▶ 47 | -4♦ → 5♣ on col 7 -
-
-48 | -foundation A♠ → foundation -
-
-49 | -foundation 2♠ → foundation -
-
-
- -
-
- NORMAL │ replay -
-
- [SPACE] play · [← →] scrub · [ESC] -
-
- -
- \ No newline at end of file diff --git a/docs/ui-mockups/replay-overlay-mobile.png b/docs/ui-mockups/replay-overlay-mobile.png deleted file mode 100644 index 5d3cd35..0000000 Binary files a/docs/ui-mockups/replay-overlay-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/settings-mobile.html b/docs/ui-mockups/settings-mobile.html deleted file mode 100644 index e507b37..0000000 --- a/docs/ui-mockups/settings-mobile.html +++ /dev/null @@ -1,258 +0,0 @@ - - - - - - - - - - - - - - - -
-
- -settings.toml -
-
v0.20.0
-
- - - -
- -
-
-card_theme -Terminal -
-arrow_forward -
- -
-
-background -Solid #151515 -
-arrow_forward -
- -
-
-card_back -Terminal -
-
-
-
-
-arrow_forward -
-
- -
-
-color_blind_mode -false -
- -
-
-
-
- -
-
-high_contrast -false -
- -
-
-
-
- -
-
-reduce_motion -true -
- -
-
-
-
- -
-
-crt_scanline_effect -false -
- -
-
-
-
-
- -
-
- -NORMAL - -settings -
-
-
-[1-4] -tab -
-· -
-[ESC] -back -
-
-
- \ No newline at end of file diff --git a/docs/ui-mockups/settings-mobile.png b/docs/ui-mockups/settings-mobile.png deleted file mode 100644 index a7ec5a9..0000000 Binary files a/docs/ui-mockups/settings-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/splash-mobile.html b/docs/ui-mockups/splash-mobile.html deleted file mode 100644 index 463bbc0..0000000 --- a/docs/ui-mockups/splash-mobile.html +++ /dev/null @@ -1,213 +0,0 @@ - - - - - -Rusty Solitaire - Terminal Edition - - - - - - - - -
- -
- -
- -
-
-
-
-
-

RUSTY SOLITAIRE

-
-TERMINAL EDITION -
-
- -
-
- -assets loaded -
-
- -theme: terminal -
-
- -progress restored -
-
-▌ ready_ - -
-
- -
-
-
-
-
-DONE · 247 ASSETS -
-
-
- -
-
-BASE16-EIGHTIES -
-
-
-
-
-
-
-
-
-
-
-
- v0.20.0 -
-
- - - - - \ No newline at end of file diff --git a/docs/ui-mockups/splash-mobile.png b/docs/ui-mockups/splash-mobile.png deleted file mode 100644 index ddc5b28..0000000 Binary files a/docs/ui-mockups/splash-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/stats-mobile.html b/docs/ui-mockups/stats-mobile.html deleted file mode 100644 index 9c9602b..0000000 --- a/docs/ui-mockups/stats-mobile.html +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - - - - - - - - - -
-
-terminal -

STATISTICS.LOG

-
-
- -
-
-
- -
-▌stats.log -247 GAMES TRACKED -
- - - -
- -
-
-61% -
-
-

WIN RATE

-

▲ +3% vs last 30

-
-
- -
-
-

GAMES

-

247

-
-
-

WINS

-

151

-
-
-

BEST TIME

-

01:54

-
-
-

STREAK

-

12

-
-
- -
-

WIN RATE · LAST 30 DAYS

-
- -100% -0% - -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-30d ago -today -
-
- -
-

DRAW MODE SPLIT

-
-
-
-
-
-DRAW-1 · 60% -DRAW-3 · 40% -
-
-
- -
-
▌ NORMAL │ stats
-
[1-4] view
-
-
- - - \ No newline at end of file diff --git a/docs/ui-mockups/stats-mobile.png b/docs/ui-mockups/stats-mobile.png deleted file mode 100644 index 6f9cf84..0000000 Binary files a/docs/ui-mockups/stats-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/sync-mobile.html b/docs/ui-mockups/sync-mobile.html deleted file mode 100644 index e2875bd..0000000 --- a/docs/ui-mockups/sync-mobile.html +++ /dev/null @@ -1,262 +0,0 @@ - - - - - -Rusty Solitaire - Sync Progress - - - - - - - - - - - -
-
▌RS_TERMINAL_OS
- -
-account_circle -sync -settings -
-
-
- -
-

SYNC PROGRESS

-

Connect to a server to sync games across devices.

-
- -
-

STATUS

-
-○ NOT SIGNED IN -Local progress only · Last attempt: never -
-
- -
-
- - ▌ AUTH.toml - -
- -
- -
-https://sync.rusty-solitaire.app -
-
- -
- -
-/ user@example.com -
-
- -
- -
-•••••••• (12 chars) -visibility -
-
-
- -
- - -
- -
-

RECENT

-
-
-2026-05-07 17:38 -· -○ no auth -· -skip -
-
-2026-05-07 14:12 -· -○ no auth -· -skip -
-
-2026-05-06 09:01 -· -✓ synced 12 games -
-
-
-
- -
-
-▌ NORMAL - -sync -
-
-[ENTER] sign in -[ESC] cancel -
-
- - - -
- \ No newline at end of file diff --git a/docs/ui-mockups/sync-mobile.png b/docs/ui-mockups/sync-mobile.png deleted file mode 100644 index a069d8f..0000000 Binary files a/docs/ui-mockups/sync-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/theme-picker-mobile.html b/docs/ui-mockups/theme-picker-mobile.html deleted file mode 100644 index 2bc6bdb..0000000 --- a/docs/ui-mockups/theme-picker-mobile.html +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - - - - - - - - -
-
-terminal -▌theme.picker -
-
-× CLOSE -
-
-
- -
-
-

CARD THEMES

-

Choose a card-face theme. Imported themes appear at the bottom.

-
-
-5 INSTALLED -
-
- -
- -
-
- -
-
-
▌RS
-
-
-
-
-Terminal -✓ ACTIVE -
-by Rusty Solitaire -
-
- -
-
-
-
-
-Classic -by Rusty Solitaire -
-
- -
-
-
-
-
-Stripes -by hayeah -
-
- -
-
-
-
-
-Polka -by hayeah -
-
- -
-
-
-
-
-Vintage -by hayeah -
-
- -
-
-add -IMPORT FROM .ZIP -
-
-+ IMPORT THEME -spacer -
-
-
-
- -
-
-▌ NORMAL - -theme -
-
-
-[ENTER] -activate -
-
-[I] -import -
-
-[ESC] -back -
-
-
- \ No newline at end of file diff --git a/docs/ui-mockups/theme-picker-mobile.png b/docs/ui-mockups/theme-picker-mobile.png deleted file mode 100644 index b6805c7..0000000 Binary files a/docs/ui-mockups/theme-picker-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/time-attack-mobile.html b/docs/ui-mockups/time-attack-mobile.html deleted file mode 100644 index 33a5691..0000000 --- a/docs/ui-mockups/time-attack-mobile.html +++ /dev/null @@ -1,215 +0,0 @@ - - - - - -Rusty Solitaire - Time Attack Configuration - - - - - - - - - - -
- -
-▌time-attack.tsx -MODE · TIMED -
- - - -
- -
-

TIME ATTACK

-

Race the clock. The faster you finish, the higher your score.

-
- -
-
-
05:00
-
MINUTES
-
- -
- - - - -
- -
-
RULES
-
- DRAW-3 - NO HINT PENALTY - +50 XP / WIN -
-
- -
-
-
PERSONAL BEST · 5 MIN
-
02:47 WIN
-
-
-
GLOBAL RANK 142
-
TOP 5%
-
-
- -
- -

Game starts after a 3-second countdown.

-
-
- -
-▌ NORMAL │ time-attack -[ENTER] begin · [ESC] back -
- - - -
-
- \ No newline at end of file diff --git a/docs/ui-mockups/time-attack-mobile.png b/docs/ui-mockups/time-attack-mobile.png deleted file mode 100644 index 5ea0b54..0000000 Binary files a/docs/ui-mockups/time-attack-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/weekly-goals-mobile.html b/docs/ui-mockups/weekly-goals-mobile.html deleted file mode 100644 index 1af3b61..0000000 --- a/docs/ui-mockups/weekly-goals-mobile.html +++ /dev/null @@ -1,265 +0,0 @@ - - - - - -Weekly Goals - Rusty Solitaire - - - - - - - - - -
-
-weekly-goals.json -
-
-timer -RESETS IN 2D 14H -
-
- -
-

WEEKLY GOALS

-

Complete goals before reset to claim XP and rewards.

-
- -
- -
-
-OVERALL · 3/5 -(60%) -
-
-
-
-
-
-stars - +220 XP CLAIMED -
-
-
- -
- -
-
-

PLAY 10 GAMES

-
+50 XP
-
-
-
-
-
-10/10 GAMES -✓ CLAIMED -
-
- -
-
-

WIN 5 DAILY SEEDS

-
+100 XP
-
-
-
-
-
-5/5 DONE -✓ CLAIMED -
-
- -
-
-

WIN UNDER 4:00 (3 TIMES)

-
+75 XP
-
-
-
-
-
-2/3 -▶ IN PROGRESS -
-
- -
-
-

PERFECT GAME (NO UNDO)

-
+150 XP
-
-
-
-
-
-0/1 -○ NOT STARTED -
-
- -
-
-

STREAK OF 5 WINS

-
+50 XP
-
-
-
-
-
-3/5 -▶ IN PROGRESS -
-
-
-
- - - -
-
- NORMAL │ weekly -
-
-[C] claim all -[ESC] back -
-
- -
- \ No newline at end of file diff --git a/docs/ui-mockups/weekly-goals-mobile.png b/docs/ui-mockups/weekly-goals-mobile.png deleted file mode 100644 index 644eb16..0000000 Binary files a/docs/ui-mockups/weekly-goals-mobile.png and /dev/null differ diff --git a/docs/ui-mockups/win-summary-mobile.html b/docs/ui-mockups/win-summary-mobile.html deleted file mode 100644 index c8f5700..0000000 --- a/docs/ui-mockups/win-summary-mobile.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - -ROOT@SOLITAIRE:~ | Win Summary - - - - - - - - - - -
- -
-
-▌win.tsx -
-
-
- -Synced -
-v0.20.0 -
-
- -
- -
-

- █ COMPLETE -

-

- GAME #2024-127 · DRAW-3 -

-
- -
-
-Final Score -1,024 -
-
-Time -12:34 -
-
-Moves -87 -
-
-Par Delta -−13 -
-
- -
-
- - ACHIEVEMENT UNLOCKED - -

FIRST DAILY WIN

-
-
-military_tech -
-
- -
- -
- - -
-
-
- -
-
-
-[ S ] -share screenshot -
-
-[ X ] -copy seed -
-
-
- - \ No newline at end of file diff --git a/docs/ui-mockups/win-summary-mobile.png b/docs/ui-mockups/win-summary-mobile.png deleted file mode 100644 index 8bb0f58..0000000 Binary files a/docs/ui-mockups/win-summary-mobile.png and /dev/null differ