Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77df2d2aef | |||
| 11bfb4f1c8 | |||
| 3e006a1e94 | |||
| 18ed1549e0 | |||
| 0fffce9a29 | |||
| 3cec200ac0 | |||
| ec7e2b7c08 | |||
| 9e04b389af | |||
| 09fcd2097e | |||
| f0b9536e09 | |||
| a09ec48097 | |||
| d5c95f9a0f | |||
| 494bd8b8ca | |||
| b04781178e | |||
| 4af19c4d62 | |||
| e6c67d03c2 | |||
| 484db22208 | |||
| 4315c0ae70 | |||
| f417177858 | |||
| 31d0a1b6e3 | |||
| 6fa1b28902 | |||
| 56dbc3ff2c | |||
| 19ba065154 | |||
| 3e98872f15 | |||
| 6cee4e9a2b | |||
| 98f9933ed0 | |||
| 0ef75a0c9a | |||
| a6030f4b7b | |||
| 28b1d38951 | |||
| efe930af1e | |||
| 022a749f5f | |||
| 6e3ce8ea59 | |||
| 0c673e3bb6 | |||
| f3b28a1b9d | |||
| 597aba200a | |||
| 8396f0f067 | |||
| 9f8e32db36 | |||
| 7f333443dd | |||
| 29b8c33d3f | |||
| edf2013ab1 | |||
| e3864c60a0 | |||
| 44493a2200 | |||
| 9983b873f9 | |||
| 079349dc0f | |||
| 8f82b9fcb5 | |||
| 0ebe87a411 | |||
| 1e6d153cd0 | |||
| af5ac68947 | |||
| 859b69b3c5 | |||
| 24ab25b0b7 | |||
| 918d83420b | |||
| a381a42f21 | |||
| 04f3dab563 | |||
| d204662415 | |||
| 4f0080dfbc | |||
| 46c3bf4bb2 | |||
| 6beb9f68ac | |||
| a0081a251c | |||
| 7411468e10 | |||
| 9af4046ac3 | |||
| d06af28aef | |||
| 27b58a5b71 | |||
| 3b6c8d2aab | |||
| 51fc8f65b1 | |||
| 65cb41461f | |||
| 24f5d140df | |||
| 03be4fcc67 | |||
| 9564f54fc0 | |||
| b4ada2a07e | |||
| d44cedbea0 | |||
| 75146847f6 | |||
| 566b112d9e | |||
| 198df75f94 | |||
| 40d07122ba | |||
| 08f74d1e25 | |||
| 6e6f3ef1ff | |||
| 549a817bb1 | |||
| 613bbf8799 | |||
| b129664344 | |||
| 7d7c83ab28 | |||
| bd388fef26 | |||
| 272d31f851 | |||
| 6ce55646d8 | |||
| 432061c3ec | |||
| 22303c62ff | |||
| b1731fe68a | |||
| 2b01f741b4 | |||
| 3110702c74 | |||
| 33fb9627a8 | |||
| 4398403418 | |||
| 002d96f2c8 | |||
| cc161cc37f | |||
| 8a3e30bd16 | |||
| 2a206b994c | |||
| ae7c6c97f1 | |||
| 016fb7214d | |||
| 948864e653 | |||
| 76a754d8e5 | |||
| 9fb59c7d47 | |||
| d714a11cfb | |||
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae | |||
| a5c3188686 | |||
| 6a289b7b50 | |||
| bee712c5ab | |||
| 0db5e9dac4 | |||
| 681a54d9bb | |||
| 7894559ca7 | |||
| ab803c07af | |||
| e43b329fc1 | |||
| 7c07f71f02 | |||
| c1329bbb21 | |||
| 4303ef3f5b | |||
| 4df962ee07 | |||
| f281425b45 | |||
| 2c822ba2d7 | |||
| 7ddf2733c9 | |||
| 585570559c | |||
| 45436d0eda | |||
| 2062bd06f3 | |||
| 0cb15872b1 | |||
| 395a322adc | |||
| 5199a5e499 | |||
| 16242e6d77 | |||
| 202a64db45 | |||
| c0415eb0ee | |||
| a449f60bc5 | |||
| ad5f613277 | |||
| c50eaf81f7 | |||
| b44d2777ec | |||
| 52407e7256 | |||
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 |
@@ -0,0 +1,73 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
# Only run when server code changes, not when CI itself updates deploy/.
|
||||
paths-ignore:
|
||||
- 'deploy/**'
|
||||
- 'argocd/**'
|
||||
- '**.md'
|
||||
|
||||
env:
|
||||
REGISTRY: git.aleshym.co
|
||||
IMAGE: git.aleshym.co/funman300/solitaire-server
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Need full history so we can push the tag-update commit back.
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Set image tag
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: solitaire_server/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
|
||||
${{ env.IMAGE }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Install kustomize
|
||||
run: |
|
||||
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/kustomize
|
||||
|
||||
- name: Pin image tag in deploy manifests
|
||||
run: |
|
||||
cd deploy
|
||||
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
|
||||
|
||||
- name: Commit and push updated kustomization
|
||||
run: |
|
||||
git config user.email "ci@gitea.local"
|
||||
git config user.name "Gitea CI"
|
||||
git add deploy/kustomization.yaml
|
||||
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
@@ -1,88 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Install Linux audio/display dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Clippy (all crates, zero warnings)
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
- name: Test (headless crates only — no display required)
|
||||
run: |
|
||||
cargo test -p solitaire_core
|
||||
cargo test -p solitaire_sync
|
||||
cargo test -p solitaire_data
|
||||
cargo test -p solitaire_server
|
||||
|
||||
build:
|
||||
name: Release Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux audio/display dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-release-
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --workspace --release
|
||||
@@ -7,3 +7,12 @@
|
||||
*.tmp
|
||||
data/
|
||||
.claude/
|
||||
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
# Android signing keystores — never commit
|
||||
*.jks
|
||||
*.jks.bak
|
||||
*.jks.bak*
|
||||
*.keystore
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
|
||||
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -34,5 +34,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
|
||||
"hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE user_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "jti",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
|
||||
}
|
||||
+91
-8
@@ -1,9 +1,9 @@
|
||||
# Solitaire Quest — Architecture Document
|
||||
# Ferrous Solitaire — Architecture Document
|
||||
|
||||
> **Version:** 1.1
|
||||
> **Version:** 1.3
|
||||
> **Language:** Rust (Edition 2024)
|
||||
> **Engine:** Bevy (latest stable)
|
||||
> **Last Updated:** 2026-04-29
|
||||
> **Last Updated:** 2026-05-12
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
||||
Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
||||
|
||||
### Sync Backend by Platform
|
||||
|
||||
@@ -43,6 +43,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
||||
| macOS | Self-hosted server | Full feature set |
|
||||
| Windows | Self-hosted server | Full feature set |
|
||||
| Linux | Self-hosted server | Full feature set |
|
||||
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
|
||||
|
||||
### Design Principles
|
||||
|
||||
@@ -86,6 +87,7 @@ solitaire_quest/
|
||||
├── solitaire_data/ # Persistence, sync client, settings
|
||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
||||
└── solitaire_app/ # Main binary entry point
|
||||
```
|
||||
|
||||
@@ -160,6 +162,20 @@ Owns:
|
||||
- Daily challenge seed generation
|
||||
- Leaderboard management
|
||||
|
||||
### `solitaire_wasm`
|
||||
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||
|
||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||
|
||||
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||
|
||||
Owns:
|
||||
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
|
||||
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
|
||||
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
|
||||
|
||||
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||
|
||||
### `solitaire_app`
|
||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
||||
|
||||
@@ -261,6 +277,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
|
||||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||||
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
|
||||
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
|
||||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||||
| `HelpPlugin` | H | Help / controls overlay |
|
||||
| `PausePlugin` | Esc | Pause and resume |
|
||||
@@ -305,6 +323,12 @@ struct FontResource(Handle<Font>);
|
||||
struct BackgroundImageSet {
|
||||
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
||||
}
|
||||
|
||||
// OS-reserved edge insets (physical px); zero on desktop
|
||||
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
|
||||
|
||||
// Whether the HUD band is visible (auto-hide chrome feature)
|
||||
enum HudVisibility { Visible, Hidden }
|
||||
```
|
||||
|
||||
### Key Bevy Events
|
||||
@@ -365,10 +389,22 @@ All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin`
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait SyncProvider: Send + Sync {
|
||||
// Required — must be implemented by every backend:
|
||||
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
||||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
||||
fn backend_name(&self) -> &'static str;
|
||||
fn is_authenticated(&self) -> bool;
|
||||
|
||||
// Optional — all have default no-op / empty implementations:
|
||||
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
|
||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
|
||||
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
|
||||
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
|
||||
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
|
||||
async fn delete_account(&self) -> Result<(), SyncError>;
|
||||
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
|
||||
// so LocalOnlyProvider silently no-ops the push-on-win path.
|
||||
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -454,6 +490,24 @@ CREATE TABLE leaderboard (
|
||||
recorded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id)
|
||||
);
|
||||
|
||||
-- migrations/002_replays.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replays (
|
||||
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
seed INTEGER NOT NULL,
|
||||
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||||
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||||
time_seconds INTEGER NOT NULL,
|
||||
final_score INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||||
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||||
replay_json TEXT NOT NULL -- full Replay serialisation
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
|
||||
```
|
||||
|
||||
### Request Lifecycle
|
||||
@@ -579,12 +633,25 @@ pub struct AchievementRecord {
|
||||
|
||||
pub struct Settings {
|
||||
pub draw_mode: DrawMode,
|
||||
pub sfx_volume: f32, // 0.0–1.0
|
||||
pub sfx_volume: f32, // 0.0–1.0
|
||||
pub music_volume: f32,
|
||||
pub animation_speed: AnimSpeed,
|
||||
pub theme: Theme,
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
|
||||
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
|
||||
pub first_run_complete: bool,
|
||||
pub color_blind_mode: bool, // blue tint on red suits
|
||||
pub high_contrast_mode: bool, // boosted luminance for low-vision users
|
||||
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
|
||||
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
|
||||
}
|
||||
|
||||
pub struct WindowGeometry {
|
||||
pub width: u32, // logical pixels
|
||||
pub height: u32,
|
||||
pub x: i32, // physical pixels, top-left corner
|
||||
pub y: i32,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -600,7 +667,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token}` |
|
||||
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
|
||||
|
||||
### Sync
|
||||
|
||||
@@ -617,6 +684,21 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
||||
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
||||
|
||||
### Replays
|
||||
|
||||
| Method | Path | Auth | Body | Response |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
|
||||
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
|
||||
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
|
||||
|
||||
### Web Replay Player
|
||||
|
||||
| Method | Path | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||
|
||||
### Account Management
|
||||
|
||||
| Method | Path | Auth | Body | Response |
|
||||
@@ -825,7 +907,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
|
||||
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
||||
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
||||
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
||||
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
|
||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||
|
||||
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
||||
@@ -945,6 +1027,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
||||
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
||||
| Token expiry | Access: 24h, Refresh: 30d |
|
||||
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
|
||||
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
||||
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
||||
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
||||
|
||||
+419
-3
@@ -1,13 +1,429 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Solitaire Quest are documented here. The format is
|
||||
All notable changes to Ferrous Solitaire are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||
project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
No threads in flight. v0.21.5 cut on 2026-05-08; CHANGELOG accumulates
|
||||
the next cycle here.
|
||||
### Fixed
|
||||
|
||||
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
|
||||
now checks `scrims.is_empty()` — a `Query<(), With<ModalScrim>>` guard —
|
||||
before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats,
|
||||
Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile
|
||||
could be open simultaneously.
|
||||
- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu
|
||||
button description "Menu: Stats, Settings, Profile, Achievements" wrapped to
|
||||
two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)"
|
||||
which fits on one line. Verified on device.
|
||||
- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons
|
||||
were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono
|
||||
font — rendered as missing-glyph rectangles on Android. Replaced with card
|
||||
suits (U+2660–2666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
|
||||
- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New
|
||||
`apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom
|
||||
by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over
|
||||
the safe area, not the full physical screen. The Settings / Help / Stats Done
|
||||
buttons are reachable on gesture-nav Android devices. Verified on device.
|
||||
|
||||
---
|
||||
|
||||
## [0.23.0] — 2026-05-12
|
||||
|
||||
Phase 8 sync UI: the self-hosted-server connection flow is now fully
|
||||
playable end-to-end. Players can open a Connect modal from Settings,
|
||||
enter a server URL + credentials, log in or register, and see the
|
||||
sync-status section update live. Token expiry auto-reopens the modal.
|
||||
Account deletion ships a two-click destroy flow. Server deployment
|
||||
artifacts (Dockerfile + docker-compose) let self-hosters spin up in one
|
||||
command.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sync setup modal — Connect / Disconnect flow** (`432061c`).
|
||||
New `SyncSetupPlugin` (`solitaire_engine/src/sync_setup_plugin.rs`)
|
||||
provides the full server-connection UI. Three tab-stopped text fields
|
||||
(URL, Username, Password) handle keyboard input via `MessageReader<KeyboardInput>`
|
||||
with focus cycling on Tab. "Log In" and "Register" buttons each spawn an
|
||||
async `AsyncComputeTaskPool` task that calls the new
|
||||
`SolitaireServerClient::login()` / `::register()` methods; `poll_auth_task`
|
||||
harvests the result, stores tokens via `store_tokens()`, hot-swaps
|
||||
`SyncProviderResource` to the new server backend, fires
|
||||
`ManualSyncRequestEvent` to pull immediately, and closes the modal.
|
||||
An inline `SyncAuthError` label displays credential errors without a
|
||||
toast. The modal is idempotent (`existing.is_empty()` guard) — safe
|
||||
to open programmatically.
|
||||
- **`SyncConfigureRequestEvent`, `SyncLogoutRequestEvent`,
|
||||
`DeleteAccountRequestEvent`** (`432061c`). Three new engine events
|
||||
wire the Settings buttons → plugin handlers. `SyncConfigureRequestEvent`
|
||||
opens the setup modal; `SyncLogoutRequestEvent` disconnects and resets
|
||||
`SyncProviderResource` to `LocalOnlyProvider`; `DeleteAccountRequestEvent`
|
||||
opens the deletion confirmation modal.
|
||||
- **Settings sync section — dynamic backend UI** (`432061c`).
|
||||
`sync_row()` in `SettingsPlugin` now takes `backend: &SyncBackend` and
|
||||
renders conditionally: `Local` → "Connect" button; `SolitaireServer` →
|
||||
username label + "Sync Now" + "Disconnect" + "Delete Account". Three new
|
||||
`SettingsButton` discriminants (`ConnectSync` tab 91, `DisconnectSync`
|
||||
tab 92, `DeleteAccount` tab 93) feed into a new `handle_sync_buttons`
|
||||
system extracted from `handle_settings_buttons` to stay within Bevy's
|
||||
16-parameter system limit.
|
||||
- **`SolitaireServerClient::login()` and `::register()`** (`432061c`).
|
||||
Both POST to `/api/auth/login` and `/api/auth/register` respectively.
|
||||
Private helper `extract_auth_tokens` parses `{ access_token, refresh_token }`.
|
||||
409 CONFLICT → "username already taken"; 401/403 → "invalid credentials";
|
||||
400 → server message echoed to the player.
|
||||
- **Re-auth prompt on token expiry** (`6ce5564`).
|
||||
`poll_pull_result` in `SyncPlugin` now fires `InfoToastEvent("Session
|
||||
expired — please reconnect")` + `SyncConfigureRequestEvent` when the
|
||||
pull task resolves to `SyncError::Auth(_)`. Because the modal is
|
||||
idempotent the re-open is safe to trigger from any system path.
|
||||
- **Server deployment artifacts** (`6ce5564`).
|
||||
`solitaire_server/Dockerfile`: multi-stage build (`rust:1.95-slim` →
|
||||
`debian:bookworm-slim`); copies `.sqlx` offline cache so `SQLX_OFFLINE=true`
|
||||
succeeds without a live database at build time; exposes port 8080.
|
||||
`solitaire_server/docker-compose.yml`: single-service compose file;
|
||||
`db-data` volume at `/app/data`; `DATABASE_URL` and `JWT_SECRET` from
|
||||
environment; HTTP health-check via `wget`. `solitaire_server/.env.example`:
|
||||
documents all required variables with generation hint (`openssl rand -hex 32`).
|
||||
- **Account deletion flow** (`272d31f`).
|
||||
"Delete Account" in Settings fires `DeleteAccountRequestEvent` →
|
||||
`SyncSetupPlugin::open_delete_confirm_modal` spawns a danger-red
|
||||
confirmation modal with "Cancel" and "Delete Forever" buttons.
|
||||
"Delete Forever" submits an async `PendingDeleteTask` that calls
|
||||
`SyncProvider::delete_account()`; `poll_delete_task` on Ok fires
|
||||
`SyncLogoutRequestEvent` + a success toast; on Err shows an error toast
|
||||
and leaves the modal open. Two-click destroy pattern — no accidental
|
||||
account deletion possible.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`SyncAuthResultEvent`** (`432061c`). Defined but never emitted or
|
||||
consumed; removed as dead code.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: **1300+ passing** / 0 failing
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_data` (sync_client), `solitaire_engine`
|
||||
(events, settings_plugin, sync_plugin, sync_setup_plugin [new], lib),
|
||||
`solitaire_app` (lib.rs), `solitaire_server` (Dockerfile,
|
||||
docker-compose.yml, .env.example [new])
|
||||
|
||||
## [0.22.0] — 2026-05-08
|
||||
|
||||
Adds difficulty-tier game selection, Android JNI bridges for keystore and
|
||||
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
|
||||
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
|
||||
|
||||
### Added
|
||||
|
||||
- **Difficulty-tier game mode** (this release).
|
||||
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
|
||||
Random`) added to `solitaire_core::game_state` alongside a new
|
||||
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
|
||||
catalogs (40 seeds each, 200 total) are generated by the new
|
||||
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
|
||||
contains seeds proven winnable at progressively larger solver budgets
|
||||
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
|
||||
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
|
||||
system-time seed and intentionally bypasses the winnable-only filter.
|
||||
The home overlay gains an expandable `▶ Difficulty` section between the
|
||||
Draw Mode row and the mode-card grid; the last-played tier is persisted
|
||||
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
|
||||
Difficulty wins pool into Classic stats (no separate buckets).
|
||||
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
|
||||
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
|
||||
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
|
||||
as a flex row of two bordered chips flanking a `"Replay N / M"`
|
||||
caption, with a detail line below showing the selected replay's
|
||||
duration + date and an optional `"· Shareable"` badge. Both chips
|
||||
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
|
||||
paint loop gives them hover/press feedback at zero extra cost.
|
||||
`repaint_replay_selector_detail` is wired into the existing
|
||||
`.chain()` alongside `handle_replay_selector_buttons` and
|
||||
`repaint_replay_selector_caption`. The click handler and repaint
|
||||
systems have been registered (and dormant) since v0.19.0; this
|
||||
commit is purely the missing spawn site.
|
||||
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
|
||||
presence (Prev, Next, Caption, Detail all spawn with the screen),
|
||||
caption initial text ("Replay 1 / 1"), detail initial text
|
||||
("{dur} win on {date}"), Shareable badge when `share_url` is set,
|
||||
empty-history "No replays" caption, and ordinal wrapping.
|
||||
`make_test_replay(time_seconds, share_url)` helper encapsulates
|
||||
`Replay::new(...)` + `chrono::NaiveDate`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
|
||||
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
|
||||
`replay_overlay` tests to `const { assert!(…) }` to satisfy
|
||||
`clippy::assertions_on_constants` (constant-fold at compile time
|
||||
rather than a runtime no-op).
|
||||
|
||||
### Added (post-cut, same pending release)
|
||||
|
||||
- **Double-tap auto-move on touch screens** (`395a322`).
|
||||
`handle_double_tap` fires `MoveRequestEvent` (single card to
|
||||
foundation/tableau, or a whole face-up stack via
|
||||
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
|
||||
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
|
||||
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
|
||||
touch latency). If no legal destination exists, fires
|
||||
`MoveRejectedEvent` (audio + visual rejection feedback). The system
|
||||
is inserted into the touch drag chain immediately before
|
||||
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
|
||||
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
|
||||
f32>>` keyed by card ID.
|
||||
- **Play-by-Seed dialog** (`0cb1587`).
|
||||
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
|
||||
seed, runs a solver preview in the background (debounced 500 ms via
|
||||
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
|
||||
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
|
||||
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
|
||||
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
|
||||
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
|
||||
cover spawn, digit append, buffer read, confirm, and cancel paths.
|
||||
- **75 new challenge seeds** (`2062bd0`).
|
||||
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
|
||||
in the `0xCAFEBABE…` namespace and filters for hands solvable in
|
||||
≤250 moves via the core solver. The 75 confirmed-win seeds are
|
||||
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
|
||||
|
||||
### Fixed (post-cut, same pending release)
|
||||
|
||||
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
|
||||
F11 fullscreen toggle makes no sense on Android (the OS owns window
|
||||
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
|
||||
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
|
||||
call is extracted as a separate statement so `#[cfg]` can annotate it
|
||||
(attributes cannot appear mid-chain in Rust).
|
||||
- **Android APK launch: export `android_main`** (`202a64d`).
|
||||
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
|
||||
`android_main` as its entry point. Without the symbol the app
|
||||
crashed immediately with `UnsatisfiedLinkError`. The new function
|
||||
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
|
||||
delegates to `run()` — equivalent to what `#[bevy_main]` would
|
||||
generate, but usable on an arbitrary entry point name.
|
||||
- **Android APK launch: gate `resize_constraints` to non-Android**
|
||||
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
|
||||
Bevy's clamp panicked with `min=800 > max=0`.
|
||||
- **Android APK launch: gate `apply_smart_default_window_size` to
|
||||
non-Android** (`202a64d`). The system calls `.clamp(800.0,
|
||||
logical_w)` which panics when the emulator reports zero window
|
||||
dimensions during early Android lifecycle events. The OS controls
|
||||
window size on Android; the system is irrelevant there.
|
||||
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
|
||||
created `.idea/` when the project was opened during APK
|
||||
verification; added to `.gitignore` and removed the accidentally-
|
||||
committed files.
|
||||
|
||||
### Android verification result
|
||||
|
||||
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
|
||||
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
|
||||
Bevy renderer initialises, splash screen loads. This is the first
|
||||
confirmed end-to-end device run.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: **1300+ passing** / 0 failing
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
|
||||
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
|
||||
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
|
||||
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
|
||||
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
|
||||
|
||||
## [0.21.8] — 2026-05-08
|
||||
|
||||
Patch release for replay-overlay polish. Through-line:
|
||||
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
|
||||
All three items were "optional polish" flagged in the v0.21.7 handoff;
|
||||
all three ship in two commits.
|
||||
|
||||
### Added
|
||||
|
||||
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
|
||||
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
|
||||
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
|
||||
luminance under HC mode. Sits above the bumped notch ticks
|
||||
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
|
||||
this colour is unambiguous.
|
||||
- **`HighContrastBackground::with_hc(default, hc)` constructor**
|
||||
(`c50eaf8`). Extends `HighContrastBackground` with an
|
||||
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
|
||||
`with_default()`). `update_high_contrast_backgrounds` now
|
||||
reads `marker.hc_color` instead of the hardcoded constant —
|
||||
backwards-compatible; all existing `with_default()` usages
|
||||
continue to bump to gray.
|
||||
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
|
||||
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
|
||||
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
|
||||
lime rather than gray). Pin test locks both the default and
|
||||
HC colour fields on the spawned entity.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scrub-bar notch-label centering** (`b44d277`). Middle
|
||||
three labels ("25%", "50%", "75%") previously had their
|
||||
left edge at the notch; now their text centre coincides
|
||||
with the notch tick. Implemented using the CSS
|
||||
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
|
||||
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
|
||||
`margin.left = -18 px` is placed at `left: Percent(pct)`,
|
||||
and `Justify::Center` centres the text within it. Endpoint
|
||||
labels ("0%", "100%") keep their flush-left / flush-right
|
||||
anchoring. `with_default()` remains one-argument.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: 1276 passing / 0 failing (engine: 831)
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_engine` (replay_overlay.rs,
|
||||
ui_theme.rs, settings_plugin.rs)
|
||||
|
||||
## [0.21.7] — 2026-05-08
|
||||
|
||||
Patch release closing the last major B-2 sub-piece. Through-line:
|
||||
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
|
||||
50 % opacity" is now implemented as a full-screen UI scrim that darkens
|
||||
the card world during replay so the chrome (banner + move-log panel)
|
||||
reads clearly against the scene.
|
||||
|
||||
### Added
|
||||
|
||||
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
|
||||
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
|
||||
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY − 1 = 54` whenever
|
||||
a replay starts; despawned alongside the banner and move-log
|
||||
panel when the replay ends. Bevy's UI/world compositor means
|
||||
no changes to `card_plugin` are needed — UI nodes always
|
||||
render above world-space sprites regardless of `Transform.z`.
|
||||
The dim layer carries no `Interaction` component (purely
|
||||
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
|
||||
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
|
||||
lifecycle (spawn/despawn mirrors the floating-chip pattern)
|
||||
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
|
||||
pinned). 1275 tests pass / 0 failing.
|
||||
|
||||
### Stats
|
||||
|
||||
- Tests: 1275 passing / 0 failing
|
||||
- Clippy: clean
|
||||
- Crates touched: `solitaire_engine` (replay_overlay.rs)
|
||||
|
||||
## [0.21.6] — 2026-05-08
|
||||
|
||||
Patch release for the post-v0.21.5 work. Through-line:
|
||||
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
|
||||
keyboard-accelerator surface (Space / Esc / ← / →) and the
|
||||
keybind footer; v0.21.6 builds on that with two parallel
|
||||
threads — accessibility + scrub-on-hold polish for the v0.21.5
|
||||
surfaces, plus a brand-new Move Log panel anchored to the
|
||||
viewport's bottom edge that gives players a 5-row recent-and-
|
||||
upcoming move history alongside the existing top-edge banner.
|
||||
|
||||
The Move Log panel is the first replay-overlay surface that
|
||||
*isn't* attached to the banner — it lives at a separate screen
|
||||
anchor (bottom: 0) with its own spawn/despawn lifecycle.
|
||||
Establishes the pattern for "multi-anchor replay UI" that the
|
||||
remaining B-2 sub-piece (mini-tableau preview) will inherit.
|
||||
|
||||
### Added
|
||||
|
||||
- **HC-mode coverage for the scrub track + quarter-mark notch
|
||||
ticks** (`d3cb1a5`). Adds parallel primitive
|
||||
`HighContrastBackground` to `ui_theme` and a paint system
|
||||
`update_high_contrast_backgrounds` in `settings_plugin` that
|
||||
mirrors the existing border-marker pattern but targets
|
||||
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
|
||||
scrub track Node and all five quarter-mark notch ticks so
|
||||
they bump from `BORDER_SUBTLE` (`#505050`) →
|
||||
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
|
||||
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
|
||||
don't get the marker — accent and state colours are already
|
||||
saturated and don't need an HC luminance variant.
|
||||
- **Continuous scrub on key-held arrow keys** (`2e25476`).
|
||||
Holding ← or → triggers continuous step at 100 ms cadence
|
||||
(10 steps/sec) — matches the mockup's `[← →] scrub`
|
||||
terminology while keeping single-press = single-step
|
||||
semantics. Per-key accumulators in a new
|
||||
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
|
||||
the accumulator and fire immediately. Release resets to 0
|
||||
so the next fresh press fires immediately rather than at
|
||||
half-interval.
|
||||
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
|
||||
`4437a1a`). New bottom-edge UI panel showing a 5-row window
|
||||
onto recent + upcoming moves: 2 prev rows above the active
|
||||
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
|
||||
rows below. Header reads `▌ MOVE LOG · N/M` (or
|
||||
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
|
||||
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
|
||||
legible contrast against the brick-red highlight. Prev /
|
||||
next rows render in `TEXT_SECONDARY` so the active row
|
||||
stays the focal point.
|
||||
- Sibling-of-banner pattern (separate root entity anchored
|
||||
at viewport bottom, not a banner child) — same
|
||||
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
|
||||
different screen anchor.
|
||||
- Five pure helpers handle the formatting:
|
||||
`format_pile`, `format_move_body`,
|
||||
`format_move_log_header`, `format_kth_recent_row` (active
|
||||
+ prev), `format_kth_next_row` (next). 1-indexed display
|
||||
numbers throughout (`Foundation(2)` reads as "foundation
|
||||
3" rather than the enum's 0-index).
|
||||
- Panel grows from 56 → 84 → 112 px across the four
|
||||
move-log commits. `MOVE_LOG_PREV_ROWS` and
|
||||
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
|
||||
the row count; `format_kth_recent_row` and
|
||||
`format_kth_next_row` return empty for out-of-range k so
|
||||
panels gracefully under-fill at the start (cursor=1) and
|
||||
end (cursor=N-1) of a replay.
|
||||
- HC marker on the panel's top border so the 1 px edge
|
||||
bumps under HC mode (same pattern as the keybind footer).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`react_to_state_change` despawns the Move Log panel** on
|
||||
`Playing → Inactive` alongside the banner root and floating
|
||||
progress chip. Third query in the same defer-and-despawn
|
||||
cycle.
|
||||
- **Move Log panel height grew 56 → 84 → 112 px** across the
|
||||
prev-rows and next-rows commits. The panel is sized to fit
|
||||
the chosen row count + header + padding; tunable via the
|
||||
`MOVE_LOG_PANEL_HEIGHT` const.
|
||||
- **`format_active_move_row` now prefixes the `▶` focus
|
||||
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
|
||||
and prepends the prefix when the body is non-empty. Empty
|
||||
case still returns empty — cursor=0 doesn't paint a stray
|
||||
`▶` on an otherwise-empty row.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
|
||||
recording the HC paint + continuous-scrub polish, then
|
||||
again as the Move Log arc shipped commit-by-commit. The
|
||||
Resume menu's B option now traces the full arc:
|
||||
notches → labels → footer → ESC → HC → arrow keys →
|
||||
HC paint → continuous scrub → move log.
|
||||
|
||||
### Stats
|
||||
|
||||
- **1273 passing tests / 0 failing** across the workspace
|
||||
(net +23 from v0.21.5's 1250 baseline):
|
||||
- 2 from `d3cb1a5` (HC marker on track + notches).
|
||||
- 2 from `2e25476` (continuous-scrub repeat-while-held +
|
||||
release-resets-accumulator).
|
||||
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
|
||||
spawn / lifecycle scenarios).
|
||||
- 4 from `140251b` (prev rows: helper k coverage + spawn
|
||||
cardinality + spawn texts + repaint on cursor advance).
|
||||
- 3 from `e7345ae` (active row highlight: wrapper bg +
|
||||
text colour + focus prefix + cursor=0 stays empty).
|
||||
- 4 from `4437a1a` (next rows: helper k coverage + spawn
|
||||
cardinality + spawn texts + under-fill at replay end).
|
||||
- Clippy clean across the workspace.
|
||||
|
||||
## [0.21.5] — 2026-05-08
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
version: unified-3.0
|
||||
version: unified-4.0
|
||||
|
||||
---
|
||||
|
||||
@@ -29,8 +29,9 @@ solitaire_sync/ # Shared API + merge logic
|
||||
solitaire_data/ # Persistence + sync client
|
||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||
solitaire_server/ # Axum backend (optional sync layer)
|
||||
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||
solitaire_app/ # Entry binary
|
||||
assets/ # Runtime assets (except audio)
|
||||
assets/ # Runtime assets (except audio + default theme)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -72,12 +73,16 @@ These override all other instructions.
|
||||
|
||||
* NO `unwrap()`
|
||||
* NO `panic!()` in runtime/game logic
|
||||
* All state transitions:
|
||||
* Core game state mutations MUST return:
|
||||
|
||||
```rust id="err_model"
|
||||
Result<T, MoveError>
|
||||
```
|
||||
|
||||
* Engine / UI state changes follow ECS patterns (Resources, Events) —
|
||||
they do not return `MoveError`
|
||||
* Use `thiserror`-derived types for any new error enums outside `solitaire_core`
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Threading Rules
|
||||
@@ -126,10 +131,15 @@ trait SyncProvider
|
||||
## 3.1 ECS Design
|
||||
|
||||
* systems = single responsibility
|
||||
* communication = Events only
|
||||
* shared state = Resources only
|
||||
* cross-system communication = Events (fire-and-forget triggers)
|
||||
* persistent shared state = Resources (polled every frame or on change)
|
||||
* per-entity state = Components only
|
||||
|
||||
Events and Resources are both valid communication paths — use Events when
|
||||
the receiver needs to react once; use Resources when the receiver polls
|
||||
or when multiple systems read the same value (e.g. `SafeAreaInsets`,
|
||||
`HudVisibility`, `LayoutResource`).
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Game State Authority
|
||||
@@ -149,11 +159,22 @@ Every player action MUST:
|
||||
Keyboard shortcuts are:
|
||||
→ optional accelerators only
|
||||
|
||||
**Exception — UI chrome gestures:**
|
||||
Tap-to-toggle visibility of UI chrome (e.g. auto-hiding HUD band) is
|
||||
permitted without a visible button. The gesture MUST:
|
||||
* affect only chrome visibility, never game state
|
||||
* restore chrome automatically when any modal opens
|
||||
* be purely additive (game remains fully playable with chrome always visible)
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Layout System
|
||||
|
||||
* recompute on `WindowResized`
|
||||
* recompute on `SafeAreaInsets` changed
|
||||
* recompute on `HudVisibility` changed
|
||||
* `compute_layout` MUST accept `hud_visible: bool`; pass `HUD_BAND_HEIGHT`
|
||||
when `true`, `0.0` when `false`
|
||||
* no fixed resolution assumptions
|
||||
|
||||
---
|
||||
@@ -178,11 +199,18 @@ Includes:
|
||||
|
||||
## 4.2 Embedded Assets
|
||||
|
||||
Only audio:
|
||||
Embed via `include_bytes!()` only when ALL of the following are true:
|
||||
|
||||
```text id="audio_rule"
|
||||
include_bytes!()
|
||||
```
|
||||
* the asset is small (< 500 KB uncompressed)
|
||||
* it changes rarely (not user-customisable)
|
||||
* a missing file would be a hard crash, not a graceful degradation
|
||||
|
||||
Currently embedded:
|
||||
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
||||
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
||||
|
||||
Do NOT embed card face PNGs, background images, or user fonts —
|
||||
these are loaded via `AssetServer` so art can be swapped without recompile.
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +238,9 @@ Must degrade gracefully under `MinimalPlugins`.
|
||||
## 5.2 Public API Rules
|
||||
|
||||
* prefer `Into<T>` over concrete types
|
||||
* all public items require doc comments
|
||||
* publicly exported functions, traits, and non-trivial types require doc comments
|
||||
* simple marker components, newtype wrappers, and internal `pub` items
|
||||
used only within the same crate are exempt from doc comment requirements
|
||||
|
||||
---
|
||||
|
||||
@@ -276,11 +306,13 @@ NEVER commit otherwise
|
||||
|
||||
Claude must request confirmation before:
|
||||
|
||||
* adding dependencies
|
||||
* modifying `solitaire_sync`
|
||||
* changing DB schema
|
||||
* adding dependencies to `solitaire_core` or `solitaire_sync`
|
||||
(engine/server crates may add deps without confirmation)
|
||||
* modifying `solitaire_sync` types or the `SyncProvider` trait
|
||||
* changing DB schema (migrations are append-only)
|
||||
* introducing `unsafe`
|
||||
* changing merge strategy
|
||||
* changing the merge strategy in `solitaire_sync::merge`
|
||||
* changing the `SyncPayload` wire format (breaking change for existing servers)
|
||||
|
||||
---
|
||||
|
||||
@@ -304,10 +336,29 @@ Core is always the source of truth.
|
||||
|
||||
Must always be handled explicitly:
|
||||
|
||||
**All platforms**
|
||||
* Bevy `Time` uses `f32`
|
||||
* `sqlx::migrate!()` path is crate-relative
|
||||
* `dirs::data_dir()` may return `None`
|
||||
* Linux may lack keyring backend
|
||||
* Linux may lack keyring backend — handle `keyring::Error` gracefully
|
||||
|
||||
**Android (active target — not stretch)**
|
||||
* Safe-area insets arrive in frames 1–3 via JNI polling, not at startup;
|
||||
UI that depends on them must handle the zero-inset initial state
|
||||
* Physical pixels ≠ logical pixels: `SafeAreaInsets` values are physical
|
||||
(from `WindowInsets` API); divide by `window.scale_factor()` before
|
||||
passing to Bevy `Val::Px`
|
||||
* `adb shell input tap` uses physical pixel coordinates
|
||||
* FiraMono (bundled font) covers: ASCII, card suits U+2660–2666,
|
||||
Arrows U+2190–21FF. It does NOT cover Geometric Shapes (U+25xx) —
|
||||
those render as missing-glyph rectangles on Android
|
||||
* The gesture/navigation bar at the bottom (≈132px physical on common
|
||||
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
||||
avoid placing interactive elements in that zone
|
||||
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
|
||||
layout constants are `#[cfg(target_os = "android")]` gated
|
||||
* JNI calls must use `attach_current_thread_permanently` — not
|
||||
`attach_current_thread` — to avoid detach-on-drop panics
|
||||
|
||||
---
|
||||
|
||||
@@ -318,6 +369,12 @@ Must always be handled explicitly:
|
||||
* blocking async calls in ECS
|
||||
* insecure credential storage
|
||||
* bypassing core logic layer
|
||||
* hardcoded pixel coordinates in layout — always derive from `compute_layout`
|
||||
* Unicode Geometric Shapes block (U+25xx) in UI text — not in FiraMono
|
||||
* spawning a second `ModalScrim` while one already exists without first
|
||||
dismissing the existing one (use `scrims.is_empty()` guard)
|
||||
* reading `SafeAreaInsets` physical values directly into `Val::Px` without
|
||||
dividing by `window.scale_factor()`
|
||||
|
||||
---
|
||||
|
||||
@@ -345,9 +402,74 @@ If unclear:
|
||||
| Both combined | full system understanding |
|
||||
|
||||
---
|
||||
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||
# 14. Modal System Conventions
|
||||
|
||||
## 14.1 Purpose
|
||||
All full-screen overlay panels MUST use the `spawn_modal` / `ModalScrim` pattern
|
||||
from `solitaire_engine::ui_modal`.
|
||||
|
||||
## 14.1 Spawn pattern
|
||||
|
||||
```rust
|
||||
let scrim = spawn_modal(commands, MyScreenMarker, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Title", font_res);
|
||||
// ... body nodes ...
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(actions, MyCloseButton, "Done", None,
|
||||
ButtonVariant::Primary, font_res);
|
||||
});
|
||||
});
|
||||
// Optional: allow clicking the scrim outside the card to dismiss
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
```
|
||||
|
||||
## 14.2 Guard rule
|
||||
|
||||
Before spawning a new modal, check `scrims: Query<(), With<ModalScrim>>`
|
||||
and return early if `!scrims.is_empty()` — unless the new modal is
|
||||
explicitly replacing the current one (despawn first, then spawn).
|
||||
|
||||
## 14.3 Safe area
|
||||
|
||||
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
||||
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
||||
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
||||
|
||||
## 14.4 Z-ordering
|
||||
|
||||
Use `Z_MODAL_PANEL` from `ui_theme` for all modal scrims. Do not use
|
||||
raw `z_index` values — they drift and cause ordering bugs.
|
||||
|
||||
---
|
||||
|
||||
# 15. Android Build & Verification
|
||||
|
||||
## 15.1 Build command
|
||||
|
||||
```bash
|
||||
cargo apk build --package solitaire_app --lib
|
||||
adb install -r target/debug/apk/solitaire-quest.apk
|
||||
```
|
||||
|
||||
## 15.2 Coordinate system reminder
|
||||
|
||||
Device physical: 1080×2400. Bevy logical: 900×2000. Scale factor: 1.20.
|
||||
`adb shell input tap X Y` takes PHYSICAL coordinates.
|
||||
To convert from what you see on screen (logical): multiply by 1.20.
|
||||
|
||||
## 15.3 Android-specific test checklist
|
||||
|
||||
Before shipping any Android build:
|
||||
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
|
||||
- [ ] All modal Done buttons are above the gesture bar
|
||||
- [ ] No Geometric Shapes glyphs in UI text
|
||||
- [ ] HUD band does not overlap the top status bar
|
||||
- [ ] Touch drag-and-drop works on all pile types
|
||||
|
||||
---
|
||||
|
||||
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||
|
||||
## 16.1 Purpose
|
||||
|
||||
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||
|
||||
@@ -360,7 +482,7 @@ This prevents:
|
||||
|
||||
---
|
||||
|
||||
## 14.2 Input Classification Step (MANDATORY)
|
||||
## 16.2 Input Classification Step (MANDATORY)
|
||||
|
||||
Every request MUST be classified into exactly one task type:
|
||||
|
||||
@@ -381,13 +503,13 @@ If uncertain → ask clarification.
|
||||
|
||||
---
|
||||
|
||||
## 14.3 Context Selection Engine
|
||||
## 16.3 Context Selection Engine
|
||||
|
||||
After classification, Claude MUST include ONLY the relevant sections below.
|
||||
|
||||
---
|
||||
|
||||
## 14.4 Context Map (CORE RULESET)
|
||||
## 16.4 Context Map (CORE RULESET)
|
||||
|
||||
### feature
|
||||
|
||||
@@ -495,7 +617,7 @@ Include:
|
||||
|
||||
---
|
||||
|
||||
## 14.5 Context Compression Rules
|
||||
## 16.5 Context Compression Rules
|
||||
|
||||
Claude MUST obey:
|
||||
|
||||
@@ -506,7 +628,7 @@ Claude MUST obey:
|
||||
|
||||
---
|
||||
|
||||
## 14.6 Context Priority Order
|
||||
## 16.6 Context Priority Order
|
||||
|
||||
When space is limited:
|
||||
|
||||
@@ -517,7 +639,7 @@ When space is limited:
|
||||
|
||||
---
|
||||
|
||||
## 14.7 “No Context Pollution” Rule
|
||||
## 16.7 “No Context Pollution” Rule
|
||||
|
||||
Claude must NOT include:
|
||||
|
||||
@@ -529,7 +651,7 @@ Claude must NOT include:
|
||||
|
||||
---
|
||||
|
||||
## 14.8 Self-Check Before Execution
|
||||
## 16.8 Self-Check Before Execution
|
||||
|
||||
Before writing code, Claude MUST verify:
|
||||
|
||||
@@ -542,7 +664,7 @@ If any fail → revise context selection.
|
||||
|
||||
---
|
||||
|
||||
## 14.9 Injection Output Format (Internal Model)
|
||||
## 16.9 Injection Output Format (Internal Model)
|
||||
|
||||
Claude should behave as if it constructed:
|
||||
|
||||
@@ -560,7 +682,7 @@ Claude should behave as if it constructed:
|
||||
|
||||
---
|
||||
|
||||
## 14.10 Relationship to ARCHITECTURE.md
|
||||
## 16.10 Relationship to ARCHITECTURE.md
|
||||
|
||||
* ARCHITECTURE.md = source of truth
|
||||
* CLAUDE.md = execution constraints
|
||||
|
||||
@@ -12,7 +12,7 @@ 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
|
||||
- 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
|
||||
@@ -360,7 +360,7 @@ notes:
|
||||
target:
|
||||
"<what is slow>"
|
||||
|
||||
constraints:CLAUDE_WORKFLOW.md
|
||||
constraints:
|
||||
- no behavior change
|
||||
- no architecture change
|
||||
- minimal code changes
|
||||
|
||||
+5
-1
@@ -41,6 +41,10 @@ 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"
|
||||
@@ -180,7 +184,7 @@ threading:
|
||||
|
||||
plugins:
|
||||
pattern: "feature_isolation"
|
||||
communication: "events"
|
||||
communication: "events and resources"
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Credits
|
||||
|
||||
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||
Ferrous Solitaire is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||
the work of many open-source projects and a small handful of third-party
|
||||
assets. This file lists every component that ships in the binary or in the
|
||||
`assets/` directory.
|
||||
@@ -43,7 +43,7 @@ copyleft code is statically linked into the game binary.
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
|
||||
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
|
||||
| `solitaire_engine/assets/themes/default/back.svg` | Original — Ferrous Solitaire | MIT (this project) |
|
||||
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
|
||||
| `assets/cards/backs/back_0.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
@@ -107,6 +107,6 @@ Audio files are MIT-licensed alongside the rest of this project.
|
||||
backs, every audio file) are original work covered by this project's MIT
|
||||
license.
|
||||
|
||||
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
||||
If you redistribute Ferrous Solitaire, you must ship this `CREDITS.md` and the
|
||||
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
||||
and OFL (FiraMono) notices remain visible to end users.
|
||||
|
||||
Generated
+6
@@ -6967,6 +6967,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"png 0.17.16",
|
||||
"solitaire_core",
|
||||
"solitaire_data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6984,8 +6986,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"jsonwebtoken",
|
||||
"keyring-core",
|
||||
"reqwest",
|
||||
@@ -7009,10 +7013,12 @@ dependencies = [
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"kira",
|
||||
"resvg",
|
||||
"ron",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solitaire_core",
|
||||
"solitaire_data",
|
||||
"solitaire_sync",
|
||||
|
||||
@@ -31,6 +31,7 @@ keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
arboard = { version = "3", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Solitaire Quest
|
||||
# Ferrous Solitaire
|
||||
|
||||
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||
system, full progression (XP / levels / achievements / daily challenges), and
|
||||
|
||||
+27
-1
@@ -1,4 +1,4 @@
|
||||
# Solitaire Quest — Self-Hosting Guide
|
||||
# Ferrous Solitaire — Self-Hosting Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -42,3 +42,29 @@ git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
## Admin — Password Reset
|
||||
|
||||
If a player loses access to their account, the server binary includes a
|
||||
built-in password reset command. Run it on the host (or inside the container)
|
||||
with `DATABASE_URL` pointing at your database:
|
||||
|
||||
```bash
|
||||
# Interactive (prompts for the new password):
|
||||
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||
./solitaire_server --reset-password <username>
|
||||
|
||||
# Non-interactive (piped from a script or password manager):
|
||||
echo "new_password" | \
|
||||
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||
./solitaire_server --reset-password <username>
|
||||
|
||||
# Inside a running Docker container:
|
||||
docker compose exec server sh -c \
|
||||
'echo "new_password" | ./solitaire_server --reset-password alice'
|
||||
```
|
||||
|
||||
On success the user's `password_hash` is updated and **all active refresh
|
||||
tokens are deleted**, so every open session must log in again with the new
|
||||
password. `JWT_SECRET` does not need to be set for this command.
|
||||
|
||||
+140
-470
@@ -1,507 +1,177 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
# Ferrous Solitaire — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-08 — **v0.21.4 cut and tagged at
|
||||
`23ff62c`**, working tree clean, all post-tag work pushed to
|
||||
origin.
|
||||
**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
|
||||
|
||||
v0.21.4 is a patch release with one through-line:
|
||||
**replay-scrubbing accessibility**. The replay overlay used to be
|
||||
pure-passive — start, watch, wait. v0.21.4 adds the scaffolding
|
||||
for *navigating within* a replay: a WIN MOVE marker on the scrub
|
||||
bar so the player can see at a glance where the winning move
|
||||
sits, plus pause / resume / step controls (with a Space keyboard
|
||||
accelerator) so they can stop on any move and inspect the board.
|
||||
Also lands the additive `Replay::win_move_index: Option<usize>`
|
||||
data field that makes the marker possible — serde-default so
|
||||
older on-disk replays load with `None` and simply don't get a
|
||||
marker (no schema bump).
|
||||
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.
|
||||
|
||||
Three commits on the B-2 replay screen-takeover redesign arc
|
||||
land here. The remaining sub-pieces (screen-takeover layout,
|
||||
move-log scroller, mini-tableau preview) share a layout-reflow
|
||||
prerequisite the banner can't carry, so they're deferred to a
|
||||
future cycle as a single multi-session arc.
|
||||
---
|
||||
|
||||
Full v0.21.4 detail lives in `CHANGELOG.md` § [0.21.4]. This
|
||||
file from here on focuses on what's *open* post-cut and how to
|
||||
resume.
|
||||
## Current state
|
||||
|
||||
## Status at pause
|
||||
- **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`.
|
||||
|
||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
||||
`23ff62c`; any post-cut docs edits ride on top of that.
|
||||
- **HEAD on origin:** matches local. v0.21.4 is fully on origin.
|
||||
- **Working tree:** clean. No WIP outstanding.
|
||||
- **`artwork/` directory:** still untracked. Intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1250 total / 1249 passing / 1 pre-existing
|
||||
time-dependent flake** across the workspace
|
||||
(1228 in v0.21.4 + 4 from `fe68861`'s scrub-notch tests + 4
|
||||
from `d322abf`'s notch-label tests + 4 from `1873b3f`'s
|
||||
keybind-footer tests + 3 from `90e24d9`'s ESC-accelerator
|
||||
tests + 1 from `23902cd`'s HC-marker test + 6 from
|
||||
`e5c4f51`'s arrow-keyboard tests). The flake is
|
||||
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
|
||||
— fails when wall-clock UTC is within 30 minutes of midnight
|
||||
(the daily-expiry warning window the test asserts against).
|
||||
Verified pre-existing. Detail in `CHANGELOG.md` § [0.21.4]
|
||||
§ Stats; post-cut delta tracked here.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.4`. v0.21.4 is on
|
||||
`23ff62c`; v0.21.3 stays on `3d92a91`; v0.21.2 stays on
|
||||
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
|
||||
`04f9bf9`; v0.20.0 stays on `41a009a`.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.3`. v0.21.3 is on
|
||||
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
|
||||
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
|
||||
`41a009a`.
|
||||
---
|
||||
|
||||
## Since the v0.21.4 cut
|
||||
## What shipped in Phase 8 (432061c – bd388fe)
|
||||
|
||||
- **`fe68861` — `feat(replay): add quarter-mark notches to scrub
|
||||
bar`.** First finite step toward B-2's screen-takeover layout.
|
||||
Five 1px vertical ticks at 0/25/50/75/100 % give the player
|
||||
visual anchor points without needing to mentally bisect the
|
||||
bar. Pure helper `scrub_notch_positions()` returns the fixed
|
||||
array; spawn loop lives next to the WIN MOVE marker spawn so
|
||||
the lifecycles match. Notches paint in `BORDER_SUBTLE`
|
||||
(matches unfilled-track colour) and rely on extending past the
|
||||
1px track (5px tall, anchored 2px above track top) for
|
||||
visibility — same trick the WIN MOVE marker uses. Spawned
|
||||
*after* the WIN MOVE marker so a notch and the marker landing
|
||||
on the same percentage paint the marker on top. Mirrors the
|
||||
notch ladder in `docs/ui-mockups/replay-overlay-mobile.html`.
|
||||
4 new tests; 1228 → 1232.
|
||||
- **`d322abf` — `feat(replay): add percentage labels under
|
||||
scrub-bar notches`.** First **layout-changing** commit in B-2's
|
||||
screen-takeover arc. Banner height grew from 60 → 76 px to make
|
||||
room for a 16 px label row beneath the 1 px scrub track; the
|
||||
top row's `flex_grow: 1.0` still consumes the same 59 px so no
|
||||
ripples on existing content. Pure helper `scrub_notch_labels()`
|
||||
returns the fixed `["0%", "25%", "50%", "75%", "100%"]` array,
|
||||
paired index-for-index with `scrub_notch_positions()`. Spawn
|
||||
loop applies an "endpoints flush, middle three percent-anchored"
|
||||
positioning pattern (Bevy 0.18 UI has no clean
|
||||
`translate-x: -50%` primitive, so endpoints flush against
|
||||
banner edges and middle three accept slight right-of-notch
|
||||
offset). Label colour is `TEXT_SECONDARY` (mockup's
|
||||
`BORDER_SUBTLE` reads as too low-contrast at 12 px against
|
||||
`BG_ELEVATED_HI`). 4 new tests; 1232 → 1236.
|
||||
- **`1873b3f` — `feat(replay): add keybind-hint footer to
|
||||
overlay banner`.** Second layout-changing commit in B-2's arc.
|
||||
Banner grew from 76 → 92 px to fit a 16 px footer row at the
|
||||
bottom edge with a vim-style mode line on the left
|
||||
(`▌ NORMAL │ replay`) and a keybind-hint on the right
|
||||
(`[SPACE] pause/resume`). Surfaces the existing Space
|
||||
accelerator visually so CLAUDE.md §3.3's UI-first contract
|
||||
holds for keyboard accelerators too. Footer lists *only
|
||||
wired* keybinds — future commits that wire ESC for stop or
|
||||
← / → for prev/next will extend the right-hand text in
|
||||
lockstep. Two pure helpers (`keybind_footer_mode_text`,
|
||||
`keybind_footer_hint_text`) keep the static text testable;
|
||||
shared `font_handle_for_labels` clone covers both label and
|
||||
footer text spawns. 1px top border in `BORDER_SUBTLE`
|
||||
separates the footer from the labels row. 4 new tests;
|
||||
1236 → 1240.
|
||||
- **`90e24d9` — `feat(replay): wire ESC accelerator for stop,
|
||||
gate pause modal`.** ESC during an active replay now stops it
|
||||
(mirrors the Stop button click). New `handle_stop_keyboard`
|
||||
system in `replay_overlay.rs` parallels `handle_pause_keyboard`
|
||||
in shape. Cross-plugin coordination via `pause_plugin::toggle_pause`:
|
||||
added a fourth defer-if check
|
||||
(`replay_state.is_some_and(|s| s.is_playing())`) right after
|
||||
`other_modal_scrims` and before `selection`. Symmetric to the
|
||||
existing modal-stack defer pattern. Footer hint extended from
|
||||
`[SPACE] pause/resume` → `[SPACE] pause/resume · [ESC] stop`
|
||||
in lockstep with the wiring; the only-wired-keybinds
|
||||
discipline holds. 3 new tests + 1 updated helper-pin test;
|
||||
1240 → 1243.
|
||||
- **`23902cd` — `feat(replay): HC-mode coverage for
|
||||
keybind-footer top border`.** Tag the footer's border-carrying
|
||||
Node with `HighContrastBorder::with_default(BORDER_SUBTLE)` so
|
||||
the existing `apply_high_contrast_borders` system bumps the
|
||||
1 px top border from `#505050` → `#a0a0a0` under HC mode.
|
||||
Footer text colours don't need bumps —
|
||||
`TEXT_SECONDARY` (`#a0a0a0`) is already at `BORDER_SUBTLE_HC`
|
||||
luminance by design (no `TEXT_SECONDARY_HC` constant exists).
|
||||
The 1 px scrub track, notch ticks, and WIN MOVE marker render
|
||||
via `BackgroundColor` (not `BorderColor`) so the marker
|
||||
doesn't apply — HC coverage for those would need a
|
||||
settings-aware paint system (precedent: `radial_rim_outline`
|
||||
in `radial_menu`) and is deferred. 1 new test; 1243 → 1244.
|
||||
- **`e5c4f51` — `feat(replay): wire ← / → keyboard accelerators
|
||||
for paused stepping`.** New `step_backwards_replay_playback`
|
||||
in `replay_playback.rs` decrements the cursor and dispatches
|
||||
`UndoRequestEvent`; the game's `handle_undo` reads it next
|
||||
frame to reverse its most-recent move — hooking the existing
|
||||
undo system rather than replaying forward from cursor 0
|
||||
(every replay-applied move pushes to the undo stack the same
|
||||
way a player move would, so undo is the right reversal
|
||||
primitive). Both arrow keys are paused-only via the same
|
||||
destructure-gate pattern the forward step uses. Footer hint
|
||||
extended in lockstep:
|
||||
`[SPACE] pause/resume · [ESC] stop · [← →] step`. Footer
|
||||
reads "step" not the mockup's "scrub" — single-move step is
|
||||
what's wired; continuous scrub would need a key-held event
|
||||
source. `ReplayOverlayPlugin` gains
|
||||
`add_message::<UndoRequestEvent>()` defensively. 6 new tests
|
||||
(2 hint pins + 4 keyboard scenarios) + 1 updated helper-pin
|
||||
test; 1244 → 1250 total tests, 1249 passing.
|
||||
| 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 |
|
||||
|
||||
**Pre-existing flake noted (verified):**
|
||||
`daily_challenge_plugin::tests::
|
||||
check_system_fires_warning_event_only_once_per_day` is
|
||||
time-dependent — fails when wall-clock UTC is within 30
|
||||
minutes of midnight (the daily-expiry warning window the test
|
||||
asserts against). Verified pre-existing by stashing all
|
||||
changes and re-running before commit — failure persisted. Same
|
||||
shape as the `winnable_seed_search` flake from earlier in the
|
||||
session. Will pass deterministically when UTC isn't in the
|
||||
warning window. Not introduced by recent work.
|
||||
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
|
||||
|
||||
Banner geometry is now mutable — every prior B-2 commit fit
|
||||
inside fixed 60 px space, but the notch-labels commit
|
||||
established the "grow the container, add a new flex-column
|
||||
child" precedent and the keybind-footer commit applied it
|
||||
again. The next sub-pieces need significantly more vertical
|
||||
room and follow the same shape.
|
||||
---
|
||||
|
||||
Next finite step on B-2: keyboard accelerator coverage is now
|
||||
complete (`Space` / `Esc` / `←` / `→`). Remaining choices:
|
||||
1. **HC-mode coverage for the scrub-track / notch ticks /
|
||||
WIN MOVE marker.** These render via `BackgroundColor` (not
|
||||
`BorderColor`) so `HighContrastBorder` doesn't apply.
|
||||
Pattern would mirror `radial_menu::radial_rim_outline` —
|
||||
per-frame paint reading `Settings::high_contrast_mode`.
|
||||
Small commit, accessibility-progressing.
|
||||
2. **Continuous scrub on key-held ← / →** instead of
|
||||
single-move step. Needs a key-held event source (or
|
||||
accumulator timer in the keyboard handler). Medium scope;
|
||||
matches the mockup's `[← →] scrub` terminology.
|
||||
3. **Move-log scroller / mini-tableau preview** — both need a
|
||||
much larger banner-height grow (effectively the takeover
|
||||
container itself). Bigger arcs; the natural place to land
|
||||
the layout reflow that turns the banner into a takeover.
|
||||
4. **Cut a v0.21.5 patch release** rolling up the four
|
||||
post-cut commits (`fe68861`, `d322abf`, `1873b3f`,
|
||||
`90e24d9`, `23902cd`, `e5c4f51`) under the through-line
|
||||
"replay-overlay scrubbing affordances + accessibility."
|
||||
Coherent narrative; six commits is a normal-sized patch
|
||||
bundle for this project.
|
||||
## Open punch list (ordered by priority)
|
||||
|
||||
Recommended order: option 4 (cut release) is a clean next
|
||||
boundary — six commits with a clear through-line is the right
|
||||
size to bundle. Option 1 (HC paint for decorative pieces) is
|
||||
the smallest next-feature commit if continuing past the cut.
|
||||
### 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
|
||||
|
||||
## Open punch list
|
||||
### 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<String>` added to `Settings`; editor modal in leaderboard panel; persists
|
||||
to `settings.json`; `handle_opt_in_button` prefers custom name over username.
|
||||
|
||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||
### 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.
|
||||
|
||||
- **APK launch verification on AVD / device.** `adb install` then
|
||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
||||
The build works and persistence is wired, but no end-to-end
|
||||
device run has been logged. Shakes out runtime bugs the build +
|
||||
unit tests can't catch.
|
||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
||||
Android backend; small custom JNI call.
|
||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
||||
to a stub returning `KeychainUnavailable`; replace with Android
|
||||
Keystore via JNI when sync auth ships on mobile.
|
||||
- **Google Play Games (gpgs) integration.** Listed as a
|
||||
Phase-Android target since Phase 1; now unblocked by the build
|
||||
target.
|
||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||
Either upstream a cargo-apk fix or document `--lib` as
|
||||
canonical in the runbook.
|
||||
### 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.
|
||||
|
||||
### Visual-identity follow-ups (post-v0.21.0)
|
||||
### 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 <username>`
|
||||
subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
|
||||
active sessions for the user.
|
||||
|
||||
The visual-identity arc is effectively complete: token system,
|
||||
chrome migration, splash boot screen, replay-overlay banner,
|
||||
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
||||
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||
### 5b. Android UX polish (2026-05-12)
|
||||
|
||||
- **Replay-overlay screen-takeover redesign.** The full mockup
|
||||
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
|
||||
mini-tableau preview, playback controls, move-log scroll, and
|
||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
||||
`e080b49`); the floating MOVE chip above the focused card
|
||||
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
|
||||
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
|
||||
(UI). Playback controls (pause / resume / step + Space
|
||||
accelerator) shipped post-v0.21.3 in `fbe48ac`. Quarter-mark
|
||||
scrub notches (5 ticks at 0/25/50/75/100 %) shipped
|
||||
post-v0.21.4 in `fe68861` — first decoration step toward the
|
||||
takeover layout. Percentage labels under each notch shipped
|
||||
post-v0.21.4 in `d322abf` — first **layout-changing** commit
|
||||
(banner 60 → 76 px). Keybind-hint footer shipped in `1873b3f`
|
||||
(banner 76 → 92 px — vim-style mode line + `[SPACE]
|
||||
pause/resume`). ESC accelerator wiring (with cross-plugin
|
||||
gate in `pause_plugin::toggle_pause`) shipped in `90e24d9`.
|
||||
HC-mode coverage for the footer's top border shipped in
|
||||
`23902cd`. ← / → keyboard accelerators for paused stepping
|
||||
shipped in `e5c4f51` (hooks the existing undo system for
|
||||
backwards step; footer extended to
|
||||
`[SPACE] pause/resume · [ESC] stop · [← →] step`). Banner
|
||||
geometry is mutable; keyboard accelerator coverage is
|
||||
complete. What still needs to land: HC-mode coverage for
|
||||
the scrub-track / notches / WIN MOVE marker (they render
|
||||
via `BackgroundColor` so the `HighContrastBorder` marker
|
||||
doesn't apply — needs a settings-aware paint), continuous
|
||||
scrub on key-held ← / → (vs single-step), then the bigger
|
||||
pieces — a move-log scroller and a mini-tableau preview —
|
||||
both screen-takeover-only pieces that need a much larger
|
||||
banner height grow (effectively the takeover container
|
||||
itself). Multi-session.
|
||||
- *Floating `MOVE N/M` chip above the focused card during
|
||||
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
||||
`Text2d` entity sibling to the banner overlay; uses the same
|
||||
`LayoutResource` pile coordinates so it survives window
|
||||
resizes without UI/camera math.
|
||||
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
|
||||
Daily-challenge-expiry toast fires once per `daily.date` when
|
||||
within 30 min of UTC midnight reset and today is incomplete.
|
||||
`ToastVariant` is now fully load-bearing (every variant has at
|
||||
least one real driver). Future Warning drivers can either reuse
|
||||
the generic `WarningToastEvent(String)` carrier or add their
|
||||
own domain message + `animation_plugin` handler.
|
||||
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
|
||||
`MoveRejectedEvent` now fires a 2-second pink-bordered
|
||||
"Invalid move" toast as the third leg of the
|
||||
audio + visual + text rejection-feedback stool.
|
||||
- *High-contrast accessibility mode — closed 2026-05-08 by
|
||||
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
|
||||
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
|
||||
dynamic-paint rollout (`c153363`).* Card text rendering plus
|
||||
8 static-border chrome surfaces (modal scaffold, tooltip,
|
||||
onboarding key chips, help panel key chips, stats panel
|
||||
cells, home Level/XP/Score row, home mode buttons, home
|
||||
mode-hotkey chips, 4 settings panel surfaces) all boost
|
||||
borders to `BORDER_SUBTLE_HC` under HC via the
|
||||
`HighContrastBorder` marker. The previously-carved-out
|
||||
dynamic-paint sites are now also covered: HUD action buttons
|
||||
and modal buttons take the same marker (their paint cycles
|
||||
only mutate `BackgroundColor`, so no race); the radial menu
|
||||
rim folds HC into its per-frame spawn via
|
||||
`radial_rim_outline` so the focused rim boosts to
|
||||
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
|
||||
hierarchy that naive marker substitution would invert).
|
||||
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
|
||||
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
|
||||
card animations; `pulse_splash_cursor` skips the per-frame
|
||||
pulse multiplier; `spawn_splash` skips the scanline overlay
|
||||
entirely. Future scope: gate any future card-lift z-bump
|
||||
animation, warning-chip pulse (when one materialises).
|
||||
- [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<ModalScrim>`. 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<ModalScrim>>` and guards `spawn_menu_popover` with `scrims.is_empty()`.
|
||||
Verified on device: ≡ tap while Stats open does nothing.
|
||||
|
||||
### Carried forward from v0.19.0
|
||||
**Note:** These 4 fixes are implemented and verified but not yet committed.
|
||||
|
||||
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
|
||||
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
|
||||
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
|
||||
hicolor + downstream `.icns`/`.ico` packaging needs. The
|
||||
`.ico` and `.icns` bundle-format files themselves are *not*
|
||||
generated — both would need new crate deps (`ico` and
|
||||
`icns` respectively) and only matter at app-bundle time
|
||||
(cargo-bundle / packaging), not at `cargo run`. Open if the
|
||||
project later ships as a packaged macOS / Windows app.
|
||||
### 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`.
|
||||
|
||||
### Other small candidates
|
||||
---
|
||||
|
||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
||||
site renders them today — the Shareable badge therefore lands
|
||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
||||
the badge will need to follow.
|
||||
- **Toast queue / immediate unification.** The two toast paths
|
||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||
for fire-and-forget) now share visual treatment but remain
|
||||
separate functions because they serve different temporal
|
||||
needs (sequential vs. parallel). If overlap becomes a UX
|
||||
issue, merge into one queue with priority lanes.
|
||||
## ARCHITECTURE.md gaps (for the update pass)
|
||||
|
||||
### Process notes
|
||||
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
|
||||
|
||||
- **The desktop-adaptation spec is the canonical reference for
|
||||
geometry decisions** when porting any future plugin. Read
|
||||
`docs/ui-mockups/desktop-adaptation.md` first; apply the
|
||||
universal rules to every surface; consult the per-screen
|
||||
table for the priority surfaces. The 9 missing-plugin screens
|
||||
(splash now ported; eight remaining) inherit the universal
|
||||
rules without dedicated guidance.
|
||||
- **Stitch `generate_variants` is unreliable for layout-only
|
||||
adaptation prompts** as of 2026-05-07. The first call timed
|
||||
out and no variant ever landed in `list_screens`. If a future
|
||||
session wants visual desktop mockups, prefer
|
||||
`generate_screen_from_text` with a fresh narrow prompt per
|
||||
screen rather than `generate_variants` against existing
|
||||
mobile screens.
|
||||
- **Token-port pattern.** v0.20.0's chrome-migration commits
|
||||
set a reusable shape for "centralised design system applied
|
||||
across N plugins":
|
||||
1. Constants module (`ui_theme.rs`) is the source of truth.
|
||||
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
||||
`const` on stable) use a literal RGB matching the token,
|
||||
with a unit test pinning the RGB to the token (e.g.
|
||||
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
|
||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
|
||||
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT` ↔
|
||||
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
|
||||
promoted const re-exported from one plugin and imported
|
||||
by the other — replaces "kept in sync" doc comments with a
|
||||
compile-time invariant.
|
||||
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
||||
as literals with a comment naming the rationale; only UI
|
||||
chrome routes through tokens.
|
||||
- **`SplashFadable` scaffolding pattern** (introduced in
|
||||
`cacb19c`). Any future overlay that needs to fade `N >> 3`
|
||||
elements together should follow the same shape: one tiny
|
||||
marker carrying the full-alpha base colour, one global query
|
||||
that lerps every marker's alpha each frame, no per-element
|
||||
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
|
||||
query exclusion pattern that the old splash was hitting at
|
||||
three siblings.
|
||||
---
|
||||
|
||||
### Canonical remote
|
||||
## Process notes
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||
Always push there. As of v0.21.0 origin matches local; the next
|
||||
push happens when post-cut work accumulates and is ready to roll
|
||||
into a v0.21.1 / v0.22.0 cut.
|
||||
- **Commit attribution:** use `funman300` as git user. Co-author line:
|
||||
`Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
|
||||
- **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.
|
||||
|
||||
### Design direction (Terminal — base16-eighties)
|
||||
|
||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
||||
16 px edge margins, 8 px card radius.
|
||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
|
||||
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242` —
|
||||
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
|
||||
success (`#acc267`), gold warning (`#ddb26f`), pink error /
|
||||
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
|
||||
info (`#12cfc0`).
|
||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
|
||||
Outlined glyphs for diamonds & clubs are *always on*; the
|
||||
Settings "color-blind mode" toggle swaps red → lime `#acc267`
|
||||
(was red → cyan pre-v0.21.0; lime is the next-best non-red
|
||||
base16-eighties accent now that the primary itself is red).
|
||||
- **Card glyphs render upright in both corners** — no 180°
|
||||
inverted-corner-indicator rotation. Single-orientation
|
||||
digital play doesn't benefit from the traditional flip-
|
||||
readback convention. `design-system.md` § Game Cards
|
||||
documents this deliberate deviation.
|
||||
---
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. v0.21.4 is tagged at 23ff62c (cut 2026-05-08, a
|
||||
patch release rolling up replay-scrubbing accessibility: WIN MOVE
|
||||
marker on the scrub bar, pause / resume / step playback controls
|
||||
with a Space keyboard accelerator, and the additive
|
||||
`Replay::win_move_index: Option<usize>` data field that makes the
|
||||
marker possible). v0.21.3 stays at 3d92a91, v0.21.2 at f23df3b,
|
||||
v0.21.1 at daa655a, v0.21.0 at 04f9bf9. Working tree clean. See
|
||||
CHANGELOG.md § [0.21.4] for full detail.
|
||||
You are a senior Rust + Bevy developer working on Ferrous Solitaire.
|
||||
Working directory: <Rusty_Solitaire clone path>.
|
||||
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
|
||||
|
||||
State: HEAD locally — see `git rev-parse HEAD`. Post-cut HEAD is
|
||||
`e5c4f51` (six carved-out commits on top of v0.21.4 — scrub-bar
|
||||
notches `fe68861`, notch labels `d322abf`, keybind-hint footer
|
||||
`1873b3f`, ESC accelerator + pause-modal gate `90e24d9`, HC
|
||||
marker for footer border `23902cd`, ← / → keyboard accelerators
|
||||
`e5c4f51`). Workspace tests: 1250 total / 1249 passing / 1
|
||||
pre-existing time-dependent flake (clock-near-midnight; verified
|
||||
not introduced by recent work). Clippy clean.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
READ FIRST (in order):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.21.4] section is the most recent cut
|
||||
3. CLAUDE.md — unified-3.0 rule set
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
6. docs/ui-mockups/ — design system + 24-mockup library +
|
||||
desktop-adaptation.md (the rules-based
|
||||
companion to the mockups; read this
|
||||
before any plugin port)
|
||||
7. docs/android/* — Android setup + build runbook
|
||||
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
(machine-local; may be missing on a
|
||||
fresh machine)
|
||||
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/<this-project>/memory/MEMORY.md
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. APK launch verification on AVD / device — `adb install` +
|
||||
`adb logcat` to shake out runtime bugs the build / unit
|
||||
tests can't catch. Likely surfaces JNI ClipboardManager
|
||||
and Android Keystore stubs that need real bridges. Larger
|
||||
scope; needs an Android device or emulator running.
|
||||
B. Replay-overlay screen-takeover redesign — multi-session
|
||||
work. Three sub-pieces shipped in v0.21.4: WIN MOVE
|
||||
marker (data field + UI) and pause / step / Space
|
||||
playback controls. The smaller floating-MOVE-chip piece
|
||||
shipped in v0.21.2 (`2fb2d63`). Post-v0.21.4: scrub
|
||||
notches `fe68861`, notch labels `d322abf` (banner
|
||||
60 → 76 px), keybind-hint footer `1873b3f` (banner
|
||||
76 → 92 px), ESC accelerator + cross-plugin gate
|
||||
`90e24d9`, HC-mode coverage for the footer top border
|
||||
`23902cd`, and ← / → keyboard accelerators for paused
|
||||
stepping `e5c4f51` (hooks the game's undo system for
|
||||
backwards step; footer extended to
|
||||
`[SPACE] pause/resume · [ESC] stop · [← →] step`).
|
||||
Keyboard accelerator coverage is complete. Natural next
|
||||
finite steps:
|
||||
1. **Cut a v0.21.5 patch release** rolling up the six
|
||||
post-cut commits under "replay-overlay scrubbing
|
||||
affordances + accessibility." Coherent narrative;
|
||||
clean release boundary.
|
||||
2. **HC-mode coverage** for the scrub-track / notches /
|
||||
WIN MOVE marker (render via `BackgroundColor` not
|
||||
`BorderColor`, so `HighContrastBorder` doesn't apply
|
||||
— needs a settings-aware paint, precedent
|
||||
`radial_rim_outline`). Small commit.
|
||||
3. **Continuous scrub on key-held ← / →** instead of
|
||||
single-step. Needs a key-held event source. Matches
|
||||
the mockup's `[← →] scrub` terminology.
|
||||
4. **Move-log scroller / mini-tableau preview** — both
|
||||
need a much larger banner-height grow (effectively
|
||||
the takeover container itself). Multi-session arcs
|
||||
that close B-2.
|
||||
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
|
||||
C. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||
Axum server, `SolitaireServerClient` impl, GPGS stub
|
||||
wired into Settings. The biggest open arc by scope; rolls
|
||||
up several Phase Android dependencies (Keystore,
|
||||
ClipboardManager).
|
||||
OPEN WORK:
|
||||
Phase 8 punch list is fully closed. All items verified complete.
|
||||
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking).
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Use the system git config (already correct).
|
||||
- When attributing playtester feedback in commits/docs, use
|
||||
"Quat" not "Rhys" (saved feedback memory).
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — gh auth setup-git wired on
|
||||
primary dev box; verify on laptop before first push.
|
||||
- Token-port pattern: when migrating tokens, walk every
|
||||
concrete artifact downstream of the token (PNG textures,
|
||||
embedded SVGs, hardcoded literals, comment color names),
|
||||
not just the token name. v0.21.0 surfaced three "the
|
||||
migration walked past this" follow-ups that all matched
|
||||
this shape — codified here so future similar work can
|
||||
pattern-match instead of rediscovering.
|
||||
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
|
||||
visibility fix (`4d48cad`) implemented an invariant that
|
||||
had been declared in a module doc comment but was never
|
||||
enforced in code. When future work touches a module with
|
||||
a "this does X" doc comment, verify the code actually does
|
||||
X and add a test if not. Two layers, two checks.
|
||||
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
|
||||
|
||||
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
||||
Note: every remaining option is multi-session by nature (A is
|
||||
gated on Android tooling, B and C are explicitly multi-session
|
||||
arcs). A fresh session is a better fit for any of them than the
|
||||
tail of a long working stretch.
|
||||
Commit those first, then suggest Phase 9 planning.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: solitaire-server
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||
targetRevision: master
|
||||
path: deploy
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: solitaire
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the solitaire_wasm crate and install the output into
|
||||
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install wasm-pack
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
#
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||
# committed to git so self-hosters who don't touch the WASM crate can
|
||||
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||
# solitaire_core/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
echo "error: wasm-pack not found." >&2
|
||||
echo " Install with: cargo install wasm-pack" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building solitaire_wasm (target: web)..."
|
||||
wasm-pack build \
|
||||
--target web \
|
||||
--out-dir "$OUT_DIR" \
|
||||
--no-typescript \
|
||||
"$REPO_ROOT/solitaire_wasm"
|
||||
|
||||
# wasm-pack writes a package.json and .gitignore into the output dir.
|
||||
# Remove them — we manage the output directory ourselves.
|
||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||
|
||||
echo "Done. Output:"
|
||||
ls -lh "$OUT_DIR"
|
||||
@@ -0,0 +1,62 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: solitaire-server
|
||||
namespace: solitaire
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: solitaire-server
|
||||
# SQLite is single-writer; Recreate avoids two pods owning the PVC at once.
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: solitaire-server
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: server
|
||||
image: solitaire-server
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
value: sqlite:///data/sol.db
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: solitaire-secrets
|
||||
key: jwt-secret
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
volumeMounts:
|
||||
- name: db-data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
volumes:
|
||||
- name: db-data
|
||||
persistentVolumeClaim:
|
||||
claimName: solitaire-db
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: solitaire-analytics
|
||||
namespace: solitaire
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: analytics.aleshym.co
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: matomo
|
||||
port:
|
||||
name: http
|
||||
tls:
|
||||
- hosts:
|
||||
- analytics.aleshym.co
|
||||
secretName: analytics-tls
|
||||
@@ -0,0 +1,27 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: solitaire-server
|
||||
namespace: solitaire
|
||||
annotations:
|
||||
# Remove the next two lines if you are not using cert-manager.
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: klondike.aleshym.co
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: solitaire-server
|
||||
port:
|
||||
name: http
|
||||
# Remove the tls block if you are not using cert-manager.
|
||||
tls:
|
||||
- hosts:
|
||||
- klondike.aleshym.co
|
||||
secretName: solitaire-tls
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- pvc.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
- mariadb-pvc.yaml
|
||||
- mariadb-deployment.yaml
|
||||
- mariadb-service.yaml
|
||||
- matomo-pvc.yaml
|
||||
- matomo-secret.yaml
|
||||
- matomo-deployment.yaml
|
||||
- matomo-service.yaml
|
||||
- ingress-analytics.yaml
|
||||
|
||||
# CI updates this block automatically via `kustomize edit set image`.
|
||||
# The image name here matches the `image: solitaire-server` stub in deployment.yaml.
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: 3e006a1e
|
||||
@@ -0,0 +1,72 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mariadb
|
||||
namespace: solitaire
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mariadb
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mariadb
|
||||
spec:
|
||||
containers:
|
||||
- name: mariadb
|
||||
image: mariadb:11
|
||||
env:
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_ROOT_PASSWORD
|
||||
- name: MYSQL_DATABASE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_DATABASE
|
||||
- name: MYSQL_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_USER
|
||||
- name: MYSQL_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_PASSWORD
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
volumeMounts:
|
||||
- name: mariadb-data
|
||||
mountPath: /var/lib/mysql
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- healthcheck.sh
|
||||
- --connect
|
||||
- --innodb_initialized
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- healthcheck.sh
|
||||
- --connect
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumes:
|
||||
- name: mariadb-data
|
||||
persistentVolumeClaim:
|
||||
claimName: mariadb-data
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: mariadb-data
|
||||
namespace: solitaire
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mariadb
|
||||
namespace: solitaire
|
||||
spec:
|
||||
selector:
|
||||
app: mariadb
|
||||
ports:
|
||||
- name: mysql
|
||||
port: 3306
|
||||
targetPort: 3306
|
||||
clusterIP: None
|
||||
@@ -0,0 +1,85 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: matomo
|
||||
namespace: solitaire
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: matomo
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: matomo
|
||||
spec:
|
||||
containers:
|
||||
- name: matomo
|
||||
image: bitnami/matomo:5
|
||||
env:
|
||||
- name: MATOMO_DATABASE_HOST
|
||||
value: mariadb
|
||||
- name: MATOMO_DATABASE_PORT_NUMBER
|
||||
value: "3306"
|
||||
- name: MATOMO_DATABASE_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_DATABASE
|
||||
- name: MATOMO_DATABASE_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_USER
|
||||
- name: MATOMO_DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MYSQL_PASSWORD
|
||||
- name: MATOMO_USERNAME
|
||||
value: admin
|
||||
- name: MATOMO_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: matomo-secret
|
||||
key: MATOMO_ADMIN_PASSWORD
|
||||
- name: MATOMO_EMAIL
|
||||
value: funman300@gmail.com
|
||||
- name: MATOMO_WEBSITE_NAME
|
||||
value: "Solitaire Quest"
|
||||
- name: MATOMO_WEBSITE_HOST
|
||||
value: "https://klondike.aleshym.co"
|
||||
- name: MATOMO_HOST
|
||||
value: analytics.aleshym.co
|
||||
- name: MATOMO_ENABLE_PROXY_URI_HEADER
|
||||
value: "yes"
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: matomo-data
|
||||
mountPath: /bitnami/matomo
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /index.php
|
||||
port: 8080
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /index.php
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumes:
|
||||
- name: matomo-data
|
||||
persistentVolumeClaim:
|
||||
claimName: matomo-data
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: matomo-data
|
||||
namespace: solitaire
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,13 @@
|
||||
# Credentials for MariaDB and the Matomo admin account.
|
||||
# Regenerate with: python3 -c "import secrets; print(secrets.token_urlsafe(18))"
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: matomo-secret
|
||||
namespace: solitaire
|
||||
stringData:
|
||||
MYSQL_ROOT_PASSWORD: "jspRn-QU18sZhB55FR-JfrMJ"
|
||||
MYSQL_DATABASE: matomo
|
||||
MYSQL_USER: matomo
|
||||
MYSQL_PASSWORD: "ZxDp648UuL5fsN7eQI23E7ue"
|
||||
MATOMO_ADMIN_PASSWORD: "J6QUtbroK4Z7zao4Dnl0J7e2"
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: matomo
|
||||
namespace: solitaire
|
||||
spec:
|
||||
selector:
|
||||
app: matomo
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: solitaire
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: solitaire-db
|
||||
namespace: solitaire
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: solitaire-server
|
||||
namespace: solitaire
|
||||
spec:
|
||||
selector:
|
||||
app: solitaire-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
+8
-6
@@ -143,16 +143,18 @@ After the APK is signed cargo-apk panics with:
|
||||
thread 'main' panicked: Bin is not compatible with Cdylib
|
||||
```
|
||||
|
||||
This happens AFTER the APK is on disk and signed. cargo-apk is
|
||||
trying to also wrap the desktop `[[bin]]` target. The APK is still
|
||||
valid. Work around with `--lib`:
|
||||
This happens AFTER the APK is on disk and signed. cargo-apk tries to
|
||||
also wrap the desktop `[[bin]]` target alongside the `[lib]`. The APK
|
||||
is valid — the panic is cosmetic. **Always use `--lib`**, which is the
|
||||
canonical build command (see `CLAUDE.md §15.1`):
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||
cargo apk build -p solitaire_app --lib
|
||||
```
|
||||
|
||||
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
|
||||
gate so cargo-apk skips the bin target on Android.)
|
||||
Root cause: upstream cargo-apk bug — it does not skip `[[bin]]` targets
|
||||
when building for Android. No in-repo fix is possible; `--lib` is the
|
||||
accepted workaround.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
# 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
|
||||
@@ -20,7 +24,7 @@ All seven Cargo crates created and compiling cleanly:
|
||||
| `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 "Solitaire Quest" at 1280×800 |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
# 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::<WindowResized>()` 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<C>`
|
||||
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::<Children>()` 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<ModalScrim>`
|
||||
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<ModalScrim>>` 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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Date:** 2026-04-28
|
||||
> **Author:** Claude Code
|
||||
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
|
||||
> **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Solitaire Quest — Phase 1 + 2: Workspace & Core Game Engine
|
||||
# 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.
|
||||
|
||||
@@ -555,7 +555,7 @@ fn main() {
|
||||
.add_plugins(
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
title: "Ferrous Solitaire".into(),
|
||||
resolution: (1280.0, 800.0).into(),
|
||||
..default()
|
||||
}),
|
||||
@@ -571,7 +571,7 @@ fn main() {
|
||||
```bash
|
||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||
```
|
||||
Expected: A blank Bevy window titled "Solitaire Quest" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
|
||||
Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1210,7 +1210,7 @@ fn main() {
|
||||
.add_plugins(
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
title: "Ferrous Solitaire".into(),
|
||||
resolution: (1280.0, 800.0).into(),
|
||||
..default()
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
### Infrastructure
|
||||
|
||||
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
||||
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||
- A running Ferrous Solitaire sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||
- Verify the server is live before starting:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **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 "Solitaire Quest *Mobile* Redesign" — the mobile-first
|
||||
> 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
|
||||
|
||||
@@ -60,6 +60,15 @@ package = "com.solitairequest.app"
|
||||
apk_name = "solitaire-quest"
|
||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||
assets = "../assets"
|
||||
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
|
||||
# packages them into the APK; the launcher selects the best-fit bucket
|
||||
# for the device screen density. Sizes used:
|
||||
# mdpi (1×, 48 dp) → 48 px (exact)
|
||||
# hdpi (1.5×, 72 dp) → 64 px (88 %, aapt scales up slightly)
|
||||
# xhdpi (2×, 96 dp) → 128 px (133 %, aapt scales down)
|
||||
# xxhdpi (3×, 144 dp) → 256 px (178 %, aapt scales down)
|
||||
# xxxhdpi (4×, 192 dp) → 256 px (133 %, aapt scales down)
|
||||
resources = "res"
|
||||
# No `runtime_libs` — we don't ship any precompiled .so files,
|
||||
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
||||
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
||||
@@ -78,7 +87,15 @@ required = true
|
||||
name = "android.permission.INTERNET"
|
||||
|
||||
[package.metadata.android.application]
|
||||
label = "Solitaire Quest"
|
||||
label = "Ferrous Solitaire"
|
||||
# Launcher icon — references the density-bucketed mipmap resource above.
|
||||
icon = "@mipmap/ic_launcher"
|
||||
# `debuggable` defaults to false on release builds; cargo-apk flips it
|
||||
# automatically for debug profiles. Leaving the field unset keeps the
|
||||
# default behaviour.
|
||||
|
||||
[package.metadata.android.application.activity]
|
||||
# Lock to portrait — the current layout has only been designed and tested
|
||||
# in portrait orientation. Remove (or add a landscape layout) before
|
||||
# enabling auto-rotate.
|
||||
orientation = "portrait"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 927 B |
Binary file not shown.
|
After Width: | Height: | Size: 759 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
+59
-15
@@ -18,21 +18,23 @@ use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::winit::WinitWindows;
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
|
||||
/// App entry point — builds and runs the Bevy app.
|
||||
@@ -76,6 +78,7 @@ pub fn run() {
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
@@ -103,7 +106,7 @@ pub fn run() {
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
title: "Ferrous Solitaire".into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".into()),
|
||||
@@ -116,6 +119,9 @@ pub fn run() {
|
||||
// small enough that a few stray dropped frames from
|
||||
// disabling vsync are imperceptible.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
// Android windows always fill the screen; max_width/max_height
|
||||
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
@@ -126,11 +132,20 @@ pub fn run() {
|
||||
..default()
|
||||
})
|
||||
// The `assets/` directory lives at the workspace root, but
|
||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||
// Point one level up so `cargo run -p solitaire_app` finds
|
||||
// card faces, backs, backgrounds, and the UI font.
|
||||
// on desktop Bevy resolves `AssetPlugin::file_path` relative
|
||||
// to the binary package's `CARGO_MANIFEST_DIR`
|
||||
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
|
||||
// miss the workspace-root `assets/` without a `../` prefix.
|
||||
//
|
||||
// On Android cargo-apk packages the same directory into the
|
||||
// APK at `assets/` (via `[package.metadata.android].assets`
|
||||
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
|
||||
// is already rooted there, so any `file_path` other than the
|
||||
// default makes it walk *out* of the APK's assets root and
|
||||
// all loads fail silently — which is what produced the
|
||||
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
}),
|
||||
@@ -142,6 +157,13 @@ pub fn run() {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
|
||||
// The drop-target highlight systems (update_drop_highlights,
|
||||
// update_drop_target_overlays) live in CursorPlugin but ARE useful
|
||||
// on Android — they've been left running because their Bevy system
|
||||
// params compile and function on Android; only the CursorIcon insert
|
||||
// is inert. Gate the whole plugin if the cursor APIs ever cause
|
||||
// Android linker issues; for now it's harmless to leave it registered.
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
@@ -158,7 +180,10 @@ pub fn run() {
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(PlayBySeedPlugin)
|
||||
.add_plugins(DifficultyPlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(SafeAreaInsetsPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
@@ -168,6 +193,8 @@ pub fn run() {
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
@@ -195,6 +222,8 @@ pub fn run() {
|
||||
// every fresh launch can flip `disable_smart_default_size` in
|
||||
// Settings to opt out. The flag is checked once at startup; a
|
||||
// mid-session change applies on the next launch.
|
||||
// Android windows are always full-screen; the OS controls sizing.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
@@ -215,6 +244,7 @@ pub fn run() {
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
@@ -335,6 +365,20 @@ fn set_window_icon(
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// Android entry point called by NativeActivity after dlopen-ing the `.so`.
|
||||
/// Sets the `AndroidApp` handle that Bevy's winit backend reads before
|
||||
/// constructing the event loop, then delegates to [`run`].
|
||||
///
|
||||
/// The `#[bevy_main]` proc-macro would generate the same code but only
|
||||
/// works on a function named `main`; our shared entry point is `run`, so
|
||||
/// we emit the equivalent expansion manually.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||
run();
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
|
||||
@@ -12,6 +12,8 @@ publish = false
|
||||
[dependencies]
|
||||
png = "0.17"
|
||||
ab_glyph = "0.2"
|
||||
solitaire_core = { path = "../solitaire_core" }
|
||||
solitaire_data = { path = "../solitaire_data" }
|
||||
|
||||
[[bin]]
|
||||
name = "gen_sfx"
|
||||
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
|
||||
[[bin]]
|
||||
name = "gen_art"
|
||||
path = "src/bin/gen_art.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_seeds"
|
||||
path = "src/bin/gen_seeds.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_difficulty_seeds"
|
||||
path = "src/bin/gen_difficulty_seeds.rs"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Generates PNG assets for Solitaire Quest.
|
||||
//! Generates PNG assets for Ferrous Solitaire.
|
||||
//!
|
||||
//! Produces:
|
||||
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`,
|
||||
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||
//!
|
||||
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||
//! provably-winnable seeds).
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \
|
||||
//! --start 0xD1FF0000_00000000 --per-tier 40
|
||||
//! ```
|
||||
//!
|
||||
//! Flags:
|
||||
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000)
|
||||
//! --per-tier Seeds to emit per tier (default 40)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
|
||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||
// whose budget proves it Winnable.
|
||||
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||
("Easy", 1_000, 1_000),
|
||||
("Medium", 5_000, 5_000),
|
||||
("Hard", 25_000, 25_000),
|
||||
("Expert", 100_000, 100_000),
|
||||
("Grandmaster", 200_000, 200_000),
|
||||
];
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
let mut start: u64 = 0xD1FF_0000_0000_0000;
|
||||
let mut per_tier: usize = 40;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--start" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --start requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
start = parse_u64(&val);
|
||||
}
|
||||
"--per-tier" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --per-tier requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
per_tier = val.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: --per-tier must be a positive integer");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("gen_difficulty_seeds: generate tiered seed catalogs");
|
||||
eprintln!(" --start <seed> starting seed (hex or decimal)");
|
||||
eprintln!(" --per-tier <n> seeds per tier (default 40)");
|
||||
return;
|
||||
}
|
||||
other => {
|
||||
eprintln!("error: unknown argument: {other}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if per_tier == 0 {
|
||||
eprintln!("error: --per-tier must be > 0");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let num_tiers = BUDGETS.len();
|
||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||
let mut tried: u64 = 0;
|
||||
let mut seed = start;
|
||||
|
||||
eprintln!(
|
||||
"gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …",
|
||||
per_tier
|
||||
);
|
||||
eprintln!(
|
||||
" Tiers: {}",
|
||||
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
|
||||
while buckets.iter().any(|b| b.len() < per_tier) {
|
||||
tried += 1;
|
||||
'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() {
|
||||
if buckets[i].len() >= per_tier {
|
||||
continue;
|
||||
}
|
||||
let cfg = SolverConfig { move_budget, state_budget };
|
||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||
SolverResult::Winnable => {
|
||||
buckets[i].push(seed);
|
||||
eprintln!(
|
||||
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||
buckets[i].len(),
|
||||
per_tier
|
||||
);
|
||||
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||
}
|
||||
SolverResult::Unwinnable => {
|
||||
// Definitely unsolvable — skip all remaining tiers.
|
||||
break 'tier;
|
||||
}
|
||||
SolverResult::Inconclusive => {
|
||||
// Budget exhausted without proof — try the next larger tier.
|
||||
// If this is the last tier, the seed is discarded (Inconclusive
|
||||
// at max budget means "probably but not provably winnable").
|
||||
if i == num_tiers - 1 {
|
||||
break 'tier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
|
||||
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
|
||||
|
||||
let date = current_date();
|
||||
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
|
||||
println!(
|
||||
" // Generated by solitaire_assetgen::gen_difficulty_seeds \
|
||||
(tier={tier_name}, date={date})"
|
||||
);
|
||||
for chunk in buckets[i].chunks(5) {
|
||||
for s in chunk {
|
||||
println!(
|
||||
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||
(s >> 48) & 0xFFFF,
|
||||
(s >> 32) & 0xFFFF,
|
||||
(s >> 16) & 0xFFFF,
|
||||
s & 0xFFFF,
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(s: &str) -> u64 {
|
||||
let cleaned = s.replace('_', "");
|
||||
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
cleaned.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn current_date() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86400;
|
||||
let mut y = 1970u64;
|
||||
let mut d = days;
|
||||
loop {
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let days_in_year = if leap { 366 } else { 365 };
|
||||
if d < days_in_year {
|
||||
break;
|
||||
}
|
||||
d -= days_in_year;
|
||||
y += 1;
|
||||
}
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let month_days: [u64; 12] = [
|
||||
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||||
];
|
||||
let mut m = 0usize;
|
||||
for &md in &month_days {
|
||||
if d < md {
|
||||
break;
|
||||
}
|
||||
d -= md;
|
||||
m += 1;
|
||||
}
|
||||
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||
//!
|
||||
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p solitaire_assetgen --bin gen_seeds --release -- \
|
||||
//! --start 0xCAFE_BABE_0000_0000 --count 75
|
||||
//! ```
|
||||
//!
|
||||
//! Flags:
|
||||
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xCAFEBABE00000000)
|
||||
//! --count Number of Winnable seeds to emit (default 75)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
let mut start: u64 = 0xCAFE_BABE_0000_0000;
|
||||
let mut count: usize = 75;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--start" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --start requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
start = parse_u64(&val);
|
||||
}
|
||||
"--count" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --count requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
count = val.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: --count must be a positive integer");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("{}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")).lines().take(20).collect::<Vec<_>>().join("\n"));
|
||||
return;
|
||||
}
|
||||
other => {
|
||||
eprintln!("error: unknown argument: {other}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
eprintln!("error: --count must be > 0");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let cfg = SolverConfig::default();
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||
let mut tried: u64 = 0;
|
||||
let mut seed = start;
|
||||
|
||||
eprintln!(
|
||||
"gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …"
|
||||
);
|
||||
|
||||
while found.len() < count {
|
||||
tried += 1;
|
||||
if matches!(
|
||||
try_solve(seed, draw_mode.clone(), &cfg),
|
||||
SolverResult::Winnable
|
||||
) {
|
||||
found.push(seed);
|
||||
eprintln!(
|
||||
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||
found.len(),
|
||||
count,
|
||||
seed,
|
||||
tried
|
||||
);
|
||||
}
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
|
||||
eprintln!("\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n");
|
||||
|
||||
println!(
|
||||
" // Generated by solitaire_assetgen::gen_seeds \
|
||||
(start=0x{start:016X}, count={count}, date={date})",
|
||||
date = current_date()
|
||||
);
|
||||
for chunk in found.chunks(5) {
|
||||
for s in chunk {
|
||||
println!(
|
||||
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||
(s >> 48) & 0xFFFF,
|
||||
(s >> 32) & 0xFFFF,
|
||||
(s >> 16) & 0xFFFF,
|
||||
s & 0xFFFF,
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(s: &str) -> u64 {
|
||||
let cleaned = s.replace('_', "");
|
||||
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
cleaned.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn current_date() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86400;
|
||||
// Gregorian calendar computation (Tomohiko Sakamoto's algorithm variant)
|
||||
let mut y = 1970u64;
|
||||
let mut d = days;
|
||||
loop {
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let days_in_year = if leap { 366 } else { 365 };
|
||||
if d < days_in_year {
|
||||
break;
|
||||
}
|
||||
d -= days_in_year;
|
||||
y += 1;
|
||||
}
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let month_days: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let mut m = 0usize;
|
||||
for &md in &month_days {
|
||||
if d < md {
|
||||
break;
|
||||
}
|
||||
d -= md;
|
||||
m += 1;
|
||||
}
|
||||
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||
}
|
||||
@@ -50,6 +50,35 @@ pub enum DrawMode {
|
||||
DrawThree,
|
||||
}
|
||||
|
||||
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
|
||||
/// catalog is drawn from. `Random` skips verification entirely and uses a
|
||||
/// system-time seed — deals may or may not be winnable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum DifficultyLevel {
|
||||
#[default]
|
||||
Easy,
|
||||
Medium,
|
||||
Hard,
|
||||
Expert,
|
||||
Grandmaster,
|
||||
/// Unverified system-time seed — may or may not be winnable.
|
||||
Random,
|
||||
}
|
||||
|
||||
impl DifficultyLevel {
|
||||
/// Short human-readable label shown in the HUD and win summary.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Easy => "Easy",
|
||||
Self::Medium => "Medium",
|
||||
Self::Hard => "Hard",
|
||||
Self::Expert => "Expert",
|
||||
Self::Grandmaster => "Grandmaster",
|
||||
Self::Random => "Random",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||||
///
|
||||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||||
@@ -59,6 +88,8 @@ pub enum DrawMode {
|
||||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||||
/// countdown around the session and auto-deals a fresh game on every win
|
||||
/// (see `solitaire_engine::TimeAttackPlugin`).
|
||||
/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog
|
||||
/// (or system-time for `Random`). Rules identical to Classic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum GameMode {
|
||||
#[default]
|
||||
@@ -70,6 +101,8 @@ pub enum GameMode {
|
||||
Challenge,
|
||||
/// Play as many games as possible within 10 minutes.
|
||||
TimeAttack,
|
||||
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
|
||||
Difficulty(DifficultyLevel),
|
||||
}
|
||||
|
||||
/// Snapshot of game state used for undo.
|
||||
@@ -112,6 +145,10 @@ pub struct GameState {
|
||||
/// Used by the `comeback` achievement condition.
|
||||
#[serde(default)]
|
||||
pub recycle_count: u32,
|
||||
/// When `true`, the player may move the top card of a foundation pile back
|
||||
/// onto a compatible tableau column. Off by default — non-standard house rule.
|
||||
#[serde(default)]
|
||||
pub take_from_foundation: bool,
|
||||
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||
/// the field. The loader refuses any value other than
|
||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||
@@ -154,6 +191,7 @@ impl GameState {
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
take_from_foundation: false,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
undo_stack: VecDeque::new(),
|
||||
}
|
||||
@@ -279,6 +317,18 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
if matches!(&from, PileType::Foundation(_)) {
|
||||
if !self.take_from_foundation {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"take-from-foundation rule is disabled".into(),
|
||||
));
|
||||
}
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only one card can return from foundation at a time".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
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()));
|
||||
@@ -376,12 +426,11 @@ impl GameState {
|
||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||
/// At that point the game can be completed without further player input.
|
||||
pub fn check_auto_complete(&self) -> bool {
|
||||
// Stock must be empty; waste may still have cards (they are resolved
|
||||
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
||||
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
|
||||
@@ -409,42 +458,53 @@ impl GameState {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
return None;
|
||||
}
|
||||
// Check waste top first — when stock is exhausted the waste may still
|
||||
// contain cards that can go directly to a foundation.
|
||||
let waste = PileType::Waste;
|
||||
if let Some((card, slot)) = self.piles[&waste].cards.last()
|
||||
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
||||
{
|
||||
let _ = card; // borrow ends here
|
||||
return Some((waste, PileType::Foundation(slot)));
|
||||
}
|
||||
for i in 0..7 {
|
||||
let tableau = PileType::Tableau(i);
|
||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||
// Prefer the slot that already claims this card's suit so
|
||||
// Aces don't sometimes land in slot 0 and then leave the
|
||||
// matching suit-claimed slot empty.
|
||||
let mut candidate: Option<u8> = None;
|
||||
let mut empty_slot: Option<u8> = None;
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
let pile = &self.piles[&foundation];
|
||||
if pile.cards.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
}
|
||||
} else if pile.claimed_suit() == Some(card.suit) {
|
||||
candidate = Some(slot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let target_slot = candidate.or_else(|| {
|
||||
// Only fall back to an empty slot if the card is an Ace,
|
||||
// which is the only rank that can claim an empty slot.
|
||||
if card.rank.value() == 1 { empty_slot } else { None }
|
||||
});
|
||||
if let Some(slot) = target_slot {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
||||
return Some((tableau, foundation));
|
||||
}
|
||||
}
|
||||
if let Some(slot) = self.piles[&tableau].cards.last()
|
||||
.and_then(|c| self.foundation_slot_for(c))
|
||||
{
|
||||
return Some((tableau, PileType::Foundation(slot)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the foundation slot index that `card` can legally move to, or
|
||||
/// `None` if no such slot exists.
|
||||
///
|
||||
/// Prefers the slot already claiming this card's suit so Aces always land
|
||||
/// in a consistent column. Falls back to an empty slot only for Aces.
|
||||
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
|
||||
let mut candidate: Option<u8> = None;
|
||||
let mut empty_slot: Option<u8> = None;
|
||||
for slot in 0..4_u8 {
|
||||
let pile = &self.piles[&PileType::Foundation(slot)];
|
||||
if pile.cards.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
}
|
||||
} else if pile.claimed_suit() == Some(card.suit) {
|
||||
candidate = Some(slot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let target = candidate.or_else(|| {
|
||||
if card.rank.value() == 1 { empty_slot } else { None }
|
||||
});
|
||||
target.filter(|&slot| {
|
||||
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -972,24 +1032,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_false_when_waste_not_empty() {
|
||||
fn auto_complete_true_when_stock_empty_waste_has_cards() {
|
||||
// Waste no longer blocks auto-complete — draw() drains it during
|
||||
// auto-complete steps. Only stock-not-empty and face-down tableau
|
||||
// cards block the flag.
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
// Leave the waste pile untouched (it may be empty after clearing stock,
|
||||
// so add a card explicitly to ensure the waste guard is exercised).
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||
id: 99,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
// Make all tableau cards face-up so only the waste guard is the blocker.
|
||||
for i in 0..7 {
|
||||
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
||||
c.face_up = true;
|
||||
}
|
||||
}
|
||||
assert!(!g.check_auto_complete());
|
||||
assert!(g.check_auto_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1225,4 +1285,71 @@ mod tests {
|
||||
"must target the Hearts-claimed slot, not the empty slot 0",
|
||||
);
|
||||
}
|
||||
|
||||
fn setup_take_from_foundation_game() -> GameState {
|
||||
let mut g = new_game();
|
||||
// Clear the board so we control the layout exactly.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Foundation slot 0: A♠, 2♠ (top = 2♠)
|
||||
let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap();
|
||||
f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||
f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true });
|
||||
// Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1)
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true,
|
||||
});
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_blocked_by_default() {
|
||||
let mut g = setup_take_from_foundation_game();
|
||||
assert!(!g.take_from_foundation);
|
||||
let err = g
|
||||
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MoveError::RuleViolation(_)),
|
||||
"expected RuleViolation, got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_allowed_when_enabled() {
|
||||
let mut g = setup_take_from_foundation_game();
|
||||
g.take_from_foundation = true;
|
||||
g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap();
|
||||
// Foundation slot 0 should now hold only the Ace.
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1);
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace);
|
||||
// The 2♠ should be on top of tableau 0 above the 3♥.
|
||||
let t0 = &g.piles[&PileType::Tableau(0)].cards;
|
||||
assert_eq!(t0.len(), 2);
|
||||
assert_eq!(t0[1].rank, Rank::Two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_rejects_illegal_tableau_placement() {
|
||||
let mut g = setup_take_from_foundation_game();
|
||||
g.take_from_foundation = true;
|
||||
// Tableau 1 is empty — only a King can go there; 2♠ is not a King.
|
||||
let err = g
|
||||
.move_cards(PileType::Foundation(0), PileType::Tableau(1), 1)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_rejects_count_gt_1() {
|
||||
let mut g = setup_take_from_foundation_game();
|
||||
g.take_from_foundation = true;
|
||||
let err = g
|
||||
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 2)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ async-trait = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# `keyring-core` is the typed Entry/Error API used by
|
||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||
@@ -26,6 +27,13 @@ tokio = { workspace = true }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring-core = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
|
||||
# process-wide JavaVM handle for JNI. Must be listed here so the
|
||||
# symbol resolves when cross-compiling for Android targets.
|
||||
bevy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
solitaire_sync = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/// Android Keystore token storage via JNI.
|
||||
///
|
||||
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
|
||||
/// device-bound key from the Android Keystore, and written atomically to
|
||||
/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
///
|
||||
/// The Keystore key survives app restarts but is destroyed on uninstall (or if
|
||||
/// the user changes biometric/lock credentials, in which case decryption fails
|
||||
/// and we surface `TokenError::KeychainUnavailable` so the caller knows to
|
||||
/// prompt re-login — identical semantics to a Linux box without Secret Service).
|
||||
///
|
||||
/// Only compiled and linked on `target_os = "android"`.
|
||||
use jni::{
|
||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||
JNIEnv, JavaVM,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::auth_tokens::TokenError;
|
||||
|
||||
const KEY_ALIAS: &str = "solitaire_quest_token_key";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TokenBlob {
|
||||
username: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JVM helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||
where
|
||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||
{
|
||||
let app = bevy::android::ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
|
||||
|
||||
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
||||
|
||||
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keystore key management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load the existing AES key from the Android Keystore, or generate one if it
|
||||
/// doesn't exist yet. Returns a local reference valid for the current JNI frame.
|
||||
fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result<JObject<'local>> {
|
||||
// KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); ks.load(null);
|
||||
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let ks = env
|
||||
.call_static_method(
|
||||
&ks_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||
&[ks_type.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
let null = JObject::null();
|
||||
env.call_method(
|
||||
&ks,
|
||||
"load",
|
||||
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||
&[JValue::Object(&null)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
// Key key = ks.getKey(ALIAS, null) — char[] password is null for hardware keys
|
||||
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
let null2 = JObject::null();
|
||||
let key = env
|
||||
.call_method(
|
||||
&ks,
|
||||
"getKey",
|
||||
"(Ljava/lang/String;[C)Ljava/security/Key;",
|
||||
&[alias.borrow(), JValue::Object(&null2)],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
if !env.is_same_object(&key, JObject::null())? {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// No key yet — generate AES-256 with GCM block mode.
|
||||
let builder_class =
|
||||
env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
|
||||
let alias2 = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
// PURPOSE_ENCRYPT | PURPOSE_DECRYPT = 1 | 2 = 3
|
||||
let purpose = JValueOwned::Int(3);
|
||||
let builder = env.new_object(
|
||||
&builder_class,
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[alias2.borrow(), purpose.borrow()],
|
||||
)?;
|
||||
|
||||
let str_class = env.find_class("java/lang/String")?;
|
||||
|
||||
// builder.setBlockModes(["GCM"])
|
||||
let gcm_str = env.new_string("GCM")?;
|
||||
let block_modes: JObjectArray = env.new_object_array(1, &str_class, &gcm_str)?;
|
||||
let block_modes_val = JValueOwned::Object(block_modes.into());
|
||||
let builder = env
|
||||
.call_method(
|
||||
&builder,
|
||||
"setBlockModes",
|
||||
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||
&[block_modes_val.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// builder.setEncryptionPaddings(["NoPadding"])
|
||||
let nopad_str = env.new_string("NoPadding")?;
|
||||
let enc_pads: JObjectArray = env.new_object_array(1, &str_class, &nopad_str)?;
|
||||
let enc_pads_val = JValueOwned::Object(enc_pads.into());
|
||||
let builder = env
|
||||
.call_method(
|
||||
&builder,
|
||||
"setEncryptionPaddings",
|
||||
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||
&[enc_pads_val.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// KeyGenParameterSpec spec = builder.build()
|
||||
let spec = env
|
||||
.call_method(
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroid/security/keystore/KeyGenParameterSpec;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// KeyGenerator kg = KeyGenerator.getInstance("AES", "AndroidKeyStore")
|
||||
let kg_class = env.find_class("javax/crypto/KeyGenerator")?;
|
||||
let aes = JValueOwned::from(env.new_string("AES")?);
|
||||
let ks_name = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let kg = env
|
||||
.call_static_method(
|
||||
&kg_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;",
|
||||
&[aes.borrow(), ks_name.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// kg.init(spec); return kg.generateKey()
|
||||
let spec_val = JValueOwned::Object(spec);
|
||||
env.call_method(
|
||||
&kg,
|
||||
"init",
|
||||
"(Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||
&[spec_val.borrow()],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
env.call_method(&kg, "generateKey", "()Ljavax/crypto/SecretKey;", &[])?
|
||||
.l()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AES-GCM encrypt / decrypt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
fn encrypt_gcm(
|
||||
env: &mut JNIEnv<'_>,
|
||||
key: &JObject<'_>,
|
||||
plaintext: &[u8],
|
||||
) -> jni::errors::Result<Vec<u8>> {
|
||||
let cipher_class = env.find_class("javax/crypto/Cipher")?;
|
||||
let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?);
|
||||
let cipher = env
|
||||
.call_static_method(
|
||||
&cipher_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||
&[transform.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// cipher.init(Cipher.ENCRYPT_MODE=1, key)
|
||||
let mode = JValueOwned::Int(1);
|
||||
env.call_method(
|
||||
&cipher,
|
||||
"init",
|
||||
"(ILjava/security/Key;)V",
|
||||
&[mode.borrow(), JValue::Object(key)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
// IV is generated by Android's provider; read it back after init.
|
||||
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||
// SAFETY: the method signature guarantees a byte array return.
|
||||
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
||||
let iv = env.convert_byte_array(&iv_arr)?;
|
||||
|
||||
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||
let pt_val = JValueOwned::Object(pt_arr.into());
|
||||
let ct_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||
|
||||
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||
out.extend_from_slice(&iv);
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Expects `data` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
fn decrypt_gcm(
|
||||
env: &mut JNIEnv<'_>,
|
||||
key: &JObject<'_>,
|
||||
data: &[u8],
|
||||
) -> jni::errors::Result<Vec<u8>> {
|
||||
let (iv, ciphertext) = data.split_at(12);
|
||||
|
||||
let cipher_class = env.find_class("javax/crypto/Cipher")?;
|
||||
let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?);
|
||||
let cipher = env
|
||||
.call_static_method(
|
||||
&cipher_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||
&[transform.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// GCMParameterSpec spec = new GCMParameterSpec(128, iv)
|
||||
let spec_class = env.find_class("javax/crypto/spec/GCMParameterSpec")?;
|
||||
let tag_len = JValueOwned::Int(128);
|
||||
let iv_arr = env.byte_array_from_slice(iv)?;
|
||||
let iv_val = JValueOwned::Object(iv_arr.into());
|
||||
let spec = env.new_object(
|
||||
&spec_class,
|
||||
"(I[B)V",
|
||||
&[tag_len.borrow(), iv_val.borrow()],
|
||||
)?;
|
||||
|
||||
// cipher.init(Cipher.DECRYPT_MODE=2, key, spec)
|
||||
let mode = JValueOwned::Int(2);
|
||||
let spec_val = JValueOwned::Object(spec);
|
||||
env.call_method(
|
||||
&cipher,
|
||||
"init",
|
||||
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||
&[mode.borrow(), JValue::Object(key), spec_val.borrow()],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
let ct_arr = env.byte_array_from_slice(ciphertext)?;
|
||||
let ct_val = JValueOwned::Object(ct_arr.into());
|
||||
let pt_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
||||
env.convert_byte_array(&pt_arr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn token_file_path() -> Option<PathBuf> {
|
||||
crate::platform::data_dir().map(|d| d.join("auth_tokens.bin"))
|
||||
}
|
||||
|
||||
fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
if !path.exists() {
|
||||
return Err(TokenError::NotFound(String::new()));
|
||||
}
|
||||
std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
|
||||
}
|
||||
|
||||
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, data)
|
||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||
}
|
||||
|
||||
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
|
||||
let data = read_file_bytes().map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()),
|
||||
other => other,
|
||||
})?;
|
||||
|
||||
if data.len() < 12 {
|
||||
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
|
||||
}
|
||||
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
|
||||
let blob: TokenBlob = serde_json::from_slice(&plaintext)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?;
|
||||
|
||||
if blob.username != username {
|
||||
return Err(TokenError::NotFound(username.to_string()));
|
||||
}
|
||||
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — mirrors auth_tokens desktop surface exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Encrypt and store `access_token` and `refresh_token` for `username`.
|
||||
///
|
||||
/// Overwrites any previously stored tokens.
|
||||
pub fn store_tokens(
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
let blob = TokenBlob {
|
||||
username: username.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.to_string(),
|
||||
};
|
||||
let plaintext = serde_json::to_vec(&blob)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
|
||||
|
||||
let encrypted = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
encrypt_gcm(env, &key, &plaintext)
|
||||
})?;
|
||||
|
||||
write_file_bytes(&encrypted)
|
||||
}
|
||||
|
||||
/// Return the stored access token for `username`.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
load_blob(username).map(|b| b.access_token)
|
||||
}
|
||||
|
||||
/// Return the stored refresh token for `username`.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
load_blob(username).map(|b| b.refresh_token)
|
||||
}
|
||||
|
||||
/// Delete stored tokens and remove the Keystore key for `username`.
|
||||
///
|
||||
/// Missing file or missing Keystore entry are silently ignored.
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
if let Some(path) = token_file_path() {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||
with_jvm(|env| {
|
||||
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let ks = env
|
||||
.call_static_method(
|
||||
&ks_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||
&[ks_type.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
let null = JObject::null();
|
||||
env.call_method(
|
||||
&ks,
|
||||
"load",
|
||||
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||
&[JValue::Object(&null)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
|
||||
.v()
|
||||
})
|
||||
}
|
||||
@@ -131,35 +131,29 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Android stub — same public API, always returns KeychainUnavailable.
|
||||
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
||||
// effect is "session login required every launch", same as a Linux
|
||||
// box without Secret Service.
|
||||
// Android — delegate to the JNI Keystore bridge in android_keystore.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn store_tokens(
|
||||
_username: &str,
|
||||
_access_token: &str,
|
||||
_refresh_token: &str,
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
crate::android_keystore::store_tokens(username, access_token, refresh_token)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
crate::android_keystore::load_access_token(username)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
crate::android_keystore::load_refresh_token(username)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
crate::android_keystore::delete_tokens(username)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
|
||||
0xDDDD_EEEE_FFFF_0000,
|
||||
0x0101_0101_0101_0101,
|
||||
0xA1B2_C3D4_E5F6_0718,
|
||||
// Generated by solitaire_assetgen::gen_seeds (start=0xCAFEBABE00000000, count=75, date=2026-05-09)
|
||||
0xCAFE_BABE_0000_0000,
|
||||
0xCAFE_BABE_0000_0002,
|
||||
0xCAFE_BABE_0000_0004,
|
||||
0xCAFE_BABE_0000_0008,
|
||||
0xCAFE_BABE_0000_000B,
|
||||
0xCAFE_BABE_0000_000D,
|
||||
0xCAFE_BABE_0000_000E,
|
||||
0xCAFE_BABE_0000_0010,
|
||||
0xCAFE_BABE_0000_0011,
|
||||
0xCAFE_BABE_0000_0014,
|
||||
0xCAFE_BABE_0000_0016,
|
||||
0xCAFE_BABE_0000_0019,
|
||||
0xCAFE_BABE_0000_001A,
|
||||
0xCAFE_BABE_0000_001F,
|
||||
0xCAFE_BABE_0000_0020,
|
||||
0xCAFE_BABE_0000_0021,
|
||||
0xCAFE_BABE_0000_0024,
|
||||
0xCAFE_BABE_0000_0025,
|
||||
0xCAFE_BABE_0000_0027,
|
||||
0xCAFE_BABE_0000_002B,
|
||||
0xCAFE_BABE_0000_002D,
|
||||
0xCAFE_BABE_0000_0030,
|
||||
0xCAFE_BABE_0000_0034,
|
||||
0xCAFE_BABE_0000_0036,
|
||||
0xCAFE_BABE_0000_003A,
|
||||
0xCAFE_BABE_0000_003B,
|
||||
0xCAFE_BABE_0000_003D,
|
||||
0xCAFE_BABE_0000_0042,
|
||||
0xCAFE_BABE_0000_0043,
|
||||
0xCAFE_BABE_0000_0044,
|
||||
0xCAFE_BABE_0000_004C,
|
||||
0xCAFE_BABE_0000_004D,
|
||||
0xCAFE_BABE_0000_004F,
|
||||
0xCAFE_BABE_0000_0050,
|
||||
0xCAFE_BABE_0000_0051,
|
||||
0xCAFE_BABE_0000_0054,
|
||||
0xCAFE_BABE_0000_0055,
|
||||
0xCAFE_BABE_0000_0056,
|
||||
0xCAFE_BABE_0000_0059,
|
||||
0xCAFE_BABE_0000_005B,
|
||||
0xCAFE_BABE_0000_005C,
|
||||
0xCAFE_BABE_0000_005E,
|
||||
0xCAFE_BABE_0000_0060,
|
||||
0xCAFE_BABE_0000_0062,
|
||||
0xCAFE_BABE_0000_0064,
|
||||
0xCAFE_BABE_0000_0067,
|
||||
0xCAFE_BABE_0000_0069,
|
||||
0xCAFE_BABE_0000_006A,
|
||||
0xCAFE_BABE_0000_006B,
|
||||
0xCAFE_BABE_0000_006C,
|
||||
0xCAFE_BABE_0000_006D,
|
||||
0xCAFE_BABE_0000_006E,
|
||||
0xCAFE_BABE_0000_006F,
|
||||
0xCAFE_BABE_0000_0072,
|
||||
0xCAFE_BABE_0000_0073,
|
||||
0xCAFE_BABE_0000_0074,
|
||||
0xCAFE_BABE_0000_0079,
|
||||
0xCAFE_BABE_0000_007A,
|
||||
0xCAFE_BABE_0000_007D,
|
||||
0xCAFE_BABE_0000_007E,
|
||||
0xCAFE_BABE_0000_007F,
|
||||
0xCAFE_BABE_0000_0082,
|
||||
0xCAFE_BABE_0000_0083,
|
||||
0xCAFE_BABE_0000_0084,
|
||||
0xCAFE_BABE_0000_0085,
|
||||
0xCAFE_BABE_0000_0089,
|
||||
0xCAFE_BABE_0000_008A,
|
||||
0xCAFE_BABE_0000_008D,
|
||||
0xCAFE_BABE_0000_008E,
|
||||
0xCAFE_BABE_0000_0090,
|
||||
0xCAFE_BABE_0000_0094,
|
||||
0xCAFE_BABE_0000_0095,
|
||||
0xCAFE_BABE_0000_0098,
|
||||
0xCAFE_BABE_0000_0099,
|
||||
0xCAFE_BABE_0000_009F,
|
||||
];
|
||||
|
||||
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Pre-verified seed catalogs for each [`DifficultyLevel`] tier.
|
||||
//!
|
||||
//! Each slice contains seeds that are provably winnable in Draw-One mode and
|
||||
//! that required a specific solver-budget range to solve — the **smallest**
|
||||
//! budget that returns `Winnable` determines the tier. See
|
||||
//! `solitaire_assetgen/src/bin/gen_difficulty_seeds.rs` for the generator.
|
||||
//!
|
||||
//! # Tiers and budget boundaries
|
||||
//!
|
||||
//! | Tier | move_budget | state_budget |
|
||||
//! |-------------|-------------|--------------|
|
||||
//! | Easy | 1 000 | 1 000 |
|
||||
//! | Medium | 5 000 | 5 000 |
|
||||
//! | Hard | 25 000 | 25 000 |
|
||||
//! | Expert | 100 000 | 100 000 |
|
||||
//! | Grandmaster | 200 000 | 200 000 |
|
||||
//!
|
||||
//! [`DifficultyLevel::Random`] has no catalog — the engine picks a system-time
|
||||
//! seed and skips verification.
|
||||
|
||||
use solitaire_core::game_state::DifficultyLevel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalogs (populated by gen_difficulty_seeds)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
||||
pub const EASY_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0001,
|
||||
0xD1FF_0000_0000_0002,
|
||||
0xD1FF_0000_0000_0007,
|
||||
0xD1FF_0000_0000_0008,
|
||||
0xD1FF_0000_0000_0009,
|
||||
0xD1FF_0000_0000_000E,
|
||||
0xD1FF_0000_0000_0013,
|
||||
0xD1FF_0000_0000_0015,
|
||||
0xD1FF_0000_0000_0018,
|
||||
0xD1FF_0000_0000_001D,
|
||||
0xD1FF_0000_0000_0021,
|
||||
0xD1FF_0000_0000_0022,
|
||||
0xD1FF_0000_0000_0026,
|
||||
0xD1FF_0000_0000_002C,
|
||||
0xD1FF_0000_0000_002E,
|
||||
0xD1FF_0000_0000_002F,
|
||||
0xD1FF_0000_0000_0035,
|
||||
0xD1FF_0000_0000_0036,
|
||||
0xD1FF_0000_0000_003C,
|
||||
0xD1FF_0000_0000_0045,
|
||||
0xD1FF_0000_0000_0046,
|
||||
0xD1FF_0000_0000_0048,
|
||||
0xD1FF_0000_0000_0049,
|
||||
0xD1FF_0000_0000_004D,
|
||||
0xD1FF_0000_0000_004F,
|
||||
0xD1FF_0000_0000_0050,
|
||||
0xD1FF_0000_0000_0051,
|
||||
0xD1FF_0000_0000_0053,
|
||||
0xD1FF_0000_0000_0054,
|
||||
0xD1FF_0000_0000_0057,
|
||||
0xD1FF_0000_0000_0058,
|
||||
0xD1FF_0000_0000_005A,
|
||||
0xD1FF_0000_0000_005B,
|
||||
0xD1FF_0000_0000_005C,
|
||||
0xD1FF_0000_0000_005D,
|
||||
0xD1FF_0000_0000_005F,
|
||||
0xD1FF_0000_0000_0061,
|
||||
0xD1FF_0000_0000_0062,
|
||||
0xD1FF_0000_0000_0063,
|
||||
0xD1FF_0000_0000_0069,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
||||
pub const MEDIUM_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0000,
|
||||
0xD1FF_0000_0000_0012,
|
||||
0xD1FF_0000_0000_0016,
|
||||
0xD1FF_0000_0000_001B,
|
||||
0xD1FF_0000_0000_001C,
|
||||
0xD1FF_0000_0000_0020,
|
||||
0xD1FF_0000_0000_002A,
|
||||
0xD1FF_0000_0000_0034,
|
||||
0xD1FF_0000_0000_003A,
|
||||
0xD1FF_0000_0000_0041,
|
||||
0xD1FF_0000_0000_0043,
|
||||
0xD1FF_0000_0000_0060,
|
||||
0xD1FF_0000_0000_006A,
|
||||
0xD1FF_0000_0000_006C,
|
||||
0xD1FF_0000_0000_006E,
|
||||
0xD1FF_0000_0000_006F,
|
||||
0xD1FF_0000_0000_0071,
|
||||
0xD1FF_0000_0000_0072,
|
||||
0xD1FF_0000_0000_0075,
|
||||
0xD1FF_0000_0000_0076,
|
||||
0xD1FF_0000_0000_007B,
|
||||
0xD1FF_0000_0000_007E,
|
||||
0xD1FF_0000_0000_0081,
|
||||
0xD1FF_0000_0000_0083,
|
||||
0xD1FF_0000_0000_0084,
|
||||
0xD1FF_0000_0000_0087,
|
||||
0xD1FF_0000_0000_0090,
|
||||
0xD1FF_0000_0000_0092,
|
||||
0xD1FF_0000_0000_0093,
|
||||
0xD1FF_0000_0000_0098,
|
||||
0xD1FF_0000_0000_0099,
|
||||
0xD1FF_0000_0000_009A,
|
||||
0xD1FF_0000_0000_009E,
|
||||
0xD1FF_0000_0000_00A5,
|
||||
0xD1FF_0000_0000_00A8,
|
||||
0xD1FF_0000_0000_00AA,
|
||||
0xD1FF_0000_0000_00AB,
|
||||
0xD1FF_0000_0000_00AE,
|
||||
0xD1FF_0000_0000_00AF,
|
||||
0xD1FF_0000_0000_00B0,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
||||
pub const HARD_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
|
||||
0xD1FF_0000_0000_001F,
|
||||
0xD1FF_0000_0000_0024,
|
||||
0xD1FF_0000_0000_0025,
|
||||
0xD1FF_0000_0000_0031,
|
||||
0xD1FF_0000_0000_0032,
|
||||
0xD1FF_0000_0000_003E,
|
||||
0xD1FF_0000_0000_004A,
|
||||
0xD1FF_0000_0000_006D,
|
||||
0xD1FF_0000_0000_0079,
|
||||
0xD1FF_0000_0000_007C,
|
||||
0xD1FF_0000_0000_0080,
|
||||
0xD1FF_0000_0000_008A,
|
||||
0xD1FF_0000_0000_0097,
|
||||
0xD1FF_0000_0000_00B1,
|
||||
0xD1FF_0000_0000_00B2,
|
||||
0xD1FF_0000_0000_00B3,
|
||||
0xD1FF_0000_0000_00B5,
|
||||
0xD1FF_0000_0000_00B7,
|
||||
0xD1FF_0000_0000_00B8,
|
||||
0xD1FF_0000_0000_00B9,
|
||||
0xD1FF_0000_0000_00BA,
|
||||
0xD1FF_0000_0000_00BB,
|
||||
0xD1FF_0000_0000_00BC,
|
||||
0xD1FF_0000_0000_00BD,
|
||||
0xD1FF_0000_0000_00C2,
|
||||
0xD1FF_0000_0000_00C3,
|
||||
0xD1FF_0000_0000_00C5,
|
||||
0xD1FF_0000_0000_00CC,
|
||||
0xD1FF_0000_0000_00CE,
|
||||
0xD1FF_0000_0000_00D1,
|
||||
0xD1FF_0000_0000_00D2,
|
||||
0xD1FF_0000_0000_00D6,
|
||||
0xD1FF_0000_0000_00D7,
|
||||
0xD1FF_0000_0000_00DC,
|
||||
0xD1FF_0000_0000_00DF,
|
||||
0xD1FF_0000_0000_00E0,
|
||||
0xD1FF_0000_0000_00E1,
|
||||
0xD1FF_0000_0000_00E4,
|
||||
0xD1FF_0000_0000_00E6,
|
||||
0xD1FF_0000_0000_00E7,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
||||
pub const EXPERT_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0006,
|
||||
0xD1FF_0000_0000_000B,
|
||||
0xD1FF_0000_0000_0019,
|
||||
0xD1FF_0000_0000_0082,
|
||||
0xD1FF_0000_0000_00CB,
|
||||
0xD1FF_0000_0000_00D5,
|
||||
0xD1FF_0000_0000_00D8,
|
||||
0xD1FF_0000_0000_00E8,
|
||||
0xD1FF_0000_0000_00EA,
|
||||
0xD1FF_0000_0000_00EB,
|
||||
0xD1FF_0000_0000_00EC,
|
||||
0xD1FF_0000_0000_00ED,
|
||||
0xD1FF_0000_0000_00F2,
|
||||
0xD1FF_0000_0000_00F3,
|
||||
0xD1FF_0000_0000_00F4,
|
||||
0xD1FF_0000_0000_00FE,
|
||||
0xD1FF_0000_0000_00FF,
|
||||
0xD1FF_0000_0000_0102,
|
||||
0xD1FF_0000_0000_0103,
|
||||
0xD1FF_0000_0000_0104,
|
||||
0xD1FF_0000_0000_0105,
|
||||
0xD1FF_0000_0000_0106,
|
||||
0xD1FF_0000_0000_0109,
|
||||
0xD1FF_0000_0000_010B,
|
||||
0xD1FF_0000_0000_010C,
|
||||
0xD1FF_0000_0000_0110,
|
||||
0xD1FF_0000_0000_0113,
|
||||
0xD1FF_0000_0000_0114,
|
||||
0xD1FF_0000_0000_011B,
|
||||
0xD1FF_0000_0000_011C,
|
||||
0xD1FF_0000_0000_011E,
|
||||
0xD1FF_0000_0000_0120,
|
||||
0xD1FF_0000_0000_0121,
|
||||
0xD1FF_0000_0000_0122,
|
||||
0xD1FF_0000_0000_0123,
|
||||
0xD1FF_0000_0000_0124,
|
||||
0xD1FF_0000_0000_0126,
|
||||
0xD1FF_0000_0000_012B,
|
||||
0xD1FF_0000_0000_012C,
|
||||
0xD1FF_0000_0000_012E,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
||||
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0027,
|
||||
0xD1FF_0000_0000_00A0,
|
||||
0xD1FF_0000_0000_00C4,
|
||||
0xD1FF_0000_0000_00D4,
|
||||
0xD1FF_0000_0000_00DE,
|
||||
0xD1FF_0000_0000_00F9,
|
||||
0xD1FF_0000_0000_0107,
|
||||
0xD1FF_0000_0000_0108,
|
||||
0xD1FF_0000_0000_0130,
|
||||
0xD1FF_0000_0000_0132,
|
||||
0xD1FF_0000_0000_0133,
|
||||
0xD1FF_0000_0000_0134,
|
||||
0xD1FF_0000_0000_0135,
|
||||
0xD1FF_0000_0000_0137,
|
||||
0xD1FF_0000_0000_0139,
|
||||
0xD1FF_0000_0000_013A,
|
||||
0xD1FF_0000_0000_013D,
|
||||
0xD1FF_0000_0000_013F,
|
||||
0xD1FF_0000_0000_0140,
|
||||
0xD1FF_0000_0000_0141,
|
||||
0xD1FF_0000_0000_0142,
|
||||
0xD1FF_0000_0000_0143,
|
||||
0xD1FF_0000_0000_0145,
|
||||
0xD1FF_0000_0000_0146,
|
||||
0xD1FF_0000_0000_014A,
|
||||
0xD1FF_0000_0000_014B,
|
||||
0xD1FF_0000_0000_014C,
|
||||
0xD1FF_0000_0000_014D,
|
||||
0xD1FF_0000_0000_014F,
|
||||
0xD1FF_0000_0000_0150,
|
||||
0xD1FF_0000_0000_0151,
|
||||
0xD1FF_0000_0000_0152,
|
||||
0xD1FF_0000_0000_0153,
|
||||
0xD1FF_0000_0000_0157,
|
||||
0xD1FF_0000_0000_0158,
|
||||
0xD1FF_0000_0000_015B,
|
||||
0xD1FF_0000_0000_015C,
|
||||
0xD1FF_0000_0000_015E,
|
||||
0xD1FF_0000_0000_0162,
|
||||
0xD1FF_0000_0000_0164,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Type alias for the catalog lookup return: a static slice or `None` for `Random`.
|
||||
pub type DifficultySeeds = Option<&'static [u64]>;
|
||||
|
||||
/// Return the seed catalog for `level`, or `None` for `Random` (caller must
|
||||
/// use a system-time seed instead).
|
||||
pub fn seeds_for(level: DifficultyLevel) -> DifficultySeeds {
|
||||
match level {
|
||||
DifficultyLevel::Easy => Some(EASY_SEEDS),
|
||||
DifficultyLevel::Medium => Some(MEDIUM_SEEDS),
|
||||
DifficultyLevel::Hard => Some(HARD_SEEDS),
|
||||
DifficultyLevel::Expert => Some(EXPERT_SEEDS),
|
||||
DifficultyLevel::Grandmaster => Some(GRANDMASTER_SEEDS),
|
||||
DifficultyLevel::Random => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_difficulty_seeds_are_unique() {
|
||||
let all: Vec<u64> = [
|
||||
EASY_SEEDS,
|
||||
MEDIUM_SEEDS,
|
||||
HARD_SEEDS,
|
||||
EXPERT_SEEDS,
|
||||
GRANDMASTER_SEEDS,
|
||||
]
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().copied())
|
||||
.collect();
|
||||
|
||||
let mut sorted = all.clone();
|
||||
sorted.sort_unstable();
|
||||
let before = sorted.len();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_for_random_returns_none() {
|
||||
assert!(seeds_for(DifficultyLevel::Random).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_for_non_random_returns_some() {
|
||||
for level in [
|
||||
DifficultyLevel::Easy,
|
||||
DifficultyLevel::Medium,
|
||||
DifficultyLevel::Hard,
|
||||
DifficultyLevel::Expert,
|
||||
DifficultyLevel::Grandmaster,
|
||||
] {
|
||||
assert!(
|
||||
seeds_for(level).is_some(),
|
||||
"{level:?} should return Some catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,6 @@ pub trait SyncProvider: Send + Sync {
|
||||
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(())
|
||||
}
|
||||
/// Fetch the global leaderboard from this backend. Returns an empty list
|
||||
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
|
||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||
@@ -83,9 +79,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
fn is_authenticated(&self) -> bool {
|
||||
(**self).is_authenticated()
|
||||
}
|
||||
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
|
||||
(**self).mirror_achievement(id).await
|
||||
}
|
||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||
(**self).fetch_leaderboard().await
|
||||
}
|
||||
@@ -138,6 +131,9 @@ pub use weekly::{
|
||||
pub mod challenge;
|
||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
|
||||
pub mod difficulty_seeds;
|
||||
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
@@ -147,6 +143,9 @@ pub use settings::{
|
||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_keystore;
|
||||
|
||||
pub mod auth_tokens;
|
||||
pub use auth_tokens::{
|
||||
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||
@@ -164,5 +163,8 @@ pub use replay::{
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
pub mod matomo_client;
|
||||
pub use matomo_client::MatomoClient;
|
||||
|
||||
pub mod platform;
|
||||
pub use platform::data_dir;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Matomo HTTP Tracking API client.
|
||||
//!
|
||||
//! Buffers game-play events and flushes them via the Matomo bulk tracking
|
||||
//! endpoint. Errors are silently discarded — analytics must never affect
|
||||
//! gameplay or block the UI.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use reqwest::Client;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Sends game-play events to a self-hosted Matomo instance via the
|
||||
/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api).
|
||||
///
|
||||
/// Construct once per session and share via `Arc`. `event` is cheap and
|
||||
/// can be called from the Bevy main thread; `flush` is async and must be
|
||||
/// called from a background task.
|
||||
pub struct MatomoClient {
|
||||
tracking_url: String,
|
||||
site_id: u32,
|
||||
/// 16 hex-char visitor ID, stable for the lifetime of this client.
|
||||
visitor_id: String,
|
||||
uid: Option<String>,
|
||||
client: Client,
|
||||
/// Pre-encoded query strings, one per buffered event.
|
||||
pending: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MatomoClient {
|
||||
/// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`).
|
||||
pub fn new(base_url: impl AsRef<str>, site_id: u32, uid: Option<String>) -> Self {
|
||||
let base = base_url.as_ref().trim_end_matches('/');
|
||||
let tracking_url = format!("{}/matomo.php", base);
|
||||
// Take the lower 64 bits of a v4 UUID and format as 16 hex chars.
|
||||
let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64);
|
||||
Self {
|
||||
tracking_url,
|
||||
site_id,
|
||||
visitor_id,
|
||||
uid,
|
||||
client: Client::new(),
|
||||
pending: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer one Matomo custom event. Never blocks; never fails visibly.
|
||||
///
|
||||
/// When the buffer exceeds 100 events the oldest 50 are dropped to
|
||||
/// prevent unbounded memory growth during extended offline play.
|
||||
pub fn event(
|
||||
&self,
|
||||
category: &str,
|
||||
action: &str,
|
||||
name: Option<&str>,
|
||||
value: Option<f64>,
|
||||
) {
|
||||
let Ok(mut guard) = self.pending.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut qs = format!(
|
||||
"idsite={}&rec=1&apiv=1&send_image=0\
|
||||
&url=game%3A%2F%2Fsolitaire%2Fevent\
|
||||
&_id={}&e_c={}&e_a={}",
|
||||
self.site_id,
|
||||
self.visitor_id,
|
||||
url_encode(category),
|
||||
url_encode(action),
|
||||
);
|
||||
if let Some(n) = name {
|
||||
qs.push_str(&format!("&e_n={}", url_encode(n)));
|
||||
}
|
||||
if let Some(v) = value {
|
||||
qs.push_str(&format!("&e_v={v}"));
|
||||
}
|
||||
if let Some(uid) = &self.uid {
|
||||
qs.push_str(&format!("&uid={}", url_encode(uid)));
|
||||
}
|
||||
|
||||
guard.push(qs);
|
||||
if guard.len() > 100 {
|
||||
guard.drain(0..50);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint.
|
||||
///
|
||||
/// The buffer is drained *before* the HTTP call so events recorded during
|
||||
/// an in-flight flush are not lost. Network errors are silently discarded.
|
||||
pub async fn flush(&self) {
|
||||
let pending = {
|
||||
let Ok(mut guard) = self.pending.lock() else {
|
||||
return;
|
||||
};
|
||||
if guard.is_empty() {
|
||||
return;
|
||||
}
|
||||
std::mem::take(&mut *guard)
|
||||
};
|
||||
|
||||
let requests: Vec<String> = pending.into_iter().map(|qs| format!("?{qs}")).collect();
|
||||
let body = serde_json::json!({ "requests": requests });
|
||||
|
||||
let _ = self
|
||||
.client
|
||||
.post(&self.tracking_url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn url_encode(s: &str) -> String {
|
||||
s.chars()
|
||||
.flat_map(|c| match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
|
||||
vec![c]
|
||||
}
|
||||
c => format!("%{:02X}", c as u32).chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
@@ -49,7 +49,7 @@ pub enum SyncBackend {
|
||||
#[default]
|
||||
#[serde(rename = "local")]
|
||||
Local,
|
||||
/// Sync with a self-hosted Solitaire Quest server.
|
||||
/// Sync with a self-hosted Ferrous Solitaire server.
|
||||
#[serde(rename = "solitaire_server")]
|
||||
SolitaireServer {
|
||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||
@@ -224,6 +224,40 @@ pub struct Settings {
|
||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||
#[serde(default = "default_replay_move_interval_secs")]
|
||||
pub replay_move_interval_secs: f32,
|
||||
/// Last difficulty tier the player selected. `None` means the player has
|
||||
/// never used the difficulty picker. When `Some`, the difficulty section in
|
||||
/// the home overlay opens pre-expanded and highlights this tier. Older
|
||||
/// `settings.json` files written before this field existed deserialize
|
||||
/// cleanly to `None` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub last_difficulty: Option<DifficultyLevel>,
|
||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||
/// before submission. Older `settings.json` files written before this
|
||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub leaderboard_display_name: Option<String>,
|
||||
/// When `true`, the player may drag the top card of a completed foundation
|
||||
/// pile back onto a compatible tableau column — a non-standard house rule.
|
||||
/// Off by default. Older `settings.json` files deserialize cleanly to
|
||||
/// `false` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub take_from_foundation: bool,
|
||||
/// When `true`, anonymous game-play events (game start, game won, etc.)
|
||||
/// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
|
||||
/// Requires `matomo_url` to be set. Older `settings.json` files deserialize
|
||||
/// cleanly to `false` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub analytics_enabled: bool,
|
||||
/// Base URL of the Matomo instance to send events to, e.g.
|
||||
/// `"https://analytics.example.com"`. When `None` the analytics toggle has
|
||||
/// no effect. Older `settings.json` files deserialize cleanly to `None`.
|
||||
#[serde(default)]
|
||||
pub matomo_url: Option<String>,
|
||||
/// Matomo site ID assigned when the tracked site was created in Matomo.
|
||||
/// Defaults to `1` (the first site created in a fresh Matomo install).
|
||||
#[serde(default = "default_matomo_site_id")]
|
||||
pub matomo_site_id: u32,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -292,6 +326,10 @@ fn default_replay_move_interval_secs() -> f32 {
|
||||
0.45
|
||||
}
|
||||
|
||||
fn default_matomo_site_id() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. Below this the cards barely register visually before
|
||||
/// the next move fires; the cap keeps the playback legible.
|
||||
@@ -342,6 +380,12 @@ impl Default for Settings {
|
||||
winnable_deals_only: false,
|
||||
disable_smart_default_size: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
last_difficulty: None,
|
||||
leaderboard_display_name: None,
|
||||
take_from_foundation: false,
|
||||
analytics_enabled: false,
|
||||
matomo_url: None,
|
||||
matomo_site_id: default_matomo_site_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot {
|
||||
// Time Attack uses its own session-level scoring; a per-game best
|
||||
// wouldn't compose with the other modes' single-game numbers.
|
||||
GameMode::TimeAttack => {}
|
||||
// Difficulty games pool into the Classic best-score/time buckets per
|
||||
// the user's stats preference.
|
||||
GameMode::Difficulty(_) => {
|
||||
self.classic_best_score = self.classic_best_score.max(score_u32);
|
||||
self.classic_fastest_win_seconds =
|
||||
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
|
||||
}
|
||||
}
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! | Struct | Backend |
|
||||
//! |---|---|
|
||||
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
|
||||
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) |
|
||||
//! | [`SolitaireServerClient`] | Self-hosted Ferrous Solitaire server (JWT auth) |
|
||||
//!
|
||||
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
|
||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||
@@ -55,7 +55,7 @@ impl SyncProvider for LocalOnlyProvider {
|
||||
// SolitaireServerClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HTTP sync client for the self-hosted Solitaire Quest server.
|
||||
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
||||
///
|
||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||
/// client automatically attempts a token refresh and retries the request once
|
||||
@@ -83,18 +83,96 @@ impl SolitaireServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with a username + password and return `(access_token, refresh_token)`.
|
||||
///
|
||||
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||
/// The client's `username` field is used as the credential — the caller must
|
||||
/// construct the client with the correct username before calling this.
|
||||
pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/login", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"username": self.username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
Self::extract_auth_tokens(resp).await
|
||||
}
|
||||
|
||||
/// Register a new account with a username + password and return `(access_token, refresh_token)`.
|
||||
///
|
||||
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||
pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/register", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"username": self.username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
Self::extract_auth_tokens(resp).await
|
||||
}
|
||||
|
||||
/// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response.
|
||||
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let msg = body["error"]
|
||||
.as_str()
|
||||
.or_else(|| body["message"].as_str())
|
||||
.unwrap_or("authentication failed");
|
||||
return Err(if status == reqwest::StatusCode::CONFLICT {
|
||||
SyncError::Auth("username already taken".into())
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
SyncError::Auth("invalid credentials".into())
|
||||
} else if status == reqwest::StatusCode::BAD_REQUEST {
|
||||
SyncError::Auth(msg.to_string())
|
||||
} else {
|
||||
SyncError::Network(format!("server returned {status}"))
|
||||
});
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
let access = body["access_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing access_token".into()))?
|
||||
.to_string();
|
||||
let refresh = body["refresh_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))?
|
||||
.to_string();
|
||||
Ok((access, refresh))
|
||||
}
|
||||
|
||||
/// Attempt to refresh the access token using the stored refresh token.
|
||||
///
|
||||
/// On success the new access token is persisted to the OS keychain,
|
||||
/// replacing the previous one. The refresh token itself is unchanged.
|
||||
/// The server rotates refresh tokens on each call: the response includes a
|
||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||
/// to the OS keychain on success.
|
||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||
let refresh = load_refresh_token(&self.username)
|
||||
let old_refresh = load_refresh_token(&self.username)
|
||||
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/refresh", self.base_url))
|
||||
.json(&serde_json::json!({ "refresh_token": refresh }))
|
||||
.json(&serde_json::json!({ "refresh_token": old_refresh }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
@@ -112,9 +190,11 @@ impl SolitaireServerClient {
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
||||
|
||||
// store_tokens replaces both access and refresh; we keep the old
|
||||
// refresh token unchanged so its 30-day TTL is preserved.
|
||||
store_tokens(&self.username, new_access, &refresh)
|
||||
// Server rotates refresh tokens — store the new one.
|
||||
// Fall back to the old token if the field is absent (pre-rotation server).
|
||||
let new_refresh = body["refresh_token"].as_str().unwrap_or(&old_refresh);
|
||||
|
||||
store_tokens(&self.username, new_access, new_refresh)
|
||||
.map_err(|e| SyncError::Auth(e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -418,3 +418,56 @@ async fn pull_after_account_deletion_returns_default_or_error() {
|
||||
|
||||
let _ = delete_tokens(username);
|
||||
}
|
||||
|
||||
/// **Push retry on 401.**
|
||||
///
|
||||
/// Mirrors `jwt_refresh_on_401_succeeds` but for the `push()` path.
|
||||
/// We install an expired access token so the first push attempt returns 401,
|
||||
/// the client refreshes, and the retry push succeeds.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn push_retries_after_401_on_expired_access_token() {
|
||||
ensure_mock_keyring();
|
||||
|
||||
let base = spawn_test_server().await;
|
||||
let username = "rt_push_expiring";
|
||||
|
||||
let (_real_access, real_refresh) =
|
||||
register_user_raw(&base, username, "pushexpirepass1!").await;
|
||||
let user_id = decode_sub(&_real_access);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
kind: String,
|
||||
}
|
||||
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||
let expired_access = encode(
|
||||
&Header::default(),
|
||||
&Claims {
|
||||
sub: user_id.clone(),
|
||||
exp,
|
||||
kind: "access".into(),
|
||||
},
|
||||
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||
)
|
||||
.expect("failed to encode expired access token");
|
||||
|
||||
store_tokens(username, &expired_access, &real_refresh)
|
||||
.expect("storing tokens in mock keyring must succeed");
|
||||
|
||||
let client = SolitaireServerClient::new(&base, username);
|
||||
let payload = make_payload(&user_id, 17);
|
||||
|
||||
// Push: server returns 401, client refreshes, retries, succeeds.
|
||||
let push_resp = client
|
||||
.push(&payload)
|
||||
.await
|
||||
.expect("push must succeed after the client transparently refreshes the access token");
|
||||
assert_eq!(
|
||||
push_resp.merged.stats.games_played, 17,
|
||||
"merged games_played must reflect what was pushed after auto-refresh"
|
||||
);
|
||||
|
||||
let _ = delete_tokens(username);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
@@ -32,6 +33,9 @@ zip = { workspace = true }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
arboard = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -116,6 +116,7 @@ impl Plugin for AchievementPlugin {
|
||||
// achievements-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
@@ -139,6 +140,7 @@ impl Plugin for AchievementPlugin {
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
|
||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||
// `cinephile` the first time playback runs to natural completion.
|
||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||
@@ -531,9 +533,9 @@ fn spawn_achievements_screen(
|
||||
}
|
||||
|
||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "+ ")
|
||||
} else {
|
||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||
(TEXT_DISABLED, TEXT_DISABLED, "- ")
|
||||
};
|
||||
|
||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Matomo analytics plugin — buffers game-play events and flushes them to
|
||||
//! the configured Matomo instance in the background.
|
||||
//!
|
||||
//! Disabled by default (opt-in via Settings → Privacy). Only active when
|
||||
//! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::AsyncComputeTaskPool;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
|
||||
|
||||
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Holds the active Matomo client. `None` when the feature is disabled.
|
||||
#[derive(Resource)]
|
||||
pub struct AnalyticsResource {
|
||||
pub client: Option<Arc<MatomoClient>>,
|
||||
flush_timer: Timer,
|
||||
}
|
||||
|
||||
impl Default for AnalyticsResource {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client: None,
|
||||
flush_timer: Timer::from_seconds(60.0, TimerMode::Repeating),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers analytics systems. Add after `SettingsPlugin` in the app.
|
||||
pub struct AnalyticsPlugin;
|
||||
|
||||
impl Plugin for AnalyticsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AnalyticsResource>()
|
||||
.add_systems(Startup, init_analytics)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_settings_change,
|
||||
on_game_won,
|
||||
on_forfeit,
|
||||
on_new_game,
|
||||
on_achievement_unlocked,
|
||||
tick_flush_timer,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn init_analytics(settings: Res<SettingsResource>, mut analytics: ResMut<AnalyticsResource>) {
|
||||
analytics.client = client_for(&settings.0);
|
||||
}
|
||||
|
||||
fn react_to_settings_change(
|
||||
mut events: MessageReader<SettingsChangedEvent>,
|
||||
mut analytics: ResMut<AnalyticsResource>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
analytics.client = client_for(&ev.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_game_won(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
let Some(client) = analytics.client.clone() else {
|
||||
return;
|
||||
};
|
||||
for ev in wins.read() {
|
||||
client.event("Game", "Won", None, Some(ev.score as f64));
|
||||
fire_flush(client.clone(), &settings.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_forfeit(
|
||||
mut forfeits: MessageReader<ForfeitEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
let Some(client) = analytics.client.clone() else {
|
||||
return;
|
||||
};
|
||||
for _ev in forfeits.read() {
|
||||
client.event("Game", "Forfeit", None, None);
|
||||
fire_flush(client.clone(), &settings.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_new_game(
|
||||
mut requests: MessageReader<NewGameRequestEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
game: Res<GameStateResource>,
|
||||
) {
|
||||
let Some(client) = analytics.client.clone() else {
|
||||
return;
|
||||
};
|
||||
for ev in requests.read() {
|
||||
if !ev.confirmed {
|
||||
continue;
|
||||
}
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
client.event("Game", "Start", Some(mode_str(mode)), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_achievement_unlocked(
|
||||
mut achievements: MessageReader<AchievementUnlockedEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
) {
|
||||
let Some(client) = analytics.client.clone() else {
|
||||
return;
|
||||
};
|
||||
for ev in achievements.read() {
|
||||
client.event("Achievement", "Unlocked", Some(&ev.0.id), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_flush_timer(
|
||||
time: Res<Time>,
|
||||
mut analytics: ResMut<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
analytics.flush_timer.tick(time.delta());
|
||||
if !analytics.flush_timer.just_finished() {
|
||||
return;
|
||||
}
|
||||
if let Some(client) = analytics.client.clone() {
|
||||
fire_flush(client, &settings.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
|
||||
if !settings.analytics_enabled {
|
||||
return None;
|
||||
}
|
||||
let url = settings.matomo_url.as_deref()?;
|
||||
let uid = match &settings.sync_backend {
|
||||
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
|
||||
SyncBackend::Local => None,
|
||||
};
|
||||
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
|
||||
}
|
||||
|
||||
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
|
||||
AsyncComputeTaskPool::get()
|
||||
.spawn(async move {
|
||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
rt.block_on(client.flush());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn mode_str(mode: GameMode) -> &'static str {
|
||||
match mode {
|
||||
GameMode::Classic => "classic",
|
||||
GameMode::Zen => "zen",
|
||||
GameMode::Challenge => "challenge",
|
||||
GameMode::TimeAttack => "time_attack",
|
||||
GameMode::Difficulty(_) => "difficulty",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Android clipboard bridge via JNI.
|
||||
///
|
||||
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn set_text(text: &str) -> Result<(), String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{
|
||||
objects::{JObject, JValueOwned},
|
||||
JavaVM,
|
||||
};
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
||||
// valid for the lifetime of the process.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||
|
||||
(|| -> jni::errors::Result<()> {
|
||||
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||
let cm = env
|
||||
.call_method(
|
||||
&activity,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[svc_name.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// ClipData clip = ClipData.newPlainText("link", text)
|
||||
let label = JValueOwned::from(env.new_string("link")?);
|
||||
let java_text = JValueOwned::from(env.new_string(text)?);
|
||||
let clip_class = env.find_class("android/content/ClipData")?;
|
||||
let clip = env
|
||||
.call_static_method(
|
||||
&clip_class,
|
||||
"newPlainText",
|
||||
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||
&[label.borrow(), java_text.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// cm.setPrimaryClip(clip)
|
||||
let clip_val = JValueOwned::Object(clip);
|
||||
env.call_method(
|
||||
&cm,
|
||||
"setPrimaryClip",
|
||||
"(Landroid/content/ClipData;)V",
|
||||
&[clip_val.borrow()],
|
||||
)?
|
||||
.v()
|
||||
})()
|
||||
.map_err(|e| format!("clipboard JNI: {e}"))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! SVG builder for the Solitaire Quest application icon.
|
||||
//! SVG builder for the Ferrous Solitaire application icon.
|
||||
//!
|
||||
//! Renders the project's signature `▌RS` Terminal mark (the same
|
||||
//! cursor-block + monogram pair used on the splash boot-screen and
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Asset-loading infrastructure for runtime SVG rasterisation and the
|
||||
//! per-platform user-themes directory.
|
||||
//!
|
||||
//! See `CARD_PLAN.md` for the full multi-phase implementation plan.
|
||||
//! This module is the entry point for Phases 1 (SVG → `Image`) and 5
|
||||
//! (user-themes directory). Phase 3 will extend it further with custom
|
||||
//! `AssetSource` implementations for `embedded://` and `themes://`.
|
||||
//! Provides the SVG → `Image` loader and the `embedded://` / `themes://`
|
||||
//! custom `AssetSource` implementations used by the theme system.
|
||||
|
||||
pub mod card_face_svg;
|
||||
pub mod icon_svg;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
|
||||
//!
|
||||
//! The card-theme system (see `CARD_PLAN.md`) ships SVG sources both as
|
||||
//! The card-theme system ships SVG sources both as
|
||||
//! the embedded default theme and as user-supplied themes. Bevy 0.18 has
|
||||
//! no built-in SVG support, so this loader bridges `usvg` (parser) +
|
||||
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures
|
||||
|
||||
@@ -114,7 +114,7 @@ impl AnimationTuning {
|
||||
platform: InputPlatform::Touch,
|
||||
duration_scale: 0.75,
|
||||
overshoot_scale: 0.5,
|
||||
drag_threshold_px: 10.0,
|
||||
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
|
||||
drag_scale: 1.12,
|
||||
hover_scale: 1.0, // no hover affordance on touch
|
||||
hover_lerp_speed: 20.0,
|
||||
|
||||
@@ -35,13 +35,12 @@ use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC,
|
||||
TYPE_CAPTION, Z_STOCK_BADGE,
|
||||
TYPE_BODY, Z_STOCK_BADGE,
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
|
||||
/// Tighter fan for face-down cards in the tableau — just enough to show the stack.
|
||||
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||||
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
|
||||
/// don't need their full body shown — only the back-pattern strip is
|
||||
@@ -49,7 +48,12 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// when hit-testing tableau columns; any drift between this and the
|
||||
/// renderer creates a visible offset between the card face and where
|
||||
/// clicks land.
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||
///
|
||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must
|
||||
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||||
/// used at runtime, while this one is the minimum floor used by
|
||||
/// `update_tableau_fan_frac` when computing proportional updates.
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||
@@ -263,6 +267,23 @@ pub struct ShadowEntity;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardShadow;
|
||||
|
||||
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
|
||||
///
|
||||
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
|
||||
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
|
||||
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
|
||||
/// frame — gives every face-down card a visible perimeter.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardBackFrame;
|
||||
|
||||
/// Fill colour for the face-down card border frame. Medium gray so it reads as
|
||||
/// a neutral "edge" without competing with the suit colours on face-up cards.
|
||||
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.38, 0.38, 0.38);
|
||||
|
||||
/// Extra width/height (in world units) added to each side of the card to form
|
||||
/// the visible border. 3 world units ≈ 3 dp on a 1× screen.
|
||||
const CARD_BACK_FRAME_PADDING: f32 = 3.0;
|
||||
|
||||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||
/// shadow given whether its parent card is currently part of the dragged
|
||||
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||
@@ -318,6 +339,21 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
|
||||
/// back PNG has a visible perimeter against the dark felt.
|
||||
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
parent.spawn((
|
||||
CardBackFrame,
|
||||
Sprite {
|
||||
color: CARD_BACK_FRAME_COLOR,
|
||||
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||
///
|
||||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||
@@ -373,6 +409,9 @@ impl Plugin for CardPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_tableau_fan_frac
|
||||
.after(GameMutation)
|
||||
.before(sync_cards_on_change),
|
||||
sync_cards_on_change.after(GameMutation),
|
||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||
start_flip_anim.after(GameMutation),
|
||||
@@ -649,16 +688,25 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
DrawMode::DrawOne => 1_usize,
|
||||
DrawMode::DrawThree => 3_usize,
|
||||
};
|
||||
cards.len().saturating_sub(visible)
|
||||
// Render one extra card so that the card sliding off the waste
|
||||
// during a draw animation is still present in the world at z=0
|
||||
// (hidden under the stack) rather than vanishing mid-tween.
|
||||
cards.len().saturating_sub(visible + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut y_offset = 0.0_f32;
|
||||
let rendered_len = cards[render_start..].len();
|
||||
for (slot, card) in cards[render_start..].iter().enumerate() {
|
||||
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
|
||||
// Fan left→right; top card (last slot) is rightmost and playable.
|
||||
slot as f32 * layout.card_size.x * 0.28
|
||||
// When len > visible, slot 0 is a hidden buffer card kept at
|
||||
// x=0 to prevent a flash during the draw tween. When len ≤
|
||||
// visible (small pile), every card is visible and should fan
|
||||
// normally — no card is hidden, so the shift is 0.
|
||||
let visible = 3_usize;
|
||||
let hidden = rendered_len.saturating_sub(visible);
|
||||
slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -667,9 +715,9 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
out.push((card, pos, z));
|
||||
if is_tableau {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
layout.tableau_fan_frac
|
||||
} else {
|
||||
TABLEAU_FACEDOWN_FAN_FRAC
|
||||
layout.tableau_facedown_fan_frac
|
||||
};
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
@@ -706,6 +754,13 @@ fn spawn_card_entity(
|
||||
entity.with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
// Face-down cards get a thin contrasting border frame so the dark back
|
||||
// PNG reads as a distinct rectangle against the dark felt.
|
||||
if !card.face_up {
|
||||
entity.with_children(|b| {
|
||||
add_card_back_frame_child(b, layout.card_size);
|
||||
});
|
||||
}
|
||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||
if card_images.is_none() {
|
||||
@@ -781,6 +836,11 @@ fn update_card_entity(
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
if !card.face_up {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_back_frame_child(b, layout.card_size);
|
||||
});
|
||||
}
|
||||
if card_images.is_none() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -1424,7 +1484,7 @@ fn update_stock_empty_indicator(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge
|
||||
//
|
||||
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
|
||||
// Shows a small "N" chip pinned to the top-right corner of the stock pile so
|
||||
// the player can see how many cards remain before the next recycle. The
|
||||
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||
// the badge hides itself when the stock has zero cards — the two indicators
|
||||
@@ -1438,8 +1498,8 @@ fn update_stock_empty_indicator(
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||
|
||||
/// Returns the count of cards currently in the stock pile.
|
||||
///
|
||||
@@ -1484,7 +1544,7 @@ fn spawn_stock_count_badge(
|
||||
};
|
||||
let text_font = TextFont {
|
||||
font: font.cloned().unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
@@ -1502,7 +1562,7 @@ fn spawn_stock_count_badge(
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
StockCountBadgeText,
|
||||
Text2d::new(format!("·{count}")),
|
||||
Text2d::new(format!("{count}")),
|
||||
text_font,
|
||||
TextColor(STOCK_BADGE_FG),
|
||||
// Slightly above the chip background so the digits aren't
|
||||
@@ -1564,7 +1624,7 @@ fn update_stock_count_badge(
|
||||
if let Ok(badge_children) = children.get(entity) {
|
||||
for child in badge_children.iter() {
|
||||
if let Ok(mut text) = texts.get_mut(child) {
|
||||
let new = format!("·{count}");
|
||||
let new = format!("{count}");
|
||||
if text.0 != new {
|
||||
text.0 = new;
|
||||
}
|
||||
@@ -1629,13 +1689,20 @@ fn snap_cards_on_window_resize(
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
||||
>,
|
||||
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
|
||||
shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
|
||||
>,
|
||||
frame_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
|
||||
>,
|
||||
mut pile_markers: Query<
|
||||
(Entity, &PileMarker, &mut Sprite),
|
||||
(Without<CardEntity>, Without<CardShadow>),
|
||||
(Without<CardEntity>, Without<CardShadow>, Without<CardBackFrame>),
|
||||
>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
@@ -1665,6 +1732,7 @@ fn snap_cards_on_window_resize(
|
||||
entities,
|
||||
label_query,
|
||||
shadow_query,
|
||||
frame_query,
|
||||
);
|
||||
|
||||
apply_stock_empty_indicator(
|
||||
@@ -1691,7 +1759,7 @@ fn snap_cards_on_window_resize(
|
||||
///
|
||||
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||
/// retargeted relative to the previous card-size's position.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn resize_cards_in_place(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
@@ -1699,12 +1767,16 @@ fn resize_cards_in_place(
|
||||
card_images: Option<&CardImageSet>,
|
||||
mut entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
||||
>,
|
||||
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
|
||||
>,
|
||||
mut frame_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
|
||||
>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
@@ -1756,6 +1828,62 @@ fn resize_cards_in_place(
|
||||
font.font_size = new_font_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize every face-down border frame to match the new card size.
|
||||
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
|
||||
for mut frame_sprite in frame_query.iter_mut() {
|
||||
frame_sprite.custom_size = Some(frame_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
||||
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
||||
/// expands as the player reveals cards while staying within the window.
|
||||
///
|
||||
/// On fresh deal (max face-up depth = 1) the function returns early, leaving
|
||||
/// both fracs at the window-size-adaptive values that `compute_layout` already
|
||||
/// computed for the current viewport. Previously it overwrote the adaptive
|
||||
/// value with the desktop minimum (0.25) — the wrong behaviour on portrait
|
||||
/// phones where the adaptive value is much larger.
|
||||
fn update_tableau_fan_frac(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut layout: Option<ResMut<LayoutResource>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else { return; };
|
||||
let Some(layout) = layout.as_mut() else { return; };
|
||||
|
||||
let max_depth = (0..7_usize)
|
||||
.filter_map(|i| game.0.piles.get(&solitaire_core::pile::PileType::Tableau(i)))
|
||||
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let card_h = layout.0.card_size.y;
|
||||
let avail = layout.0.available_tableau_height;
|
||||
|
||||
// With ≤ 1 face-up card per column (fresh deal, or completely face-down
|
||||
// piles) the face-up fan fraction has no visible effect. Leave both fracs
|
||||
// at the adaptive values set by compute_layout rather than snapping them
|
||||
// to the desktop minimum.
|
||||
if max_depth <= 1 || card_h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let ideal = avail / ((max_depth - 1) as f32 * card_h);
|
||||
let max_frac = if card_h > 0.0 { avail / (12.0 * card_h) } else { TABLEAU_FAN_FRAC };
|
||||
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC));
|
||||
let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC);
|
||||
|
||||
if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 {
|
||||
layout.0.tableau_fan_frac = new_frac;
|
||||
}
|
||||
if (layout.0.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
|
||||
layout.0.tableau_facedown_fan_frac = new_facedown_frac;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1862,7 +1990,7 @@ mod tests {
|
||||
// At game start waste is empty, so all 52 cards are across stock + tableau.
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let layout =
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
assert_eq!(positions.len(), 52);
|
||||
}
|
||||
@@ -1882,7 +2010,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(waste_ids.len(), 3);
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Filter rendered positions to only waste cards (by card ID).
|
||||
@@ -1890,11 +2018,13 @@ mod tests {
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.collect();
|
||||
// Draw-One: only 1 waste card should be rendered regardless of pile depth.
|
||||
assert_eq!(waste_rendered.len(), 1);
|
||||
// The single rendered card must be the top (last) waste card.
|
||||
// Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to
|
||||
// prevent the evicted card from flashing during the draw tween).
|
||||
assert!(waste_rendered.len() <= 2, "Draw-One renders at most 2 waste cards");
|
||||
assert!(!waste_rendered.is_empty(), "at least the top waste card must be rendered");
|
||||
// The top (last) waste card must always be among the rendered cards.
|
||||
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
|
||||
assert_eq!(waste_rendered[0].0.id, top_id);
|
||||
assert!(waste_rendered.iter().any(|(c, _, _)| c.id == top_id), "top waste card must be rendered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1911,32 +2041,73 @@ mod tests {
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
waste_pile.iter().map(|c| c.id).collect();
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let mut waste_rendered: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.collect();
|
||||
// Draw-Three: at most 3 waste cards rendered.
|
||||
assert_eq!(waste_rendered.len(), 3);
|
||||
// Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to
|
||||
// prevent the evicted card from flashing during the draw tween).
|
||||
assert!(waste_rendered.len() <= 4, "Draw-Three renders at most 4 waste cards");
|
||||
assert!(waste_rendered.len() >= 3, "Draw-Three renders at least 3 waste cards when pile is deep enough");
|
||||
|
||||
// The three fanned cards must have strictly increasing X coordinates
|
||||
// (left = oldest visible, right = top/playable).
|
||||
// The three visible fanned cards (slots 1–3) must have strictly
|
||||
// increasing X coordinates. The hidden extra card at slot 0 sits at x=0.
|
||||
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
|
||||
for w in waste_rendered.windows(2) {
|
||||
assert!(w[1].1.x > w[0].1.x, "fanned waste cards must have distinct X positions");
|
||||
// The top 3 cards (after the hidden one) must be fanned.
|
||||
let visible = &waste_rendered[waste_rendered.len().saturating_sub(3)..];
|
||||
for w in visible.windows(2) {
|
||||
assert!(w[1].1.x >= w[0].1.x, "fanned waste cards must have non-decreasing X positions");
|
||||
}
|
||||
// Top card (rightmost) must be the last card in the waste pile.
|
||||
// Top card (rightmost by x) must be the last card in the waste pile.
|
||||
let top_id = waste_pile.last().unwrap().id;
|
||||
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
|
||||
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
|
||||
// pile was too small to have a buffer card, collapsing 2 visible cards
|
||||
// onto x=0 instead of fanning them.
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
// Draw exactly once — in Draw-Three mode with a full stock this gives
|
||||
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
|
||||
let _ = g.draw();
|
||||
let waste_pile = &g.piles[&PileType::Waste].cards;
|
||||
// We need exactly 2 or 3 waste cards to hit the small-pile path.
|
||||
// One draw in Draw-Three adds up to 3 cards; take the first 2 if needed.
|
||||
let count = waste_pile.len();
|
||||
assert!(count >= 2, "need at least 2 waste cards");
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
waste_pile.iter().map(|c| c.id).collect();
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let mut waste_rendered: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.collect();
|
||||
// All waste cards should be visible (no hidden buffer when len ≤ visible).
|
||||
assert_eq!(waste_rendered.len(), count, "all waste cards rendered when pile ≤ visible");
|
||||
|
||||
// Cards must be fanned with distinct x positions (or equal for 1-card).
|
||||
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
|
||||
if count >= 2 {
|
||||
let last = waste_rendered.last().unwrap();
|
||||
let second_last = &waste_rendered[waste_rendered.len() - 2];
|
||||
assert!(last.1.x > second_last.1.x, "top 2 waste cards must fan to distinct x positions");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let layout =
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Collect positions for Tableau(6) (should have 7 cards).
|
||||
@@ -2248,7 +2419,7 @@ mod tests {
|
||||
#[test]
|
||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
|
||||
@@ -2409,7 +2580,7 @@ mod tests {
|
||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||
// post-resize card width, so the in-place path is using the
|
||||
// refreshed Layout.
|
||||
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0));
|
||||
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||
assert!(
|
||||
(after - expected).abs() < 1e-3,
|
||||
@@ -2640,7 +2811,7 @@ mod tests {
|
||||
// First update inside `app()` runs the spawn path; run one more to
|
||||
// confirm the in-place update path is also stable.
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "·24");
|
||||
assert_eq!(stock_badge_text(&mut app), "24");
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
|
||||
}
|
||||
|
||||
@@ -2666,7 +2837,7 @@ mod tests {
|
||||
// initial 24) and assert the badge text follows.
|
||||
let mut app = app();
|
||||
// Sanity-check the starting count.
|
||||
assert_eq!(stock_badge_text(&mut app), "·24");
|
||||
assert_eq!(stock_badge_text(&mut app), "24");
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
@@ -2674,7 +2845,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "·23");
|
||||
assert_eq!(stock_badge_text(&mut app), "23");
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
|
||||
}
|
||||
|
||||
|
||||
@@ -604,7 +604,7 @@ mod tests {
|
||||
use crate::layout::compute_layout;
|
||||
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
||||
}
|
||||
@@ -624,7 +624,7 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.insert_resource(GameStateResource(game))
|
||||
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
|
||||
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true)))
|
||||
.insert_resource(DragState::default())
|
||||
.add_systems(Update, update_drop_target_overlays);
|
||||
app
|
||||
|
||||
@@ -581,6 +581,12 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<DailyExpiryWarningShown>()
|
||||
.0 = Some(today);
|
||||
// Flush any stale events from headless_app()'s initial update (the
|
||||
// double-buffer keeps them visible for one extra frame).
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<WarningToastEvent>>()
|
||||
.clear();
|
||||
app.update();
|
||||
let events = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
@@ -597,6 +603,9 @@ mod tests {
|
||||
.resource_mut::<ProgressResource>()
|
||||
.0
|
||||
.daily_challenge_last_completed = Some(today);
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<WarningToastEvent>>()
|
||||
.clear();
|
||||
app.update();
|
||||
let events = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Difficulty-tier game-start plugin.
|
||||
//!
|
||||
//! Handles [`StartDifficultyRequestEvent`] by picking the next seed from the
|
||||
//! appropriate pre-verified catalog in `solitaire_data::difficulty_seeds` and
|
||||
//! writing a [`NewGameRequestEvent`]. For [`DifficultyLevel::Random`] a
|
||||
//! system-time seed is used instead — the deal may or may not be winnable.
|
||||
//!
|
||||
//! # Catalog cycling
|
||||
//!
|
||||
//! Each tier maintains an independent cursor in [`DifficultyIndexResource`]
|
||||
//! that advances one step each time a game is started at that tier. The cursor
|
||||
//! wraps modulo the catalog length so players never run out of variety. The
|
||||
//! resource is *not* persisted — it resets to 0 on every launch, which is fine
|
||||
//! because the starting position is effectively random (player-chosen timing
|
||||
//! determines which seed in the 40-entry catalog they start at).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
||||
use solitaire_data::difficulty_seeds::seeds_for;
|
||||
|
||||
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-tier catalog cursors. Each value is the index of the **next** seed to
|
||||
/// deal from that tier's catalog. Wraps modulo the catalog length.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct DifficultyIndexResource {
|
||||
easy: usize,
|
||||
medium: usize,
|
||||
hard: usize,
|
||||
expert: usize,
|
||||
grandmaster: usize,
|
||||
}
|
||||
|
||||
impl DifficultyIndexResource {
|
||||
/// Advance the cursor for `level` and return the seed at the old position.
|
||||
/// Falls back to a system-time seed if the catalog is unexpectedly empty.
|
||||
pub fn next_seed(&mut self, level: DifficultyLevel) -> u64 {
|
||||
let Some(catalog) = seeds_for(level) else {
|
||||
return seed_from_system_time();
|
||||
};
|
||||
if catalog.is_empty() {
|
||||
return seed_from_system_time();
|
||||
}
|
||||
let cursor = match level {
|
||||
DifficultyLevel::Easy => &mut self.easy,
|
||||
DifficultyLevel::Medium => &mut self.medium,
|
||||
DifficultyLevel::Hard => &mut self.hard,
|
||||
DifficultyLevel::Expert => &mut self.expert,
|
||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
||||
};
|
||||
let seed = catalog[*cursor % catalog.len()];
|
||||
*cursor = cursor.wrapping_add(1);
|
||||
seed
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers all difficulty-mode systems and resources.
|
||||
pub struct DifficultyPlugin;
|
||||
|
||||
impl Plugin for DifficultyPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<DifficultyIndexResource>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
handle_difficulty_request.before(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolves `StartDifficultyRequestEvent` → catalog seed → `NewGameRequestEvent`.
|
||||
fn handle_difficulty_request(
|
||||
mut requests: MessageReader<StartDifficultyRequestEvent>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut index: ResMut<DifficultyIndexResource>,
|
||||
) {
|
||||
for ev in requests.read() {
|
||||
let seed = if ev.level == DifficultyLevel::Random {
|
||||
seed_from_system_time()
|
||||
} else {
|
||||
index.next_seed(ev.level)
|
||||
};
|
||||
|
||||
new_game.write(NewGameRequestEvent {
|
||||
seed: Some(seed),
|
||||
mode: Some(GameMode::Difficulty(ev.level)),
|
||||
confirmed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_from_system_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0xD1FF_0000_DEAD_BEEF)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_data::difficulty_seeds::{EASY_SEEDS, MEDIUM_SEEDS};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(DifficultyPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn fire_request(app: &mut App, level: DifficultyLevel) {
|
||||
app.world_mut()
|
||||
.write_message(StartDifficultyRequestEvent { level });
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn drain_new_game_events(app: &mut App) -> Vec<NewGameRequestEvent> {
|
||||
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
cursor.read(msgs).copied().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easy_request_dispatches_seed_from_easy_catalog() {
|
||||
let mut app = headless_app();
|
||||
fire_request(&mut app, DifficultyLevel::Easy);
|
||||
|
||||
let events = drain_new_game_events(&mut app);
|
||||
assert_eq!(events.len(), 1);
|
||||
let ev = &events[0];
|
||||
assert!(ev.seed.is_some());
|
||||
assert_eq!(ev.mode, Some(GameMode::Difficulty(DifficultyLevel::Easy)));
|
||||
assert!(!ev.confirmed);
|
||||
// Seed must come from the Easy catalog (non-empty catalog is the test
|
||||
// precondition — the catalog uniqueness test in difficulty_seeds.rs
|
||||
// guards integrity).
|
||||
if !EASY_SEEDS.is_empty() {
|
||||
assert!(
|
||||
EASY_SEEDS.contains(&ev.seed.unwrap()),
|
||||
"seed {:?} not in EASY_SEEDS",
|
||||
ev.seed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successive_easy_requests_cycle_through_catalog() {
|
||||
let mut app = headless_app();
|
||||
fire_request(&mut app, DifficultyLevel::Easy);
|
||||
fire_request(&mut app, DifficultyLevel::Easy);
|
||||
|
||||
let events = drain_new_game_events(&mut app);
|
||||
assert_eq!(events.len(), 2);
|
||||
// Two successive requests should return different seeds (assuming the
|
||||
// catalog has at least 2 entries — it has 40).
|
||||
if EASY_SEEDS.len() >= 2 {
|
||||
assert_ne!(
|
||||
events[0].seed, events[1].seed,
|
||||
"successive Easy requests should produce different seeds"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_request_dispatches_seed_from_medium_catalog() {
|
||||
let mut app = headless_app();
|
||||
fire_request(&mut app, DifficultyLevel::Medium);
|
||||
|
||||
let events = drain_new_game_events(&mut app);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0].mode,
|
||||
Some(GameMode::Difficulty(DifficultyLevel::Medium))
|
||||
);
|
||||
if !MEDIUM_SEEDS.is_empty() {
|
||||
assert!(MEDIUM_SEEDS.contains(&events[0].seed.unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_request_dispatches_some_seed_with_random_mode() {
|
||||
let mut app = headless_app();
|
||||
fire_request(&mut app, DifficultyLevel::Random);
|
||||
|
||||
let events = drain_new_game_events(&mut app);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(events[0].seed.is_some(), "Random should always produce Some(seed)");
|
||||
assert_eq!(
|
||||
events[0].mode,
|
||||
Some(GameMode::Difficulty(DifficultyLevel::Random))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_tier_cursors_are_independent() {
|
||||
let mut app = headless_app();
|
||||
fire_request(&mut app, DifficultyLevel::Easy);
|
||||
fire_request(&mut app, DifficultyLevel::Medium);
|
||||
|
||||
let events = drain_new_game_events(&mut app);
|
||||
assert_eq!(events.len(), 2);
|
||||
// Seeds from different catalogs should differ (they come from different
|
||||
// address ranges by construction of gen_difficulty_seeds).
|
||||
assert_ne!(
|
||||
events[0].seed, events[1].seed,
|
||||
"Easy and Medium should draw from independent catalogs"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,23 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ManualSyncRequestEvent;
|
||||
|
||||
/// Request to open the sync-server setup modal (Connect flow).
|
||||
/// Fired by the "Connect" button in the Settings sync section.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct SyncConfigureRequestEvent;
|
||||
|
||||
/// Request to disconnect from the current sync backend, clear stored
|
||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||
/// button in the Settings sync section.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct SyncLogoutRequestEvent;
|
||||
|
||||
/// Request to open the account-deletion confirmation modal. Fired by the
|
||||
/// "Delete Account" button in the Settings sync section (visible only when
|
||||
/// a server backend is configured). Consumed by `SyncSetupPlugin`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct DeleteAccountRequestEvent;
|
||||
|
||||
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
||||
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
||||
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
||||
@@ -172,6 +189,23 @@ pub struct StartTimeAttackRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct StartDailyChallengeRequestEvent;
|
||||
|
||||
/// Request to open the Play-by-Seed dialog. Fired by the Home overlay
|
||||
/// "Play by Seed" mode card. The handler in `play_by_seed_plugin` spawns
|
||||
/// a numeric-input modal where the player types a decimal seed and
|
||||
/// optionally sees a solver-verified verdict before dealing.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct StartPlayBySeedRequestEvent;
|
||||
|
||||
/// Request to start a game at a specific difficulty tier. Fired by the
|
||||
/// difficulty section in the home overlay. The handler in `difficulty_plugin`
|
||||
/// picks a seed from the corresponding pre-verified catalog (or generates a
|
||||
/// random system-time seed for `DifficultyLevel::Random`) and writes a
|
||||
/// `NewGameRequestEvent`.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct StartDifficultyRequestEvent {
|
||||
pub level: solitaire_core::game_state::DifficultyLevel,
|
||||
}
|
||||
|
||||
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
|
||||
/// "Stats" row alongside the existing `S` accelerator.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
@@ -249,6 +283,15 @@ pub struct ForfeitEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitRequestEvent;
|
||||
|
||||
/// Fired when the player clicks "Scan for new themes" in Settings.
|
||||
///
|
||||
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
|
||||
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
|
||||
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
|
||||
/// fires [`InfoToastEvent`] messages to report results.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ScanThemesRequestEvent;
|
||||
|
||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use bevy::window::AppLifecycle;
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
@@ -200,6 +201,7 @@ impl Plugin for GamePlugin {
|
||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<AppLifecycle>()
|
||||
.add_systems(
|
||||
Update,
|
||||
poll_pending_new_game_seed.before(GameMutation),
|
||||
@@ -252,20 +254,42 @@ pub fn advance_elapsed(
|
||||
}
|
||||
|
||||
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
||||
/// the game is in progress (not won), not paused, and the launch /
|
||||
/// mode-picker Home modal isn't covering the board. Stops counting on
|
||||
/// win so the final time reflects how long the player took to solve
|
||||
/// the deal; stops while the pause overlay is open; stops while Home
|
||||
/// is up so the timer doesn't tick under the picker before the player
|
||||
/// has actually committed to a deal.
|
||||
/// the game is in progress (not won), not paused, and no blocking modal
|
||||
/// (Home picker or first-run onboarding) is covering the board. Stops
|
||||
/// counting on win so the final time reflects how long the player took;
|
||||
/// stops while the pause overlay is open; stops while Home is up so the
|
||||
/// timer doesn't tick before the player commits to a deal; stops while
|
||||
/// the onboarding modal is visible so a new player's first-game time
|
||||
/// isn't inflated by reading the tutorial.
|
||||
///
|
||||
/// On Android the first frame after the app is resumed from background
|
||||
/// can carry a very large `delta_secs` equal to the entire suspension
|
||||
/// period. `skip_next_delta` is set to `true` on `WillSuspend` /
|
||||
/// `Suspended` so that frame's delta is dropped instead of applied.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tick_elapsed_time(
|
||||
time: Res<Time>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut accumulator: Local<f32>,
|
||||
mut skip_next_delta: Local<bool>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||
onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillSuspend | AppLifecycle::Suspended) {
|
||||
*skip_next_delta = true;
|
||||
}
|
||||
}
|
||||
if paused.is_some_and(|p| p.0)
|
||||
|| !home_screens.is_empty()
|
||||
|| !onboarding_screens.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if *skip_next_delta {
|
||||
*skip_next_delta = false;
|
||||
return;
|
||||
}
|
||||
let is_won = game.0.is_won;
|
||||
@@ -466,6 +490,9 @@ fn handle_new_game(
|
||||
let chosen_seed = initial_seed;
|
||||
|
||||
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
|
||||
if let Some(s) = settings.as_ref() {
|
||||
game.0.take_from_foundation = s.0.take_from_foundation;
|
||||
}
|
||||
// Reset the in-flight replay buffer — a fresh deal starts with
|
||||
// an empty move list. The previously saved replay on disk
|
||||
// (latest_replay.json) is preserved until the player wins again.
|
||||
@@ -989,17 +1016,29 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
let mut sources: Vec<Card> = Vec::new();
|
||||
for ty in [PileType::Stock, PileType::Waste] {
|
||||
if let Some(p) = game.piles.get(&ty) {
|
||||
sources.extend(p.cards.iter().cloned());
|
||||
}
|
||||
// Drawing from a non-empty stock, and recycling a non-empty waste back to
|
||||
// stock, are always legal moves in standard Klondike (unlimited recycles).
|
||||
// A game can only be genuinely stuck when both stock AND waste are exhausted.
|
||||
let stock_empty = game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
|
||||
let waste_empty = game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
|
||||
if !stock_empty || !waste_empty {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stock and waste exhausted — check whether any visible card can be placed.
|
||||
let mut sources: Vec<Card> = Vec::new();
|
||||
// Top waste card (waste is empty here, but included for completeness).
|
||||
if let Some(p) = game.piles.get(&PileType::Waste)
|
||||
&& let Some(top) = p.cards.last()
|
||||
{
|
||||
sources.push(top.clone());
|
||||
}
|
||||
// Any face-up card in a tableau column can be the base of a movable run.
|
||||
for i in 0..7_usize {
|
||||
if let Some(t) = game.piles.get(&PileType::Tableau(i))
|
||||
&& let Some(top) = t.cards.last().filter(|c| c.face_up)
|
||||
{
|
||||
sources.push(top.clone());
|
||||
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
|
||||
for card in t.cards.iter().filter(|c| c.face_up) {
|
||||
sources.push(card.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,9 +1103,11 @@ fn check_no_moves(
|
||||
}
|
||||
|
||||
if !moves_ok && !*already_fired {
|
||||
toast.write(InfoToastEvent(
|
||||
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
|
||||
));
|
||||
#[cfg(target_os = "android")]
|
||||
let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
|
||||
toast.write(InfoToastEvent(no_moves_msg.to_string()));
|
||||
*already_fired = true;
|
||||
// Only spawn the overlay if one does not already exist.
|
||||
if game_over_screens.is_empty() {
|
||||
@@ -1638,19 +1679,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_true_for_fresh_game() {
|
||||
// A fresh deal always contains at least one playable card —
|
||||
// typically several tableau→tableau opportunities plus any Aces
|
||||
// that surface as a tableau column's bottom card.
|
||||
// A fresh deal always has a non-empty stock (24 cards), so drawing
|
||||
// is always a legal move regardless of the initial face-up tableau cards.
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards() {
|
||||
// Reproduces Quat's softlock: stock has cards but no card anywhere
|
||||
// (stock or otherwise) can land on any pile. The previous heuristic
|
||||
// returned `true` here because stock was non-empty, so the game
|
||||
// sat there forever instead of declaring softlock.
|
||||
fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
|
||||
// Drawing from a non-empty stock is always a legal move in standard
|
||||
// Klondike (unlimited recycles), even if the drawn card cannot be
|
||||
// immediately placed. The game is only stuck when both stock AND waste
|
||||
// are exhausted and no visible card can be moved.
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
for slot in 0..4_u8 {
|
||||
@@ -1660,25 +1700,15 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
// Fill foundation 0 with Clubs A–10, leaving only J/Q/K of Clubs
|
||||
// as plausible foundation moves; load the stock with cards that
|
||||
// can't land on the empty tableau (anything but a King) and can't
|
||||
// extend foundation 0 (anything but Jack of Clubs).
|
||||
let stock = game.piles.get_mut(&PileType::Stock).unwrap();
|
||||
stock.cards.clear();
|
||||
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
|
||||
stock.cards.push(Card { id: 100 + r as u32, suit: Suit::Hearts, rank: r, face_up: false });
|
||||
}
|
||||
let foundation_zero = game.piles.get_mut(&PileType::Foundation(0)).unwrap();
|
||||
for r in [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
] {
|
||||
foundation_zero.cards.push(Card { id: r as u32, suit: Suit::Clubs, rank: r, face_up: true });
|
||||
}
|
||||
// Stock is non-empty, so drawing is always a valid move.
|
||||
assert!(
|
||||
!has_legal_moves(&game),
|
||||
"stock cards with no legal landing should count as softlock",
|
||||
has_legal_moves(&game),
|
||||
"non-empty stock means drawing is a legal move regardless of placement options",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1730,6 +1760,40 @@ mod tests {
|
||||
assert!(!has_legal_moves(&game), "Two of Clubs with empty board has no legal move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
|
||||
// Regression: the bug only checked t.cards.last() (top face-up card).
|
||||
// If the only legal move involves a face-up card that is NOT the top
|
||||
// card of its column the previous code would return false (softlock)
|
||||
// even though the player can still move that run.
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
// Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
|
||||
// King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
|
||||
// only legal tableau move, and that move targets the Queen which is non-top.
|
||||
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.push(Card { id: 10, suit: Suit::Spades, rank: Rank::Queen, face_up: true });
|
||||
t0.cards.push(Card { id: 11, suit: Suit::Hearts, rank: Rank::Jack, face_up: true });
|
||||
|
||||
let t1 = game.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||||
t1.cards.push(Card { id: 12, suit: Suit::Diamonds, rank: Rank::King, face_up: true });
|
||||
|
||||
assert!(
|
||||
has_legal_moves(&game),
|
||||
"Queen (non-top face-up) should be detected as a valid move source onto King",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #57 — Confirm-new-game dialog tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -44,13 +44,19 @@ pub struct HelpPlugin;
|
||||
impl Plugin for HelpPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<HelpRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the help-scroll
|
||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||
// `MouseWheel` and `TouchInput` are emitted by Bevy's input
|
||||
// plugin under `DefaultPlugins`; register them explicitly so
|
||||
// scroll systems run cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
|
||||
(
|
||||
toggle_help_screen,
|
||||
handle_help_close_button,
|
||||
scroll_help_panel,
|
||||
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +135,36 @@ struct ControlSection {
|
||||
rows: &'static [ControlRow],
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlSection {
|
||||
title: "Touch",
|
||||
rows: &[
|
||||
ControlRow { keys: "Tap stock", description: "Draw from stock" },
|
||||
ControlRow { keys: "Drag card", description: "Move cards between piles" },
|
||||
ControlRow { keys: "Tap foundation area", description: "Auto-move top card to foundation" },
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "New Game",
|
||||
rows: &[
|
||||
ControlRow { keys: "New+", description: "Start a new Classic game" },
|
||||
ControlRow { keys: "Modes↓", description: "Pick Daily, Zen, Challenge, or Time Attack" },
|
||||
],
|
||||
},
|
||||
ControlSection {
|
||||
title: "HUD buttons",
|
||||
rows: &[
|
||||
ControlRow { keys: "←", description: "Undo last move" },
|
||||
ControlRow { keys: "||", description: "Pause / resume" },
|
||||
ControlRow { keys: "?", description: "This help screen" },
|
||||
ControlRow { keys: "→", description: "Show a hint" },
|
||||
ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlSection {
|
||||
title: "Gameplay",
|
||||
@@ -229,6 +265,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
padding: UiRect::bottom(Val::Px(96.0)),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
@@ -250,9 +287,8 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
// The hotkey rendered as a small chip with a border —
|
||||
// visual cue that it's a key reference, not part of
|
||||
// the description text.
|
||||
// Keyboard chip — suppressed on Android (no keyboard).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
line.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
use solitaire_data::save_settings_to;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
ToggleProfileRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
@@ -81,6 +81,27 @@ struct HomeDrawThreeButton;
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeScrollable;
|
||||
|
||||
/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that
|
||||
/// expands / collapses the difficulty tier chip row.
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeDifficultyToggle;
|
||||
|
||||
/// Marker on each difficulty tier chip inside the expanded difficulty
|
||||
/// section. The wrapped `DifficultyLevel` identifies which tier was
|
||||
/// clicked so the handler can fire `StartDifficultyRequestEvent`.
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeDifficultyChip(DifficultyLevel);
|
||||
|
||||
/// Whether the difficulty section is currently expanded. Toggled by
|
||||
/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen`
|
||||
/// to determine initial render state.
|
||||
///
|
||||
/// Initialised at plugin startup; `spawn_home_on_launch` upgrades it
|
||||
/// to `true` when `settings.last_difficulty` is already set so
|
||||
/// returning players see their tier pre-expanded.
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct DifficultyExpanded(pub bool);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private mode-card data shape
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,6 +117,7 @@ enum HomeMode {
|
||||
Zen,
|
||||
Challenge,
|
||||
TimeAttack,
|
||||
PlayBySeed,
|
||||
}
|
||||
|
||||
impl HomeMode {
|
||||
@@ -107,6 +129,7 @@ impl HomeMode {
|
||||
HomeMode::Zen => "Zen Mode",
|
||||
HomeMode::Challenge => "Challenge",
|
||||
HomeMode::TimeAttack => "Time Attack",
|
||||
HomeMode::PlayBySeed => "Play by Seed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +141,7 @@ impl HomeMode {
|
||||
HomeMode::Zen => "No timer, no score. Just the cards.",
|
||||
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
||||
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
|
||||
HomeMode::PlayBySeed => "Enter any number to play a specific deal.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,30 +150,25 @@ impl HomeMode {
|
||||
/// readability rather than visual fidelity. Swap to `Image` nodes
|
||||
/// when art lands; the rest of the tile layout doesn't change.
|
||||
///
|
||||
/// Picks are constrained to **card suits** (U+2660-2666) and basic
|
||||
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
|
||||
/// FiraMono-Medium face actually covers. Earlier choices in
|
||||
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
|
||||
/// missing-glyph rectangles because FiraMono's coverage there is
|
||||
/// minimal.
|
||||
/// Picks are constrained to **card suits** (U+2660-2666), the
|
||||
/// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed
|
||||
/// present in the bundled FiraMono-Medium face. The Geometric
|
||||
/// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in
|
||||
/// that range render as missing-glyph rectangles on Android.
|
||||
fn glyph(self) -> &'static str {
|
||||
match self {
|
||||
// Black club — card suit, the obvious solitaire mark.
|
||||
// Black club — card suit; the obvious solitaire mark.
|
||||
HomeMode::Classic => "\u{2663}",
|
||||
// Black diamond — Geometric Shapes; reads as the day's gem.
|
||||
HomeMode::Daily => "\u{25C6}",
|
||||
// White circle — Geometric Shapes; reads as the Zen enso.
|
||||
HomeMode::Zen => "\u{25CB}",
|
||||
// Black up-pointing triangle — Geometric Shapes; reads as
|
||||
// a mountain / a step up in difficulty.
|
||||
HomeMode::Challenge => "\u{25B2}",
|
||||
// Rightwards arrow — Arrows block (U+2190-21FF), a core
|
||||
// range every dev-oriented monospace font (FiraMono
|
||||
// included) ships. Reads as "go / fast-forward" for the
|
||||
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
|
||||
// ships ▲ (up triangle) but evidently not the sideways
|
||||
// siblings.
|
||||
// Black diamond suit — "gem of the day" reading.
|
||||
HomeMode::Daily => "\u{2666}",
|
||||
// Black heart suit — calm/warm; conveys the Zen mood.
|
||||
HomeMode::Zen => "\u{2665}",
|
||||
// Black spade suit — sharp/high-stakes; signals difficulty.
|
||||
HomeMode::Challenge => "\u{2660}",
|
||||
// Rightwards arrow — "go / fast-forward" for the timed mode.
|
||||
HomeMode::TimeAttack => "\u{2192}",
|
||||
// Number sign — ASCII; "a specific seed ID".
|
||||
HomeMode::PlayBySeed => "#",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +181,7 @@ impl HomeMode {
|
||||
HomeMode::Zen => "Z",
|
||||
HomeMode::Challenge => "X",
|
||||
HomeMode::TimeAttack => "T",
|
||||
HomeMode::PlayBySeed => "6",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,11 +253,14 @@ impl Plugin for HomePlugin {
|
||||
// Pre-mark the auto-show as already done in headless mode so the
|
||||
// gating system is a permanent no-op for tests.
|
||||
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
||||
.init_resource::<DifficultyExpanded>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<StartChallengeRequestEvent>()
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<StartPlayBySeedRequestEvent>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
@@ -245,13 +268,10 @@ impl Plugin for HomePlugin {
|
||||
// runs cleanly under MinimalPlugins headless tests too.
|
||||
.add_message::<MouseWheel>()
|
||||
// `.chain()` because several systems (M-toggle, card click,
|
||||
// cancel button, digit-key shortcut) all read the
|
||||
// `HomeScreen` entity and may queue a despawn on it in the
|
||||
// same tick. Bevy's parallel scheduler would otherwise let
|
||||
// two of them run simultaneously and double-despawn the
|
||||
// entity, panicking when the second command buffer is
|
||||
// applied. Chaining serialises these systems and keeps the
|
||||
// despawn deterministic.
|
||||
// cancel button, digit-key shortcut, difficulty handlers)
|
||||
// all read the `HomeScreen` entity and may queue a despawn
|
||||
// on it in the same tick. Chaining serialises these systems
|
||||
// and keeps the despawn deterministic.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -262,6 +282,8 @@ impl Plugin for HomePlugin {
|
||||
handle_home_cancel_button,
|
||||
handle_home_profile_chip,
|
||||
handle_home_draw_mode_buttons,
|
||||
handle_home_difficulty_toggle,
|
||||
handle_home_difficulty_chip_click,
|
||||
handle_home_digit_keys,
|
||||
)
|
||||
.chain(),
|
||||
@@ -306,6 +328,7 @@ fn spawn_home_on_launch(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||
) {
|
||||
if shown.0
|
||||
|| !splash.is_empty()
|
||||
@@ -316,6 +339,11 @@ fn spawn_home_on_launch(
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-expand the difficulty section when the player has a saved preference.
|
||||
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
|
||||
diff_expanded.0 = true;
|
||||
}
|
||||
|
||||
spawn_home_screen(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
@@ -324,6 +352,7 @@ fn spawn_home_on_launch(
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
diff_expanded.0,
|
||||
),
|
||||
);
|
||||
shown.0 = true;
|
||||
@@ -343,6 +372,7 @@ fn toggle_home_screen(
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
return;
|
||||
@@ -358,6 +388,7 @@ fn toggle_home_screen(
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
diff_expanded.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -373,6 +404,7 @@ fn build_home_context<'a>(
|
||||
settings: Option<&SettingsResource>,
|
||||
daily: Option<&DailyChallengeResource>,
|
||||
font_res: Option<&'a FontResource>,
|
||||
difficulty_expanded: bool,
|
||||
) -> HomeContext<'a> {
|
||||
let daily_today = daily.map(|d| {
|
||||
let completed_today = progress
|
||||
@@ -398,6 +430,8 @@ fn build_home_context<'a>(
|
||||
.map(|s| s.0.draw_mode.clone())
|
||||
.unwrap_or(DrawMode::DrawOne),
|
||||
font_res,
|
||||
difficulty_expanded,
|
||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +457,7 @@ fn handle_home_card_click(
|
||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||
@@ -457,6 +492,9 @@ fn handle_home_card_click(
|
||||
HomeMode::TimeAttack => {
|
||||
time_attack.write(StartTimeAttackRequestEvent);
|
||||
}
|
||||
HomeMode::PlayBySeed => {
|
||||
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal after dispatching the launch event.
|
||||
@@ -557,6 +595,7 @@ fn handle_home_draw_mode_buttons(
|
||||
stats: Option<Res<StatsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
@@ -600,10 +639,92 @@ fn handle_home_draw_mode_buttons(
|
||||
Some(settings),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
diff_expanded.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Difficulty section handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and
|
||||
/// repaint the Home modal so the chevron and chip row update. Mirrors
|
||||
/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in
|
||||
/// `spawn_difficulty_section` rather than scattered across mutation helpers.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_home_difficulty_toggle(
|
||||
mut commands: Commands,
|
||||
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
diff_expanded.0 = !diff_expanded.0;
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
spawn_home_screen(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
progress.as_deref(),
|
||||
stats.as_deref(),
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
diff_expanded.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Click on a difficulty tier chip — persist `last_difficulty`, fire
|
||||
/// `StartDifficultyRequestEvent`, and close the Home modal.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_home_difficulty_chip_click(
|
||||
mut commands: Commands,
|
||||
chips: Query<(&Interaction, &HomeDifficultyChip), Changed<Interaction>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
|
||||
mut settings: Option<ResMut<SettingsResource>>,
|
||||
storage_path: Option<Res<SettingsStoragePath>>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else {
|
||||
return;
|
||||
};
|
||||
let level = chip.0;
|
||||
|
||||
if let Some(s) = settings.as_mut() {
|
||||
s.0.last_difficulty = Some(level);
|
||||
if let Some(p) = storage_path
|
||||
&& let Some(path) = p.0.as_deref()
|
||||
&& let Err(e) = save_settings_to(path, &s.0)
|
||||
{
|
||||
warn!("home: failed to persist last_difficulty: {e}");
|
||||
}
|
||||
changed.write(SettingsChangedEvent(s.0.clone()));
|
||||
}
|
||||
|
||||
difficulty_ev.write(StartDifficultyRequestEvent { level });
|
||||
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digit-key shortcuts (1-5) — modal-scoped
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -619,6 +740,7 @@ fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
||||
KeyCode::Digit3 => Some(HomeMode::Zen),
|
||||
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
||||
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
||||
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -646,6 +768,7 @@ fn handle_home_digit_keys(
|
||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||
) {
|
||||
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
||||
if screens.is_empty() {
|
||||
@@ -658,6 +781,7 @@ fn handle_home_digit_keys(
|
||||
KeyCode::Digit3,
|
||||
KeyCode::Digit4,
|
||||
KeyCode::Digit5,
|
||||
KeyCode::Digit6,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|k| keys.just_pressed(*k))
|
||||
@@ -687,6 +811,9 @@ fn handle_home_digit_keys(
|
||||
HomeMode::TimeAttack => {
|
||||
time_attack.write(StartTimeAttackRequestEvent);
|
||||
}
|
||||
HomeMode::PlayBySeed => {
|
||||
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal after dispatching the launch event — same shape as
|
||||
@@ -717,6 +844,11 @@ struct HomeContext<'a> {
|
||||
daily_today: Option<DailyToday>,
|
||||
draw_mode: DrawMode,
|
||||
font_res: Option<&'a FontResource>,
|
||||
/// Whether the difficulty section header is currently expanded.
|
||||
difficulty_expanded: bool,
|
||||
/// The last difficulty tier the player selected (persisted in Settings).
|
||||
/// When `Some`, that tier's chip is highlighted.
|
||||
last_difficulty: Option<DifficultyLevel>,
|
||||
}
|
||||
|
||||
/// Today's daily-challenge metadata as the Home picker needs it. Only
|
||||
@@ -784,10 +916,13 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
HomeMode::PlayBySeed,
|
||||
] {
|
||||
spawn_mode_card(grid, mode, &ctx);
|
||||
}
|
||||
});
|
||||
|
||||
spawn_difficulty_section(body, &ctx);
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
@@ -951,6 +1086,101 @@ fn spawn_draw_mode_chip<M: Component>(
|
||||
});
|
||||
}
|
||||
|
||||
/// Collapsible difficulty-tier section injected below the mode tile grid.
|
||||
///
|
||||
/// Structure:
|
||||
/// ```text
|
||||
/// ▶ Difficulty ← HomeDifficultyToggle (Button, row)
|
||||
/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded
|
||||
/// ```
|
||||
///
|
||||
/// The toggle header despawns + respawns the home screen (same pattern as
|
||||
/// the draw-mode toggle) so the chevron direction and chip row visibility
|
||||
/// update without Visibility component surgery.
|
||||
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
||||
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
||||
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
||||
|
||||
let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" };
|
||||
|
||||
// Header row — click to toggle expand/collapse.
|
||||
parent
|
||||
.spawn((
|
||||
HomeDifficultyToggle,
|
||||
Button,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(chevron),
|
||||
font_label.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
row.spawn((
|
||||
Text::new("Difficulty"),
|
||||
font_label.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier chips — only rendered when expanded.
|
||||
if ctx.difficulty_expanded {
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
row_gap: VAL_SPACE_2,
|
||||
column_gap: VAL_SPACE_2,
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
for level in [
|
||||
DifficultyLevel::Easy,
|
||||
DifficultyLevel::Medium,
|
||||
DifficultyLevel::Hard,
|
||||
DifficultyLevel::Expert,
|
||||
DifficultyLevel::Grandmaster,
|
||||
DifficultyLevel::Random,
|
||||
] {
|
||||
let active = ctx.last_difficulty == Some(level);
|
||||
let (bg, fg) = if active {
|
||||
(ACCENT_PRIMARY, BG_ELEVATED)
|
||||
} else {
|
||||
(BG_ELEVATED_HI, TEXT_PRIMARY)
|
||||
};
|
||||
row.spawn((
|
||||
HomeDifficultyChip(level),
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(bg),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|c| {
|
||||
c.spawn((
|
||||
Text::new(level.label()),
|
||||
font_chip.clone(),
|
||||
TextColor(fg),
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
|
||||
/// otherwise the raw number with thousands separators. Keeps chip text
|
||||
/// short enough to fit a 3-up header strip without wrapping.
|
||||
@@ -999,6 +1229,7 @@ fn home_mode_focus_order(mode: HomeMode) -> i32 {
|
||||
HomeMode::Zen => 2,
|
||||
HomeMode::Challenge => 3,
|
||||
HomeMode::TimeAttack => 4,
|
||||
HomeMode::PlayBySeed => 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,8 +1377,8 @@ fn spawn_mode_card(
|
||||
));
|
||||
|
||||
if unlocked {
|
||||
// Hotkey chip — same look as the kbd-chip rows used
|
||||
// elsewhere so accelerators read consistently.
|
||||
// Hotkey chip — suppressed on Android (touch builds have no keyboard).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
row.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
@@ -1402,13 +1633,14 @@ mod tests {
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
HomeMode::PlayBySeed,
|
||||
] {
|
||||
assert!(
|
||||
modes.contains(&expected),
|
||||
"missing card for {expected:?}; found {modes:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(modes.len(), 5, "exactly five cards expected");
|
||||
assert_eq!(modes.len(), 6, "exactly six cards expected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1600,7 +1832,7 @@ mod tests {
|
||||
.map(|(c, f)| (c.0, *f))
|
||||
.collect();
|
||||
|
||||
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable");
|
||||
assert_eq!(cards.len(), 6, "all six cards must carry a Focusable");
|
||||
for (mode, focusable) in &cards {
|
||||
assert_eq!(
|
||||
focusable.group,
|
||||
@@ -1626,7 +1858,7 @@ mod tests {
|
||||
|
||||
for (mode, disabled) in states {
|
||||
match mode {
|
||||
HomeMode::Classic | HomeMode::Daily => assert!(
|
||||
HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
|
||||
!disabled,
|
||||
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! without a separate tick system.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
@@ -17,6 +18,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
||||
use crate::ui_theme::SPACE_2;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||
@@ -33,10 +36,18 @@ use crate::events::{
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::input_plugin::TouchDragSet;
|
||||
use crate::layout::LayoutSystem;
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::resources::DragState;
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_focus::{FocusGroup, Focusable};
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
/// Marker on the score text node.
|
||||
@@ -115,6 +126,37 @@ pub struct HudDrawCycle;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudSelection;
|
||||
|
||||
/// Marker on the HUD band background node (the translucent band behind buttons).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudBand;
|
||||
|
||||
/// Marker on the HUD score/info column root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudColumn;
|
||||
|
||||
/// Marker on the action button bar root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudActionBar;
|
||||
|
||||
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
||||
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
||||
/// on desktop. Resets to `Visible` whenever a modal opens.
|
||||
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HudVisibility {
|
||||
#[default]
|
||||
Visible,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct HudTapTracker {
|
||||
start_pos: Option<bevy::math::Vec2>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const HUD_TAP_SLOP_PX: f32 = 15.0;
|
||||
|
||||
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
||||
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
||||
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
|
||||
@@ -239,6 +281,11 @@ pub struct PauseButton;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpButton;
|
||||
|
||||
/// Marker on the "Hint" action button. Click spawns an async solver task
|
||||
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HintButton;
|
||||
|
||||
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
||||
/// (a small dropdown panel) below the action bar. Each popover row starts
|
||||
/// the corresponding game mode.
|
||||
@@ -273,10 +320,29 @@ pub struct MenuButton;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct MenuPopover;
|
||||
|
||||
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
|
||||
/// while they are open. External systems (e.g. `PausePlugin`) query this to
|
||||
/// determine whether a HUD popover is currently visible without importing the
|
||||
/// individual popover types.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudPopoverOpen;
|
||||
|
||||
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
|
||||
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
|
||||
#[derive(Component, Debug)]
|
||||
struct MenuPopoverBackdrop;
|
||||
|
||||
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
|
||||
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
|
||||
#[derive(Component, Debug)]
|
||||
struct ModesPopoverBackdrop;
|
||||
|
||||
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||
/// `Toggle*RequestEvent` the click handler fires.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub enum MenuOption {
|
||||
Help,
|
||||
Modes,
|
||||
Stats,
|
||||
Achievements,
|
||||
Profile,
|
||||
@@ -322,11 +388,24 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<WinStreakMilestoneEvent>()
|
||||
.init_resource::<PreviousScore>()
|
||||
.init_resource::<HudActionFade>()
|
||||
.init_resource::<HudVisibility>()
|
||||
// Escape-close handlers for popovers read this; init defensively
|
||||
// so HudPlugin works under MinimalPlugins in tests.
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
// WindowResized is registered by table_plugin; re-register
|
||||
// defensively so the HUD plugin works standalone in tests.
|
||||
.add_message::<WindowResized>()
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(
|
||||
Update,
|
||||
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
||||
)
|
||||
.add_systems(Update, restore_hud_on_modal)
|
||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud)
|
||||
.add_systems(Update, update_hud_typography)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -350,10 +429,15 @@ impl Plugin for HudPlugin {
|
||||
handle_undo_button,
|
||||
handle_pause_button,
|
||||
handle_help_button,
|
||||
handle_hint_button,
|
||||
handle_modes_button,
|
||||
handle_mode_option_click,
|
||||
handle_modes_backdrop_click,
|
||||
close_modes_popover_on_escape,
|
||||
handle_menu_button,
|
||||
handle_menu_option_click,
|
||||
handle_menu_backdrop_click,
|
||||
close_menu_popover_on_escape,
|
||||
paint_action_buttons,
|
||||
),
|
||||
)
|
||||
@@ -363,6 +447,17 @@ impl Plugin for HudPlugin {
|
||||
// `paint_action_buttons` would clobber the alpha back to 1.0
|
||||
// mid-fade and produce a visible blip.
|
||||
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
app.init_resource::<HudTapTracker>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
.add_systems(
|
||||
Update,
|
||||
toggle_hud_on_tap
|
||||
.after(TouchDragSet::AfterStartDrag)
|
||||
.in_set(TouchDragSet::BeforeEndDrag),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,11 +471,13 @@ impl Plugin for HudPlugin {
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
fn spawn_hud_band(mut commands: Commands) {
|
||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
const BASE_TOP: f32 = 0.0;
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(0.0),
|
||||
top: Val::Px(BASE_TOP + top_inset),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
@@ -391,6 +488,8 @@ fn spawn_hud_band(mut commands: Commands) {
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||
HudBand,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -413,7 +512,12 @@ fn spawn_hud_band(mut commands: Commands) {
|
||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||
/// transient items disappear cleanly, and uses the typography scale to
|
||||
/// make Score the visual protagonist.
|
||||
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
fn spawn_hud(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_score = TextFont {
|
||||
font: font_handle.clone(),
|
||||
@@ -434,6 +538,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
let row_node = || Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||
// Auto-complete, selection chip) can collectively be wider than
|
||||
// the available space and overflow into the action-button column
|
||||
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||
// a second line; on a desktop window the rows stay single-line
|
||||
// because the parent column has no width cap and the row never
|
||||
// exceeds the natural line width.
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
row_gap: VAL_SPACE_1,
|
||||
align_items: AlignItems::Baseline,
|
||||
..default()
|
||||
};
|
||||
@@ -443,12 +557,22 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
flex_direction: FlexDirection::Column,
|
||||
// Cap the column at 50% of viewport so on narrow
|
||||
// (mobile) widths the inner tier rows have a bounded
|
||||
// width to wrap against, and the column can't bleed
|
||||
// into the right-anchored action button row (also
|
||||
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||
// wider than any tier row's natural width, so the
|
||||
// visible layout is unaffected.
|
||||
max_width: Val::Percent(50.0),
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
HudColumn,
|
||||
))
|
||||
.with_children(|hud| {
|
||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||
@@ -568,94 +692,82 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||
/// because it's the most consequential action; the destructive button sits
|
||||
/// on its own visual edge.
|
||||
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
||||
// top-bar-overlap fix. Aligns with the rest of `hud_plugin`'s
|
||||
// text (which already routes through the `TYPE_*` tokens) and
|
||||
// reclaims horizontal space so the action button row doesn't
|
||||
// collide with the left-anchored HUD column at narrow window
|
||||
// widths.
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
|
||||
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
|
||||
// fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band
|
||||
// of 370 dp). On desktop, keep the descriptive text labels.
|
||||
#[cfg(target_os = "android")]
|
||||
let (max_width, col_gap, row_gap_val) =
|
||||
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let (max_width, col_gap, row_gap_val) =
|
||||
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
let labels = (
|
||||
/* menu */ "\u{2261}", // ≡ identical-to (Arrows/Math-Op, confirmed FiraMono)
|
||||
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
||||
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
||||
/* help */ "?",
|
||||
/* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
|
||||
/* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono)
|
||||
// replaces ▾ (U+25BE) which is absent from FiraMono
|
||||
/* new */ "+",
|
||||
);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let labels = (
|
||||
"Menu \u{25BE}",
|
||||
"Undo",
|
||||
"Pause",
|
||||
"Help",
|
||||
"Hint",
|
||||
"Modes \u{25BE}",
|
||||
"New Game",
|
||||
);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_2,
|
||||
max_width,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
column_gap: col_gap,
|
||||
row_gap: row_gap_val,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
HudActionBar,
|
||||
))
|
||||
.with_children(|row| {
|
||||
// Menu and Modes don't have a single hotkey accelerator
|
||||
// (each row inside their popover has its own); their button
|
||||
// labels carry the dropdown chevron in lieu of a key chip.
|
||||
//
|
||||
// The trailing `order` argument is the per-button index in
|
||||
// visual reading order (left → right). It feeds
|
||||
// `Focusable { group: Hud, order }` so Tab cycles the action
|
||||
// bar in the same order the eye scans it.
|
||||
spawn_action_button(
|
||||
row,
|
||||
MenuButton,
|
||||
"Menu \u{25BE}",
|
||||
None,
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||
&font,
|
||||
0,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
UndoButton,
|
||||
"Undo",
|
||||
Some("U"),
|
||||
"Take back your last move. Costs points and blocks No Undo.",
|
||||
&font,
|
||||
1,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
PauseButton,
|
||||
"Pause",
|
||||
Some("Esc"),
|
||||
"Pause the game and freeze the timer.",
|
||||
&font,
|
||||
2,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
HelpButton,
|
||||
"Help",
|
||||
Some("F1"),
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
&font,
|
||||
3,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
ModesButton,
|
||||
"Modes \u{25BE}",
|
||||
None,
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
&font,
|
||||
4,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
NewGameButton,
|
||||
"New Game",
|
||||
Some("N"),
|
||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||
&font,
|
||||
5,
|
||||
);
|
||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||
// so Tab cycles the action bar in visual reading order.
|
||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
|
||||
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
|
||||
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
|
||||
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
|
||||
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
|
||||
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
|
||||
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -680,33 +792,44 @@ fn spawn_action_button<M: Component>(
|
||||
tooltip: &'static str,
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
text_color: Color,
|
||||
) {
|
||||
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||
// touch device — the button itself is the affordance — and they
|
||||
// visibly clutter the narrow-viewport action row. Force the hint
|
||||
// off on Android; the chevrons on Menu/Modes remain because they
|
||||
// indicate dropdown behaviour and still apply on touch.
|
||||
#[cfg(target_os = "android")]
|
||||
let hotkey: Option<&'static str> = None;
|
||||
|
||||
let hotkey_font = TextFont {
|
||||
font: font.font.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
// On Android, use tighter padding and a slightly smaller min-size so all
|
||||
// 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥
|
||||
// Apple's minimum touch target; padding of 4 dp each side keeps the icon
|
||||
// centred with room to breathe. On desktop, keep the comfortable 48 dp
|
||||
// floor and 8 dp side padding.
|
||||
#[cfg(target_os = "android")]
|
||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
|
||||
|
||||
row.spawn((
|
||||
marker,
|
||||
ActionButton,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
// Joins the `Hud` focus group at the supplied order so Tab
|
||||
// cycles HUD buttons left-to-right under Phase 2. The HUD focus
|
||||
// ring still only engages when a HUD button is hovered (or in
|
||||
// future phases, when the player explicitly switches groups);
|
||||
// the marker just declares membership.
|
||||
Focusable {
|
||||
group: FocusGroup::Hud,
|
||||
order,
|
||||
},
|
||||
Node {
|
||||
// Horizontal padding stepped down from VAL_SPACE_3 to
|
||||
// VAL_SPACE_2 to reclaim ~96px across the 6-button row at
|
||||
// narrow window widths (see top-bar-overlap fix in the
|
||||
// companion commit). Vertical padding stays at VAL_SPACE_2
|
||||
// so button height tracks the rest of the chrome band.
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||
padding: pad,
|
||||
min_width: min_w,
|
||||
min_height: min_h,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
@@ -718,7 +841,7 @@ fn spawn_action_button<M: Component>(
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||
if let Some(key) = hotkey {
|
||||
// Hotkey hint rendered as a dim caption next to the label —
|
||||
// keeps the keyboard accelerator discoverable without
|
||||
@@ -777,12 +900,43 @@ fn handle_help_button(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_hint_button(
|
||||
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
||||
paused: Option<Res<crate::PausedResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
||||
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
if paused.as_ref().is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let Some(ref g) = game else { return };
|
||||
if g.0.is_won {
|
||||
#[cfg(target_os = "android")]
|
||||
let won_msg = "Game won! Tap New Game to play again";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let won_msg = "Game won! Press N for a new game";
|
||||
info_toast.write(InfoToastEvent(won_msg.to_string()));
|
||||
return;
|
||||
}
|
||||
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
||||
hint.spawn(g.0.clone(), cfg.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
|
||||
/// second click. Mode rows are populated per the player's current level so
|
||||
/// only unlocked options appear.
|
||||
fn handle_modes_button(
|
||||
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
@@ -796,6 +950,9 @@ fn handle_modes_button(
|
||||
}
|
||||
if let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
} else {
|
||||
spawn_modes_popover(
|
||||
&mut commands,
|
||||
@@ -859,6 +1016,7 @@ fn spawn_modes_popover(
|
||||
commands
|
||||
.spawn((
|
||||
ModesPopover,
|
||||
HudPopoverOpen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
@@ -896,6 +1054,23 @@ fn spawn_modes_popover(
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
|
||||
// Z_HUD+5) so tapping outside the panel light-dismisses it.
|
||||
commands.spawn((
|
||||
ModesPopoverBackdrop,
|
||||
Button,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
ZIndex(Z_HUD + 4),
|
||||
));
|
||||
}
|
||||
|
||||
/// Dispatches the click on a popover row to the matching request event,
|
||||
@@ -909,6 +1084,7 @@ fn spawn_modes_popover(
|
||||
fn handle_mode_option_click(
|
||||
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||
@@ -941,9 +1117,13 @@ fn handle_mode_option_click(
|
||||
}
|
||||
}
|
||||
if clicked_any
|
||||
&& let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
&& let Ok(entity) = popovers.single()
|
||||
{
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
|
||||
@@ -952,6 +1132,8 @@ fn handle_mode_option_click(
|
||||
fn handle_menu_button(
|
||||
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
@@ -963,7 +1145,10 @@ fn handle_menu_button(
|
||||
}
|
||||
if let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
} else if scrims.is_empty() {
|
||||
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
@@ -982,7 +1167,17 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
// Each row carries a tooltip alongside its label so hover reveals
|
||||
// a one-line description of what each overlay shows — mirroring
|
||||
// the tooltips on the action-bar buttons that opened this popover.
|
||||
let rows: [(MenuOption, &'static str, &'static str); 5] = [
|
||||
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
||||
(
|
||||
MenuOption::Help,
|
||||
"Help",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
),
|
||||
(
|
||||
MenuOption::Modes,
|
||||
"Game Modes",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
),
|
||||
(
|
||||
MenuOption::Stats,
|
||||
"Stats",
|
||||
@@ -1013,6 +1208,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
commands
|
||||
.spawn((
|
||||
MenuPopover,
|
||||
HudPopoverOpen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
@@ -1050,6 +1246,23 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
||||
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
||||
commands.spawn((
|
||||
MenuPopoverBackdrop,
|
||||
Button,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
ZIndex(Z_HUD + 4),
|
||||
));
|
||||
}
|
||||
|
||||
/// Dispatches the click on a menu row to the matching toggle event,
|
||||
@@ -1058,20 +1271,32 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
fn handle_menu_option_click(
|
||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||
mut help: MessageWriter<HelpRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let mut clicked_any = false;
|
||||
let mut open_modes = false;
|
||||
for (interaction, option) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
clicked_any = true;
|
||||
match option {
|
||||
MenuOption::Help => {
|
||||
help.write(HelpRequestEvent);
|
||||
}
|
||||
MenuOption::Modes => {
|
||||
open_modes = true;
|
||||
}
|
||||
MenuOption::Stats => {
|
||||
stats.write(ToggleStatsRequestEvent);
|
||||
}
|
||||
@@ -1092,7 +1317,85 @@ fn handle_menu_option_click(
|
||||
if clicked_any
|
||||
&& let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
if open_modes {
|
||||
spawn_modes_popover(
|
||||
&mut commands,
|
||||
progress.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
||||
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
|
||||
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
|
||||
fn close_modes_popover_on_escape(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||
return;
|
||||
}
|
||||
for e in popovers.iter().chain(backdrops.iter()) {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
|
||||
/// is pressed while the popover is open.
|
||||
fn close_menu_popover_on_escape(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||
return;
|
||||
}
|
||||
for e in popovers.iter().chain(backdrops.iter()) {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
||||
/// anywhere outside the panel.
|
||||
fn handle_modes_backdrop_click(
|
||||
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !pressed {
|
||||
return;
|
||||
}
|
||||
for e in popovers.iter().chain(backdrops.iter()) {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
||||
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
||||
fn handle_menu_backdrop_click(
|
||||
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !pressed {
|
||||
return;
|
||||
}
|
||||
for e in popovers.iter().chain(backdrops.iter()) {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-fade state for the action button bar. The bar fades out when
|
||||
@@ -1741,6 +2044,7 @@ fn update_hud(
|
||||
GameMode::Zen => "ZEN".to_string(),
|
||||
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1937,6 +2241,122 @@ pub fn challenge_time_color(remaining: u64) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
|
||||
///
|
||||
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
|
||||
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
|
||||
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
|
||||
/// all three items remain on one row inside the 50 %-wide HUD column
|
||||
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
|
||||
/// restored so desktop/tablet layouts are unaffected.
|
||||
type HudScoreFont<'w, 's> =
|
||||
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
|
||||
type HudMovesFont<'w, 's> =
|
||||
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
|
||||
type HudTimeFont<'w, 's> =
|
||||
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
|
||||
|
||||
fn update_hud_typography(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
mut score_q: HudScoreFont,
|
||||
mut moves_q: HudMovesFont,
|
||||
mut time_q: HudTimeFont,
|
||||
) {
|
||||
let Some(ev) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
let (score_size, secondary_size) = if ev.width < 480.0 {
|
||||
(TYPE_BODY_LG, TYPE_CAPTION)
|
||||
} else {
|
||||
(TYPE_HEADLINE, TYPE_BODY_LG)
|
||||
};
|
||||
for mut font in &mut score_q {
|
||||
font.font_size = score_size;
|
||||
}
|
||||
for mut font in &mut moves_q {
|
||||
font.font_size = secondary_size;
|
||||
}
|
||||
for mut font in &mut time_q {
|
||||
font.font_size = secondary_size;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_hud_visibility(
|
||||
hud_vis: Res<HudVisibility>,
|
||||
mut nodes: Query<
|
||||
&mut Visibility,
|
||||
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
|
||||
>,
|
||||
window_entities: Query<(Entity, &Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
) {
|
||||
if !hud_vis.is_changed() {
|
||||
return;
|
||||
}
|
||||
let v = if *hud_vis == HudVisibility::Visible {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
for mut node_vis in &mut nodes {
|
||||
*node_vis = v;
|
||||
}
|
||||
if let Some((entity, window)) = window_entities.iter().next() {
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: window.resolution.width(),
|
||||
height: window.resolution.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_hud_on_modal(
|
||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||
mut hud_vis: ResMut<HudVisibility>,
|
||||
) {
|
||||
if !new_scrims.is_empty() {
|
||||
*hud_vis = HudVisibility::Visible;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn toggle_hud_on_tap(
|
||||
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
|
||||
drag: Res<DragState>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut tracker: ResMut<HudTapTracker>,
|
||||
mut hud_vis: ResMut<HudVisibility>,
|
||||
) {
|
||||
use bevy::input::touch::TouchPhase;
|
||||
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
||||
tracker.start_pos = None;
|
||||
return;
|
||||
}
|
||||
for event in touch_events.read() {
|
||||
match event.phase {
|
||||
TouchPhase::Started => {
|
||||
tracker.start_pos = Some(event.position);
|
||||
}
|
||||
TouchPhase::Ended if drag.is_idle() => {
|
||||
if let Some(start) = tracker.start_pos.take() {
|
||||
if (event.position - start).length() < HUD_TAP_SLOP_PX {
|
||||
*hud_vis = match *hud_vis {
|
||||
HudVisibility::Visible => HudVisibility::Hidden,
|
||||
HudVisibility::Hidden => HudVisibility::Visible,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
TouchPhase::Canceled | TouchPhase::Moved => {
|
||||
tracker.start_pos = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2442,6 +2862,7 @@ mod tests {
|
||||
focusable_for::<UndoButton>(&mut app),
|
||||
focusable_for::<PauseButton>(&mut app),
|
||||
focusable_for::<HelpButton>(&mut app),
|
||||
focusable_for::<HintButton>(&mut app),
|
||||
focusable_for::<ModesButton>(&mut app),
|
||||
focusable_for::<NewGameButton>(&mut app),
|
||||
] {
|
||||
@@ -2550,6 +2971,10 @@ mod tests {
|
||||
tooltip_for::<HelpButton>(&mut app),
|
||||
"Show controls, rules, and keyboard shortcuts."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HintButton>(&mut app),
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<ModesButton>(&mut app),
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
||||
@@ -2636,7 +3061,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Same contract for MenuOption rows: five entries, each with a
|
||||
// Same contract for MenuOption rows: seven entries, each with a
|
||||
// tooltip, exact strings matching the approved microcopy.
|
||||
let mut menu_q = app
|
||||
.world_mut()
|
||||
@@ -2647,11 +3072,13 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
menu_tooltips.len(),
|
||||
5,
|
||||
"expected a tooltip on each of the 5 menu rows, got {}",
|
||||
7,
|
||||
"expected a tooltip on each of the 7 menu rows, got {}",
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
@@ -2669,14 +3096,15 @@ mod tests {
|
||||
fn hud_button_order_matches_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
||||
// Modes, New Game. Their `order` fields must be 0..=5 in that
|
||||
// order so Tab cycles them as the player reads them.
|
||||
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
||||
// that order so Tab cycles them as the player reads them.
|
||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,7 +23,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{MonitorSelection, PrimaryWindow, WindowMode};
|
||||
use bevy::window::PrimaryWindow;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{MonitorSelection, WindowMode};
|
||||
use solitaire_core::card::{Card, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
@@ -31,11 +33,9 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::{
|
||||
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
||||
TABLEAU_FAN_FRAC,
|
||||
};
|
||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
|
||||
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
|
||||
use crate::radial_menu::RightClickRadialState;
|
||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
@@ -51,6 +51,16 @@ use crate::resources::{DragState, GameStateResource, HintCycleIndex};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// System-set labels used to anchor external systems relative to the touch
|
||||
/// drag pipeline without duplicating the internal chain ordering.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum TouchDragSet {
|
||||
/// After `touch_start_drag` has run — drag state is populated if a card was touched.
|
||||
AfterStartDrag,
|
||||
/// Before `touch_end_drag` runs — drag state has not yet been cleared.
|
||||
BeforeEndDrag,
|
||||
}
|
||||
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
|
||||
@@ -103,14 +113,18 @@ impl Plugin for InputPlugin {
|
||||
follow_drag,
|
||||
end_drag.before(GameMutation),
|
||||
// Touch drag pipeline (parallel path through DragState).
|
||||
touch_start_drag,
|
||||
touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
|
||||
touch_follow_drag,
|
||||
touch_end_drag.before(GameMutation),
|
||||
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
|
||||
touch_end_drag.after(TouchDragSet::BeforeEndDrag).before(GameMutation),
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(Update, handle_fullscreen)
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change)
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change);
|
||||
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
app.add_systems(Update, handle_fullscreen);
|
||||
app
|
||||
// Async hint pipeline: state-change drop runs before the
|
||||
// poll system so a move applied this frame cancels any
|
||||
// in-flight task before its result can be surfaced.
|
||||
@@ -423,6 +437,7 @@ fn reset_hint_cycle_on_state_change(
|
||||
|
||||
/// `F11` toggles between borderless-fullscreen and windowed mode.
|
||||
/// Not gated by the pause flag — the player can always resize the window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn handle_fullscreen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
@@ -515,8 +530,10 @@ fn handle_touch_stock_tap(
|
||||
/// Begins a mouse drag: records the press position and the cards that would be
|
||||
/// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`]
|
||||
/// once the drag threshold is crossed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_drag(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
@@ -531,6 +548,15 @@ fn start_drag(
|
||||
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
// On platforms where Winit simulates a MouseButton::Left press from the
|
||||
// first touch, this guard ensures touch_start_drag (which runs after this
|
||||
// system) claims the drag state instead of the mouse path. Without it the
|
||||
// card is tracked via cursor_world (updated from the simulated mouse
|
||||
// position) rather than the Touches resource, which can be one frame
|
||||
// behind the actual finger position on Android.
|
||||
if touches.as_ref().is_some_and(|t| t.iter_just_pressed().next().is_some()) {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(world) = cursor_world(&windows, &cameras) else { return };
|
||||
|
||||
@@ -607,7 +633,7 @@ fn follow_drag(
|
||||
|
||||
// Move cards to the cursor.
|
||||
let bottom_pos = world + drag.cursor_offset;
|
||||
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||
|
||||
for (i, &id) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform, _)) =
|
||||
@@ -868,7 +894,7 @@ fn touch_follow_drag(
|
||||
}
|
||||
|
||||
let bottom_pos = world + drag.cursor_offset;
|
||||
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||
|
||||
for (i, &id) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform, _)) =
|
||||
@@ -1040,8 +1066,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
/// Where a card at `stack_index` in pile `pile` would be rendered.
|
||||
///
|
||||
/// For tableau columns the per-card fan step depends on the face-up state of
|
||||
/// every preceding card — face-down cards step by `TABLEAU_FACEDOWN_FAN_FRAC`,
|
||||
/// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions`
|
||||
/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`,
|
||||
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
|
||||
/// exactly; any drift creates an offset between the visible card face and
|
||||
/// where clicks land.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||
@@ -1051,9 +1077,9 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
|
||||
if let Some(pile_cards) = game.piles.get(pile) {
|
||||
for card in pile_cards.cards.iter().take(stack_index) {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
layout.tableau_fan_frac
|
||||
} else {
|
||||
TABLEAU_FACEDOWN_FAN_FRAC
|
||||
layout.tableau_facedown_fan_frac
|
||||
};
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
@@ -1188,7 +1214,7 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||
let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
|
||||
let top_edge = center.y + layout.card_size.y / 2.0;
|
||||
let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
|
||||
@@ -1204,12 +1230,17 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #27 — Double-click to auto-move
|
||||
// Task #27 — Double-click / double-tap to auto-move
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum seconds between two clicks to count as a double-click.
|
||||
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||
|
||||
/// Duration of the lime flash applied to moved cards when a tap
|
||||
/// auto-move succeeds. Short enough not to linger, long enough to register
|
||||
/// during the card animation (~0.3 s).
|
||||
const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
|
||||
|
||||
/// Find the best legal destination for `card` — Foundation first, then Tableau.
|
||||
///
|
||||
/// Returns `None` if no legal move exists from the card's current location.
|
||||
@@ -1363,6 +1394,116 @@ fn handle_double_click(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tap-to-move (touch equivalent of mouse auto-move)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fires `MoveRequestEvent` when the player taps a face-up card without
|
||||
/// dragging — the touch equivalent of the mouse auto-move flow.
|
||||
///
|
||||
/// Must run **before** `touch_end_drag` in the system chain. At
|
||||
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
|
||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||
/// are cleared and the tap/drag distinction is permanently lost.
|
||||
///
|
||||
/// Move priority:
|
||||
/// 1. Single top card to its best foundation (or tableau).
|
||||
/// 2. Whole face-up run to best tableau column when no single-card move exists.
|
||||
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_tap(
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
radial: Option<Res<RightClickRadialState>>,
|
||||
drag: Res<DragState>,
|
||||
game: Res<GameStateResource>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||
mut commands: Commands,
|
||||
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Long-press opened the radial — let radial_handle_release_or_cancel own
|
||||
// the finger-lift event.
|
||||
if radial.is_some_and(|r| r.is_active()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(active_id) = drag.active_touch_id else { return };
|
||||
if drag.committed {
|
||||
return;
|
||||
}
|
||||
|
||||
for event in touch_events.read() {
|
||||
if event.id != active_id || event.phase != TouchPhase::Ended {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Uncommitted touch ended = pure tap.
|
||||
let Some(&top_card_id) = drag.cards.last() else { return };
|
||||
let Some(ref pile) = drag.origin_pile else { return };
|
||||
let Some(pile_cards) = game.0.piles.get(pile) else { return };
|
||||
|
||||
let Some(top_card) = pile_cards.cards.iter().find(|c| c.id == top_card_id) else {
|
||||
return;
|
||||
};
|
||||
if !top_card.face_up {
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if ce.card_id == top_card_id {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS });
|
||||
break;
|
||||
}
|
||||
}
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: move whole face-up stack to best tableau column.
|
||||
if drag.cards.len() > 1 {
|
||||
let stack_index = pile_cards.cards.len() - drag.cards.len();
|
||||
if let Some(bottom_card) = pile_cards.cards.get(stack_index)
|
||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
pile,
|
||||
&game.0,
|
||||
drag.cards.len(),
|
||||
)
|
||||
{
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if drag.cards.contains(&ce.card_id) {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS });
|
||||
}
|
||||
}
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile.clone(),
|
||||
count: drag.cards.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint system helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1501,7 +1642,7 @@ mod tests {
|
||||
#[test]
|
||||
fn find_draggable_picks_top_of_tableau() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
@@ -1515,7 +1656,7 @@ mod tests {
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||
// the bottom (index 6). Click at the topmost face-down card's
|
||||
@@ -1536,7 +1677,7 @@ mod tests {
|
||||
// face-up bottom card, clicking the visible card face missed the
|
||||
// hit-test box and only the bottom strip of the card responded.
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
@@ -1575,7 +1716,7 @@ mod tests {
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
@@ -1607,7 +1748,7 @@ mod tests {
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (id 201), with count = 1.
|
||||
let pos = card_position(&game, &layout, &PileType::Waste, 0);
|
||||
@@ -1620,7 +1761,7 @@ mod tests {
|
||||
#[test]
|
||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||
let mut game = game;
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||||
@@ -1632,7 +1773,7 @@ mod tests {
|
||||
#[test]
|
||||
fn find_drop_target_returns_none_for_origin() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let pos = layout.pile_positions[&PileType::Tableau(3)];
|
||||
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
|
||||
assert_eq!(target, None);
|
||||
@@ -1641,7 +1782,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
||||
@@ -1666,7 +1807,7 @@ mod tests {
|
||||
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
|
||||
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let waste_base = layout.pile_positions[&PileType::Waste];
|
||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||
@@ -1682,7 +1823,7 @@ mod tests {
|
||||
#[test]
|
||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Clear tableau 0 so it's an empty slot.
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||||
let pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
@@ -1693,7 +1834,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for pile in [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(2),
|
||||
@@ -2194,7 +2335,7 @@ mod tests {
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(crate::layout::LayoutResource(
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
@@ -2215,5 +2356,6 @@ mod tests {
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+267
-30
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
|
||||
UpdateOnResize,
|
||||
}
|
||||
|
||||
/// Minimum supported window dimensions. Layout is still computed below this
|
||||
/// size but cards will be small.
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||
/// Minimum window dimensions used as a layout floor.
|
||||
///
|
||||
/// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
|
||||
/// on either axis is laid out as if it were at least this size. The floor
|
||||
/// exists to guard against degenerate / divide-by-zero layouts on very small
|
||||
/// surfaces (Bevy can briefly report 0-size windows during startup or after
|
||||
/// minimisation on some compositors); it is not a "minimum supported playable
|
||||
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
|
||||
/// `solitaire_app::lib`.
|
||||
///
|
||||
/// The previous floor of 800×600 was set with desktop in mind and produced
|
||||
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
|
||||
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
|
||||
/// tableau pile past `+180`, which clipped both at the visible viewport
|
||||
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
|
||||
/// smallest reasonable phone (≈ 360×640) so every real device flows through
|
||||
/// without clamping, while still being large enough that the layout math
|
||||
/// produces non-degenerate card sizes.
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
||||
|
||||
/// Aspect ratio (height / width) of a standard playing card.
|
||||
///
|
||||
@@ -36,11 +52,22 @@ const CARD_ASPECT: f32 = 1.4523;
|
||||
/// the tableau row.
|
||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||
|
||||
/// Fraction of card height contributed by each additional face-up tableau card
|
||||
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
|
||||
/// solve for a worst-case column without depending on `card_plugin`.
|
||||
/// Minimum fraction of card height used as vertical offset between face-up
|
||||
/// tableau cards. Used for the height-based sizing candidate (worst-case
|
||||
/// column must fit at this fraction). On desktop (height-limited) windows the
|
||||
/// adaptive computation returns this value exactly; on portrait phones it
|
||||
/// expands to fill available vertical space.
|
||||
const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
|
||||
/// Minimum fraction for face-down tableau cards. Scales proportionally with
|
||||
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
|
||||
///
|
||||
/// Raised from 0.12 to 0.20 so face-down stacks on portrait phones show
|
||||
/// enough of each card back to read as a meaningful stack rather than a
|
||||
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
|
||||
/// the adaptive scaling in `compute_layout`.
|
||||
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
||||
|
||||
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
||||
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||
/// this column inside the visible window.
|
||||
@@ -50,10 +77,15 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
///
|
||||
/// 64 px comfortably fits the action button bar (~32 px tall) plus the
|
||||
/// Score/Moves text line plus padding, with a few pixels of breathing room.
|
||||
/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`.
|
||||
/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line.
|
||||
/// Android: 128 px accommodates the two-row button wrap on narrow phones
|
||||
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
|
||||
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
|
||||
/// buttons overlaps the top card row.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||
#[cfg(target_os = "android")]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 128.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
@@ -72,9 +104,33 @@ pub struct Layout {
|
||||
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
||||
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
||||
pub pile_positions: HashMap<PileType, Vec2>,
|
||||
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
|
||||
/// windows it expands to fill the available vertical space so the tableau
|
||||
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
|
||||
/// and hit testing (`input_plugin`) both read from this field so they
|
||||
/// stay in sync.
|
||||
pub tableau_fan_frac: f32,
|
||||
/// Per-step vertical offset fraction for face-down tableau cards, as a
|
||||
/// fraction of `card_size.y`. Scales proportionally with `tableau_fan_frac`
|
||||
/// (ratio preserved from `TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC`).
|
||||
pub tableau_facedown_fan_frac: f32,
|
||||
/// Vertical pixel budget available for tableau fan steps — the distance
|
||||
/// from the top edge of the first tableau card to the bottom margin, in
|
||||
/// logical pixels. Used by `card_plugin::update_tableau_fan_frac` to
|
||||
/// recompute `tableau_fan_frac` dynamically based on the actual max
|
||||
/// face-up column depth after each game state change.
|
||||
pub available_tableau_height: f32,
|
||||
}
|
||||
|
||||
/// Compute the board layout from a window size.
|
||||
/// Compute the board layout from a window size and safe-area insets.
|
||||
///
|
||||
/// `safe_area_top` and `safe_area_bottom` are the **logical-pixel** heights of
|
||||
/// the OS-reserved regions at the top and bottom of the screen (status bar and
|
||||
/// gesture / navigation bar on Android). Pass `0.0` on desktop or when the
|
||||
/// inset is unknown. Android's `WindowInsets` API returns **physical** pixels;
|
||||
/// callers must divide by `window.scale_factor()` before passing values here.
|
||||
///
|
||||
/// # Geometry
|
||||
/// - `card_width` is the smaller of:
|
||||
@@ -90,8 +146,9 @@ pub struct Layout {
|
||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||
/// waste/stock cluster from the foundations.
|
||||
pub fn compute_layout(window: Vec2) -> Layout {
|
||||
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, hud_visible: bool) -> Layout {
|
||||
let window = window.max(MIN_WINDOW);
|
||||
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
||||
|
||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||
let card_width_width_based = window.x / 9.0;
|
||||
@@ -113,7 +170,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -133,7 +190,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
};
|
||||
|
||||
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
|
||||
let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
|
||||
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
|
||||
let tableau_y = top_y - card_height - vertical_gap;
|
||||
|
||||
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
||||
@@ -153,9 +210,36 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
|
||||
}
|
||||
|
||||
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
||||
// height-based sizing already ensures a worst-case 13-card column fits at
|
||||
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
|
||||
// keeps it there — no change from prior behaviour. On width-limited
|
||||
// (portrait phone) windows card_size is small and lots of vertical space
|
||||
// is unused; we solve for the fraction that exactly fills the available
|
||||
// space to the bottom margin.
|
||||
//
|
||||
// avail = distance from the top of the first tableau card to the bottom
|
||||
// margin — i.e. the space available for 12 fan steps.
|
||||
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||
let ideal_fan_frac = if card_height > 0.0 {
|
||||
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
||||
} else {
|
||||
TABLEAU_FAN_FRAC
|
||||
};
|
||||
// Never go below the desktop minimum — avoids shrinking the fan on
|
||||
// degenerate near-square windows where the formula might undershoot.
|
||||
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
|
||||
// Scale the face-down fraction proportionally so rendering and hit-testing
|
||||
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
|
||||
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
||||
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
||||
|
||||
Layout {
|
||||
card_size,
|
||||
pile_positions,
|
||||
tableau_fan_frac,
|
||||
tableau_facedown_fan_frac,
|
||||
available_tableau_height: avail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,15 +271,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn layout_has_all_thirteen_piles() {
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0)));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0)));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0)));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_size_scales_with_window_width() {
|
||||
let small = compute_layout(Vec2::new(800.0, 600.0));
|
||||
let large = compute_layout(Vec2::new(1920.0, 1080.0));
|
||||
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
||||
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true);
|
||||
assert!(large.card_size.x > small.card_size.x);
|
||||
assert!(
|
||||
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
|
||||
@@ -205,14 +289,42 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn layout_below_minimum_clamps_to_minimum() {
|
||||
let below = compute_layout(Vec2::new(400.0, 300.0));
|
||||
let at_min = compute_layout(MIN_WINDOW);
|
||||
// 200×200 sits below the floor on both axes, so the clamp pulls each
|
||||
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0, 0.0, true).
|
||||
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0, true);
|
||||
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0, true);
|
||||
assert_eq!(below.card_size, at_min.card_size);
|
||||
}
|
||||
|
||||
/// Regression for the v0.22.3 Android viewport-overflow bug. A typical
|
||||
/// portrait-phone viewport (360 dp × 800 dp) must produce a layout
|
||||
/// where every pile fits horizontally — i.e. card_width is derived
|
||||
/// from the actual window, not a clamped-up desktop floor.
|
||||
#[test]
|
||||
fn phone_portrait_layout_fits_horizontally() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
assert!(
|
||||
pos.x - half_card >= -half_w - 1e-3,
|
||||
"{:?} overflows left at portrait phone window {:?}",
|
||||
pile,
|
||||
window
|
||||
);
|
||||
assert!(
|
||||
pos.x + half_card <= half_w + 1e-3,
|
||||
"{:?} overflows right at portrait phone window {:?}",
|
||||
pile,
|
||||
window
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_columns_are_sorted_left_to_right() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for i in 0..6 {
|
||||
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
||||
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
||||
@@ -222,7 +334,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn top_row_is_above_tableau_row() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
assert!(stock_y > tableau_y);
|
||||
@@ -235,7 +347,7 @@ mod tests {
|
||||
#[test]
|
||||
fn top_row_clears_hud_band() {
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||
@@ -247,7 +359,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||
let waste_x = layout.pile_positions[&PileType::Waste].x;
|
||||
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
|
||||
@@ -258,7 +370,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for slot in 0..4_u8 {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||
@@ -277,7 +389,7 @@ mod tests {
|
||||
// keep a worst-case 13-card column inside the window. (Most desktop
|
||||
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
|
||||
let window = Vec2::new(2560.0, 1080.0);
|
||||
let layout = compute_layout(window);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
layout.card_size.x < width_based,
|
||||
@@ -293,7 +405,7 @@ mod tests {
|
||||
// the bottleneck and card_width matches the legacy window.x / 9
|
||||
// derivation exactly.
|
||||
let window = Vec2::new(900.0, 1600.0);
|
||||
let layout = compute_layout(window);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||
@@ -307,7 +419,7 @@ mod tests {
|
||||
fn worst_case_tableau_fits_vertically_on_default_resolution() {
|
||||
// Default app resolution (see solitaire_app/src/main.rs).
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
// Bottom edge of the 13th fanned face-up card.
|
||||
@@ -326,7 +438,7 @@ mod tests {
|
||||
fn worst_case_tableau_fits_vertically_on_full_hd() {
|
||||
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
||||
let window = Vec2::new(1920.0, 1080.0);
|
||||
let layout = compute_layout(window);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||
@@ -338,6 +450,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Portrait phone (width-limited) should expand the fan fraction beyond
|
||||
/// the desktop minimum so the tableau fills the available vertical space.
|
||||
#[test]
|
||||
fn portrait_phone_expands_tableau_fan_frac() {
|
||||
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0, true);
|
||||
assert!(
|
||||
phone.tableau_fan_frac > desktop.tableau_fan_frac,
|
||||
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
|
||||
phone.tableau_fan_frac,
|
||||
desktop.tableau_fan_frac,
|
||||
);
|
||||
}
|
||||
|
||||
/// The expanded fan on a portrait phone must not overflow the visible
|
||||
/// window — the worst-case 13-card column must stay above the bottom margin.
|
||||
#[test]
|
||||
fn expanded_fan_fits_phone_viewport() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let h_gap = layout.card_size.x / 4.0;
|
||||
// Bottom of the 13th (worst-case) fanned face-up card.
|
||||
let bottom = tableau_y - 12.0 * layout.tableau_fan_frac * card_h - card_h / 2.0;
|
||||
let margin = -window.y / 2.0 + h_gap;
|
||||
assert!(
|
||||
bottom >= margin - 1e-3,
|
||||
"worst-case fan overflows phone viewport: bottom={bottom:.1} < margin={margin:.1}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Desktop (height-limited) must keep the minimum fan fraction so the
|
||||
/// existing worst-case-fits-vertically invariant is preserved.
|
||||
#[test]
|
||||
fn desktop_tableau_fan_frac_is_minimum() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
assert!(
|
||||
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
|
||||
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
|
||||
layout.tableau_fan_frac,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_piles_fit_inside_window_horizontally() {
|
||||
for window in [
|
||||
@@ -345,7 +501,7 @@ mod tests {
|
||||
Vec2::new(1280.0, 800.0),
|
||||
Vec2::new(1920.0, 1080.0),
|
||||
] {
|
||||
let layout = compute_layout(window);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
@@ -364,4 +520,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-zero `safe_area_top` must shift both the top row and the tableau
|
||||
/// downward by the same amount — so the first card row stays below the
|
||||
/// status-bar band and the tableau tracks it proportionally.
|
||||
#[test]
|
||||
fn safe_area_top_shifts_top_row_downward() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
||||
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
|
||||
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
|
||||
assert!(
|
||||
stock_with_inset < stock_no_inset,
|
||||
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
|
||||
stock_no_inset,
|
||||
stock_with_inset,
|
||||
);
|
||||
assert!(
|
||||
(stock_no_inset - stock_with_inset - 32.0).abs() < 1e-3,
|
||||
"stock pile must shift by exactly safe_area_top (32 dp): delta was {:.3}",
|
||||
stock_no_inset - stock_with_inset,
|
||||
);
|
||||
}
|
||||
|
||||
/// With a safe-area inset the card grid must still fit horizontally —
|
||||
/// safe_area_top only affects the vertical budget.
|
||||
#[test]
|
||||
fn safe_area_top_does_not_affect_horizontal_layout() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0, true);
|
||||
for pile in [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(6),
|
||||
] {
|
||||
assert!(
|
||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||
"{pile:?} x-position must not change with safe_area_top",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bottom safe-area inset must shrink the tableau fan so the worst-case
|
||||
/// column stays above the gesture bar.
|
||||
#[test]
|
||||
fn safe_area_bottom_reduces_tableau_fan() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0, true);
|
||||
assert!(
|
||||
with_inset.tableau_fan_frac <= without.tableau_fan_frac,
|
||||
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
|
||||
without.tableau_fan_frac,
|
||||
with_inset.tableau_fan_frac,
|
||||
);
|
||||
let card_h = with_inset.card_size.y;
|
||||
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
|
||||
let h_gap = with_inset.card_size.x / 4.0;
|
||||
let margin = -window.y / 2.0 + 48.0 + h_gap;
|
||||
assert!(
|
||||
bottom_edge >= margin - 1e-3,
|
||||
"worst-case tableau bottom {bottom_edge:.2} overflows gesture-bar margin {margin:.2}",
|
||||
);
|
||||
}
|
||||
|
||||
/// safe_area_bottom must not affect horizontal positions.
|
||||
#[test]
|
||||
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0, true);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0, true);
|
||||
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
|
||||
assert!(
|
||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||
"{pile:?} x-position must not change with safe_area_bottom",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,23 +9,24 @@
|
||||
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||
//! the panel shows "Not available" immediately.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}};
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
|
||||
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
||||
VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,6 +97,30 @@ struct OptInTask(Option<Task<Result<(), String>>>);
|
||||
#[derive(Resource, Default)]
|
||||
struct OptOutTask(Option<Task<Result<(), String>>>);
|
||||
|
||||
/// Marker on the "Set Name" button inside the leaderboard panel.
|
||||
#[derive(Component, Debug)]
|
||||
struct SetDisplayNameButton;
|
||||
|
||||
/// Marker on the display-name editor modal root.
|
||||
#[derive(Component, Debug)]
|
||||
struct DisplayNameModal;
|
||||
|
||||
/// Text currently typed in the display-name modal's input field.
|
||||
#[derive(Resource, Default)]
|
||||
struct DisplayNameBuffer(String);
|
||||
|
||||
/// Marker on the text node inside the display-name input field.
|
||||
#[derive(Component, Debug)]
|
||||
struct DisplayNameTextField;
|
||||
|
||||
/// Marker on the "Save" button in the display-name modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct DisplayNameConfirmButton;
|
||||
|
||||
/// Marker on the "Cancel" button in the display-name modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct DisplayNameCancelButton;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,12 +136,13 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<ClosedThisFrame>()
|
||||
.init_resource::<OptInTask>()
|
||||
.init_resource::<OptOutTask>()
|
||||
.init_resource::<DisplayNameBuffer>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// leaderboard-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<KeyboardInput>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -129,6 +155,10 @@ impl Plugin for LeaderboardPlugin {
|
||||
poll_opt_in_task,
|
||||
handle_opt_out_button,
|
||||
poll_opt_out_task,
|
||||
handle_set_display_name_button,
|
||||
handle_display_name_text_input,
|
||||
handle_display_name_confirm,
|
||||
handle_display_name_cancel,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
@@ -156,6 +186,7 @@ fn toggle_leaderboard_screen(
|
||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||
data: Res<LeaderboardResource>,
|
||||
provider: Option<Res<SyncProviderResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut task_res: ResMut<LeaderboardFetchTask>,
|
||||
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||
@@ -174,7 +205,8 @@ fn toggle_leaderboard_screen(
|
||||
let remote_available = provider
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.0.backend_name() != "local");
|
||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
||||
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
|
||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
|
||||
|
||||
// Start a background fetch if not already in flight.
|
||||
if task_res.0.is_none()
|
||||
@@ -201,12 +233,14 @@ fn poll_leaderboard_fetch(
|
||||
/// When a fetch completes, cache the data and update any open panel.
|
||||
/// Skips the panel rebuild if the user closed the panel in this same frame
|
||||
/// (commands are deferred, so the query would still see the despawned entity).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_leaderboard_panel(
|
||||
mut commands: Commands,
|
||||
mut result_res: ResMut<LeaderboardFetchResult>,
|
||||
mut data: ResMut<LeaderboardResource>,
|
||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||
provider: Option<Res<SyncProviderResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
closed_flag: Res<ClosedThisFrame>,
|
||||
) {
|
||||
@@ -235,9 +269,10 @@ fn update_leaderboard_panel(
|
||||
let remote_available = provider
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.0.backend_name() != "local");
|
||||
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,11 +340,17 @@ fn handle_opt_in_button(
|
||||
let display_name = settings
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
|
||||
Some(username.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
// Prefer an explicit display name; fall back to server username.
|
||||
s.0.leaderboard_display_name
|
||||
.as_deref()
|
||||
.or_else(|| {
|
||||
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
|
||||
Some(username.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| "Player".to_string());
|
||||
|
||||
@@ -391,6 +432,7 @@ fn spawn_leaderboard_screen(
|
||||
commands: &mut Commands,
|
||||
data: &LeaderboardResource,
|
||||
remote_available: bool,
|
||||
effective_display_name: Option<&str>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
@@ -426,6 +468,33 @@ fn spawn_leaderboard_screen(
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Public name row: shows the effective display name + "Set Name" button.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
let label = match effective_display_name {
|
||||
Some(n) => format!("Public name: {n}"),
|
||||
None => "Public name: (same as username)".to_string(),
|
||||
};
|
||||
row.spawn((
|
||||
Text::new(label),
|
||||
font_caption.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_modal_button(
|
||||
row,
|
||||
SetDisplayNameButton,
|
||||
"Set Name",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
|
||||
// Opt In / Opt Out row uses the same modal-button helpers as
|
||||
// the rest of the UI for consistent hover / press feedback.
|
||||
spawn_modal_actions(card, |row| {
|
||||
@@ -606,6 +675,194 @@ fn data_cell(
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display-name editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opens the display-name editor modal when the "Set Name" button is pressed.
|
||||
fn handle_set_display_name_button(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
|
||||
existing: Query<(), With<DisplayNameModal>>,
|
||||
mut commands: Commands,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut buf: ResMut<DisplayNameBuffer>,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
if !existing.is_empty() {
|
||||
return; // already open
|
||||
}
|
||||
buf.0 = settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.0.leaderboard_display_name.clone())
|
||||
.unwrap_or_default();
|
||||
spawn_display_name_modal(&mut commands, &buf.0, font_res.as_deref());
|
||||
}
|
||||
|
||||
/// Routes keyboard input into the display-name buffer while the editor is open.
|
||||
fn handle_display_name_text_input(
|
||||
screen: Query<(), With<DisplayNameModal>>,
|
||||
mut key_events: MessageReader<KeyboardInput>,
|
||||
mut buf: ResMut<DisplayNameBuffer>,
|
||||
mut text_q: Query<&mut Text, With<DisplayNameTextField>>,
|
||||
) {
|
||||
if screen.is_empty() {
|
||||
key_events.clear();
|
||||
return;
|
||||
}
|
||||
for ev in key_events.read() {
|
||||
if ev.state != ButtonState::Pressed {
|
||||
continue;
|
||||
}
|
||||
if ev.key_code == KeyCode::Backspace {
|
||||
buf.0.pop();
|
||||
} else if let Some(ch) = ev.text.as_deref().and_then(printable_char_dn)
|
||||
&& buf.0.len() < 32
|
||||
{
|
||||
buf.0.push(ch);
|
||||
}
|
||||
}
|
||||
for mut text in &mut text_q {
|
||||
text.0 = if buf.0.is_empty() {
|
||||
" ".to_string()
|
||||
} else {
|
||||
buf.0.clone()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the typed display name to `SettingsResource` and closes the modal.
|
||||
fn handle_display_name_confirm(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||
screens: Query<Entity, With<DisplayNameModal>>,
|
||||
mut commands: Commands,
|
||||
buf: Res<DisplayNameBuffer>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
settings_path: Option<Res<SettingsStoragePath>>,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut settings) = settings {
|
||||
let trimmed = buf.0.trim().to_string();
|
||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
};
|
||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||
{
|
||||
warn!("failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Discards any typed text and closes the display-name editor modal.
|
||||
fn handle_display_name_cancel(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
|
||||
screens: Query<Entity, With<DisplayNameModal>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_display_name_modal(
|
||||
commands: &mut Commands,
|
||||
current_name: &str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let make_font = |size: f32| TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: size,
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, DisplayNameModal, Z_PAUSE_DIALOG, |card| {
|
||||
spawn_modal_header(card, "Public Display Name", font_res);
|
||||
|
||||
card.spawn((
|
||||
Text::new(
|
||||
"Shown on the leaderboard when you opt in. Leave blank to use your username.",
|
||||
),
|
||||
make_font(TYPE_CAPTION),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Input field container.
|
||||
card.spawn((
|
||||
Node {
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||
min_height: Val::Px(32.0),
|
||||
min_width: Val::Px(260.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|border| {
|
||||
let initial = if current_name.is_empty() {
|
||||
" ".to_string()
|
||||
} else {
|
||||
current_name.to_string()
|
||||
};
|
||||
border.spawn((
|
||||
DisplayNameTextField,
|
||||
Text::new(initial),
|
||||
make_font(TYPE_BODY),
|
||||
TextColor(if current_name.is_empty() {
|
||||
TEXT_DISABLED
|
||||
} else {
|
||||
TEXT_PRIMARY
|
||||
}),
|
||||
));
|
||||
});
|
||||
|
||||
card.spawn((
|
||||
Text::new("Max 32 characters."),
|
||||
make_font(TYPE_CAPTION),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
DisplayNameCancelButton,
|
||||
"Cancel",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
DisplayNameConfirmButton,
|
||||
"Save",
|
||||
None,
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
||||
fn printable_char_dn(text: &str) -> Option<char> {
|
||||
let ch = text.chars().next()?;
|
||||
(' '..='~').contains(&ch).then_some(ch)
|
||||
}
|
||||
|
||||
fn format_secs(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
//! Bevy integration layer for Ferrous Solitaire.
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_clipboard;
|
||||
pub mod assets;
|
||||
pub mod card_animation;
|
||||
pub mod achievement_plugin;
|
||||
pub mod analytics_plugin;
|
||||
pub mod animation_plugin;
|
||||
pub mod auto_complete_plugin;
|
||||
pub mod audio_plugin;
|
||||
@@ -12,6 +15,7 @@ pub mod feedback_anim_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod difficulty_plugin;
|
||||
pub mod diagnostics_hud;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
@@ -24,6 +28,7 @@ pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod pending_hint;
|
||||
pub mod play_by_seed_plugin;
|
||||
pub mod profile_plugin;
|
||||
pub mod radial_menu;
|
||||
pub mod replay_overlay;
|
||||
@@ -31,10 +36,12 @@ pub mod replay_playback;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod safe_area;
|
||||
pub mod selection_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
pub mod sync_plugin;
|
||||
pub mod sync_setup_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod theme;
|
||||
pub mod time_attack_plugin;
|
||||
@@ -54,6 +61,7 @@ pub use theme::{
|
||||
ThemeRegistryPlugin,
|
||||
};
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
|
||||
pub use challenge_plugin::{
|
||||
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
||||
};
|
||||
@@ -92,11 +100,14 @@ pub use events::{
|
||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
|
||||
WinStreakMilestoneEvent, XpAwardedEvent,
|
||||
};
|
||||
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
|
||||
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
|
||||
pub use game_plugin::{
|
||||
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
||||
ReplayPath,
|
||||
@@ -104,9 +115,9 @@ pub use game_plugin::{
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||
pub use hud_plugin::{
|
||||
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
|
||||
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
|
||||
StreakFlourish, UndoButton,
|
||||
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility,
|
||||
MenuButton, MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton,
|
||||
PauseButton, StreakFlourish, UndoButton,
|
||||
};
|
||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
@@ -131,6 +142,7 @@ pub use settings_plugin::{
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
||||
pub use selection_plugin::{
|
||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||
};
|
||||
@@ -141,6 +153,7 @@ pub use stats_plugin::{
|
||||
StatsScreen, StatsUpdate, WatchReplayButton,
|
||||
};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
pub use sync_setup_plugin::SyncSetupPlugin;
|
||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||
pub use ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! Slides:
|
||||
//!
|
||||
//! 1. **Welcome** — brief introduction to Solitaire Quest.
|
||||
//! 1. **Welcome** — brief introduction to Ferrous Solitaire.
|
||||
//! 2. **How to play** — drag-and-drop, double-click, and right-click hints.
|
||||
//! 3. **Keyboard shortcuts** — a summary pulled from the same canonical list
|
||||
//! used in `HelpScreen`. Accelerators: `Esc` anywhere in the flow skips
|
||||
@@ -41,7 +41,13 @@ use crate::ui_theme::{
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1).
|
||||
///
|
||||
/// Android omits the keyboard-shortcuts slide (index 2) because there is no
|
||||
/// physical keyboard on a touchscreen device, dropping the count to 2.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const SLIDE_COUNT: u8 = 3;
|
||||
#[cfg(target_os = "android")]
|
||||
const SLIDE_COUNT: u8 = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components (private — never re-exported)
|
||||
@@ -276,6 +282,8 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
|
||||
match index {
|
||||
0 => spawn_slide_welcome(commands, font_res),
|
||||
1 => spawn_slide_how_to_play(commands, font_res),
|
||||
// Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
2 => spawn_slide_hotkeys(commands, font_res),
|
||||
_ => spawn_slide_welcome(commands, font_res),
|
||||
}
|
||||
@@ -284,10 +292,10 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
|
||||
/// Slide 1 — Welcome.
|
||||
fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||
spawn_modal_header(card, "Welcome to Solitaire Quest", font_res);
|
||||
spawn_modal_header(card, "Welcome to Ferrous Solitaire", font_res);
|
||||
spawn_modal_body_text(
|
||||
card,
|
||||
"Solitaire Quest is a free, offline-first Klondike Solitaire game. \
|
||||
"Ferrous Solitaire is a free, offline-first Klondike Solitaire game. \
|
||||
Play classic draw-1 or draw-3 Klondike, earn XP, unlock achievements, \
|
||||
and compete on the leaderboard. Your progress is saved locally — \
|
||||
optional sync to your own server keeps it in step across all your devices.",
|
||||
@@ -664,8 +672,15 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn slide_count_constant_is_three() {
|
||||
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3");
|
||||
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3 on desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "android")]
|
||||
fn slide_count_constant_is_two_on_android() {
|
||||
assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::resources::{DragState, GameStateResource};
|
||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
use crate::hud_plugin::HudPopoverOpen;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant, ModalScrim,
|
||||
@@ -52,9 +53,13 @@ pub struct PausedResource(pub bool);
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PauseScreen;
|
||||
|
||||
/// Marker on the draw-mode toggle button inside the pause overlay.
|
||||
/// Marker on the "Draw 1" option button inside the pause overlay.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseDrawToggle;
|
||||
struct PauseDrawOneButton;
|
||||
|
||||
/// Marker on the "Draw 3" option button inside the pause overlay.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseDrawThreeButton;
|
||||
|
||||
/// Marker on the Resume primary button on the pause modal.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -117,12 +122,13 @@ impl Plugin for PausePlugin {
|
||||
toggle_pause
|
||||
.before(SelectionKeySet)
|
||||
.before(handle_forfeit_keyboard),
|
||||
handle_pause_draw_toggle,
|
||||
handle_pause_draw_buttons,
|
||||
handle_pause_resume_button,
|
||||
handle_pause_forfeit_button,
|
||||
handle_forfeit_request,
|
||||
handle_forfeit_confirm_buttons,
|
||||
handle_forfeit_keyboard,
|
||||
auto_resume_on_overlay,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -137,6 +143,7 @@ struct PauseModalQueries<'w, 's> {
|
||||
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
|
||||
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
|
||||
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
||||
open_hud_popovers: Query<'w, 's, Entity, With<HudPopoverOpen>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -162,6 +169,7 @@ fn toggle_pause(
|
||||
forfeit_screens,
|
||||
game_over_screens,
|
||||
other_modal_scrims,
|
||||
open_hud_popovers,
|
||||
} = modal_queries;
|
||||
|
||||
// Either Esc or a click on the HUD "Pause" button (which fires
|
||||
@@ -186,6 +194,12 @@ fn toggle_pause(
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
// A HUD popover (Menu or Modes dropdown) is open — the popover's own
|
||||
// Escape handler (in HudPlugin) will close it this frame. Don't also
|
||||
// spawn the pause overlay on top of the closing popover.
|
||||
if !open_hud_popovers.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
|
||||
// own the Esc press — that handler stops the replay. Without this guard a
|
||||
// single Esc both stops the replay AND opens the pause modal on top of the
|
||||
@@ -240,12 +254,14 @@ fn toggle_pause(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the draw-mode toggle button on the pause overlay.
|
||||
/// Handles the draw-mode segmented control on the pause overlay.
|
||||
///
|
||||
/// Toggling flips the draw mode in `SettingsResource`, persists settings, and
|
||||
/// fires `SettingsChangedEvent`. The change takes effect on the next new game.
|
||||
fn handle_pause_draw_toggle(
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>,
|
||||
/// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
|
||||
/// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
|
||||
/// so the rest of the engine sees the update. Change takes effect next game.
|
||||
fn handle_pause_draw_buttons(
|
||||
draw_one_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawOneButton>)>,
|
||||
draw_three_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawThreeButton>)>,
|
||||
paused: Res<PausedResource>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
path: Option<Res<SettingsStoragePath>>,
|
||||
@@ -254,22 +270,23 @@ fn handle_pause_draw_toggle(
|
||||
if !paused.0 {
|
||||
return;
|
||||
}
|
||||
let Some(mut settings) = settings else { return };
|
||||
for interaction in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
settings.0.draw_mode = match settings.0.draw_mode {
|
||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||
};
|
||||
if let Some(p) = &path
|
||||
&& let Some(target) = &p.0
|
||||
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
||||
warn!("failed to save settings after draw-mode toggle: {e}");
|
||||
}
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
|
||||
let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !pressed_one && !pressed_three {
|
||||
return;
|
||||
}
|
||||
let Some(mut settings) = settings else { return };
|
||||
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
|
||||
if settings.0.draw_mode == new_mode {
|
||||
return;
|
||||
}
|
||||
settings.0.draw_mode = new_mode;
|
||||
if let Some(p) = &path
|
||||
&& let Some(target) = &p.0
|
||||
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
||||
warn!("failed to save settings after draw-mode change: {e}");
|
||||
}
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
|
||||
/// Closes the pause modal when the player clicks the Resume button.
|
||||
@@ -414,6 +431,27 @@ fn close_forfeit_modal(
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatically closes the pause modal when any non-pause overlay opens
|
||||
/// on top of it (Stats, Settings, Help, Achievements, Profile, etc.).
|
||||
///
|
||||
/// The player reaches these overlays via the HUD menu while paused, which
|
||||
/// causes both the pause modal and the overlay to be live simultaneously.
|
||||
/// That is always unintentional — the overlay should own the screen.
|
||||
fn auto_resume_on_overlay(
|
||||
mut commands: Commands,
|
||||
pause_screens: Query<Entity, With<PauseScreen>>,
|
||||
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
) {
|
||||
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
for entity in &pause_screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
paused.0 = false;
|
||||
}
|
||||
|
||||
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
||||
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
|
||||
/// action buttons, plus a Draw Mode toggle row when settings are
|
||||
@@ -460,8 +498,10 @@ fn spawn_pause_screen(
|
||||
});
|
||||
}
|
||||
|
||||
/// Inline "Draw Mode [Draw 1]" row + a caption explaining the change
|
||||
/// applies to the next game. Spawned inside the modal body.
|
||||
/// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
|
||||
///
|
||||
/// The active option renders as `Secondary` (elevated), the inactive one as
|
||||
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
|
||||
fn spawn_draw_mode_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
mode: DrawMode,
|
||||
@@ -477,6 +517,10 @@ fn spawn_draw_mode_row(
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let (one_variant, three_variant) = match mode {
|
||||
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
||||
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
||||
};
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
@@ -490,14 +534,8 @@ fn spawn_draw_mode_row(
|
||||
label_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
spawn_modal_button(
|
||||
row,
|
||||
PauseDrawToggle,
|
||||
draw_mode_label(mode),
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
|
||||
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
|
||||
});
|
||||
parent.spawn((
|
||||
Text::new("Takes effect next game"),
|
||||
@@ -781,9 +819,9 @@ mod tests {
|
||||
// Set paused so handle_pause_draw_toggle acts.
|
||||
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
||||
|
||||
// Spawn a PauseDrawToggle button with Pressed interaction.
|
||||
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
|
||||
app.world_mut().spawn((
|
||||
PauseDrawToggle,
|
||||
PauseDrawThreeButton,
|
||||
Button,
|
||||
Interaction::Pressed,
|
||||
));
|
||||
@@ -798,18 +836,16 @@ mod tests {
|
||||
assert_eq!(
|
||||
*mode,
|
||||
DrawMode::DrawThree,
|
||||
"draw mode must flip from DrawOne to DrawThree when toggle is pressed"
|
||||
"pressing Draw 3 must set mode to DrawThree"
|
||||
);
|
||||
|
||||
// A second press should flip back.
|
||||
{
|
||||
let mut interaction_query = app
|
||||
.world_mut()
|
||||
.query::<&mut Interaction>();
|
||||
for mut i in interaction_query.iter_mut(app.world_mut()) {
|
||||
*i = Interaction::Pressed;
|
||||
}
|
||||
}
|
||||
// Pressing "Draw 1" while DrawThree is active should switch back.
|
||||
app.world_mut().spawn((
|
||||
PauseDrawOneButton,
|
||||
Button,
|
||||
Interaction::Pressed,
|
||||
));
|
||||
|
||||
app.update();
|
||||
|
||||
let mode2 = &app
|
||||
@@ -820,7 +856,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
*mode2,
|
||||
DrawMode::DrawOne,
|
||||
"draw mode must flip back from DrawThree to DrawOne on second press"
|
||||
"pressing Draw 1 must set mode to DrawOne"
|
||||
);
|
||||
|
||||
// Verify a SettingsChangedEvent was fired.
|
||||
@@ -1084,6 +1120,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// When a non-pause modal scrim appears (e.g. Settings overlay opens
|
||||
/// from the menu while game is paused), `auto_resume_on_overlay` must
|
||||
/// despawn the pause modal and clear `PausedResource`.
|
||||
#[test]
|
||||
fn auto_resume_closes_pause_when_overlay_opens() {
|
||||
let mut app = headless_app();
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
assert!(app.world().resource::<PausedResource>().0);
|
||||
assert_eq!(
|
||||
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
|
||||
1
|
||||
);
|
||||
|
||||
// Simulate another overlay opening (e.g. Stats) by spawning a bare ModalScrim.
|
||||
app.world_mut().spawn(ModalScrim);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
!app.world().resource::<PausedResource>().0,
|
||||
"auto_resume_on_overlay must clear PausedResource when another modal opens"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
|
||||
0,
|
||||
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_confirm_y_also_closes_pause_modal() {
|
||||
let mut app = forfeit_app();
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
//! Play-by-Seed dialog: lets the player type a decimal seed number and start
|
||||
//! a Classic game with that exact deal. A live solver-verification badge
|
||||
//! updates asynchronously after a short typing debounce so the player knows
|
||||
//! whether the deal is provably winnable before committing.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. `HomePlugin` fires [`StartPlayBySeedRequestEvent`] when the "Play by
|
||||
//! Seed" card is clicked (or `6` is pressed in the Mode Launcher).
|
||||
//! 2. `handle_open_dialog` reads the event and spawns the seed-input modal.
|
||||
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
|
||||
//! the modal is open, updating [`SeedInputBuffer`] each frame.
|
||||
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
|
||||
//! 60 Hz) of no input before spawning a [`try_solve`] task on
|
||||
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
|
||||
//! by resetting the resource.
|
||||
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
|
||||
//! [`SolverVerdictBadge`] text node with the verdict.
|
||||
//! 6. `handle_confirm` fires [`NewGameRequestEvent`] with the parsed seed and
|
||||
//! despawns the dialog on Play click or `Enter`.
|
||||
//! 7. `handle_cancel` despawns the dialog on Cancel click or `Escape`.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
|
||||
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal_header,
|
||||
ButtonVariant, ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_PRESSED, BORDER_SUBTLE, HighContrastBorder, RADIUS_MD,
|
||||
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3,
|
||||
Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components and resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the seed-input modal scrim (the despawn root).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PlayBySeedScreen;
|
||||
|
||||
/// Holds the decimal digit string the player is typing and a frame counter
|
||||
/// used to debounce solver task spawning.
|
||||
#[derive(Component, Debug, Default)]
|
||||
struct SeedInputBuffer {
|
||||
/// Raw decimal digit string. Never longer than 20 chars (u64::MAX is 20
|
||||
/// decimal digits). Empty means "no seed entered".
|
||||
text: String,
|
||||
/// Frames elapsed since the last keystroke. The solver task is spawned
|
||||
/// once this crosses [`DEBOUNCE_FRAMES`] and the buffer is non-empty.
|
||||
frames_since_change: u32,
|
||||
}
|
||||
|
||||
/// Marker on the text node that renders the solver verdict caption.
|
||||
#[derive(Component, Debug)]
|
||||
struct SolverVerdictBadge;
|
||||
|
||||
/// Marker on the Play (confirm) button so `handle_confirm` can find it.
|
||||
#[derive(Component, Debug)]
|
||||
struct PlayBySeedConfirmButton;
|
||||
|
||||
/// Marker on the Cancel button.
|
||||
#[derive(Component, Debug)]
|
||||
struct PlayBySeedCancelButton;
|
||||
|
||||
/// Marker on the input-field text node so `handle_text_input` can update
|
||||
/// it without a separate query for the buffer entity.
|
||||
#[derive(Component, Debug)]
|
||||
struct SeedInputDisplay;
|
||||
|
||||
/// In-flight async solver verification task. At most one is live at a time —
|
||||
/// a fresh keypress resets this resource (dropping the previous `Task<_>`)
|
||||
/// before spawning the next one.
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingVerification {
|
||||
seed: Option<u64>,
|
||||
handle: Option<Task<SolverResult>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frames of no-keypress activity before the solver task is spawned.
|
||||
/// 12 frames ≈ 200 ms at 60 Hz — long enough to avoid thrashing on fast
|
||||
/// typists but short enough to feel responsive.
|
||||
const DEBOUNCE_FRAMES: u32 = 12;
|
||||
|
||||
/// Maximum decimal digits accepted. 20 covers all of u64::MAX (18,446,744,073,709,551,615).
|
||||
const MAX_SEED_DIGITS: usize = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers all play-by-seed systems and resources.
|
||||
pub struct PlayBySeedPlugin;
|
||||
|
||||
impl Plugin for PlayBySeedPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<PendingVerification>()
|
||||
.add_message::<StartPlayBySeedRequestEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_open_dialog,
|
||||
handle_text_input,
|
||||
tick_debounce_and_spawn_solver_task,
|
||||
poll_solver_task,
|
||||
handle_confirm,
|
||||
handle_cancel,
|
||||
)
|
||||
.chain()
|
||||
// Fire before GameMutation so `handle_confirm`'s
|
||||
// NewGameRequestEvent is processed on the same frame.
|
||||
.before(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns the seed-input dialog when `StartPlayBySeedRequestEvent` fires.
|
||||
fn handle_open_dialog(
|
||||
mut commands: Commands,
|
||||
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
existing: Query<(), With<PlayBySeedScreen>>,
|
||||
) {
|
||||
if requests.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
// Guard against double-spawn (e.g. two events in one frame).
|
||||
if !existing.is_empty() {
|
||||
return;
|
||||
}
|
||||
let font = font_res.as_deref();
|
||||
let font_handle = font.map(|f| f.0.clone()).unwrap_or_default();
|
||||
|
||||
let scrim = spawn_modal(&mut commands, PlayBySeedScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Play by Seed", font);
|
||||
spawn_modal_body_text(
|
||||
card,
|
||||
"Enter a number to play that specific deal.",
|
||||
TEXT_SECONDARY,
|
||||
font,
|
||||
);
|
||||
|
||||
// Input field — a bordered box that shows the typed digits.
|
||||
card.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED_PRESSED),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
SeedInputBuffer::default(),
|
||||
))
|
||||
.with_children(|field| {
|
||||
field.spawn((
|
||||
SeedInputDisplay,
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DISABLED),
|
||||
));
|
||||
});
|
||||
|
||||
// Solver verdict badge — updates as solver runs.
|
||||
card.spawn((
|
||||
SolverVerdictBadge,
|
||||
Text::new("Type a number"),
|
||||
TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
spawn_modal_actions(card, |row| {
|
||||
spawn_modal_button(
|
||||
row,
|
||||
PlayBySeedCancelButton,
|
||||
"Cancel",
|
||||
Some("Esc"),
|
||||
ButtonVariant::Secondary,
|
||||
font,
|
||||
);
|
||||
spawn_modal_button(
|
||||
row,
|
||||
PlayBySeedConfirmButton,
|
||||
"Play",
|
||||
Some("Enter"),
|
||||
ButtonVariant::Primary,
|
||||
font,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Play-by-Seed is read-only input — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Appends decimal digits and handles Backspace while the dialog is open.
|
||||
fn handle_text_input(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screen: Query<(), With<PlayBySeedScreen>>,
|
||||
mut buffers: Query<&mut SeedInputBuffer>,
|
||||
mut displays: Query<(&mut Text, &mut TextColor), With<SeedInputDisplay>>,
|
||||
mut pending: ResMut<PendingVerification>,
|
||||
) {
|
||||
if screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(mut buf) = buffers.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let digit_keys = [
|
||||
(KeyCode::Digit0, '0'),
|
||||
(KeyCode::Digit1, '1'),
|
||||
(KeyCode::Digit2, '2'),
|
||||
(KeyCode::Digit3, '3'),
|
||||
(KeyCode::Digit4, '4'),
|
||||
(KeyCode::Digit5, '5'),
|
||||
(KeyCode::Digit6, '6'),
|
||||
(KeyCode::Digit7, '7'),
|
||||
(KeyCode::Digit8, '8'),
|
||||
(KeyCode::Digit9, '9'),
|
||||
(KeyCode::Numpad0, '0'),
|
||||
(KeyCode::Numpad1, '1'),
|
||||
(KeyCode::Numpad2, '2'),
|
||||
(KeyCode::Numpad3, '3'),
|
||||
(KeyCode::Numpad4, '4'),
|
||||
(KeyCode::Numpad5, '5'),
|
||||
(KeyCode::Numpad6, '6'),
|
||||
(KeyCode::Numpad7, '7'),
|
||||
(KeyCode::Numpad8, '8'),
|
||||
(KeyCode::Numpad9, '9'),
|
||||
];
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
for (key, ch) in digit_keys {
|
||||
if keys.just_pressed(key) && buf.text.len() < MAX_SEED_DIGITS {
|
||||
// Drop a leading zero unless the buffer is empty (prevents "007").
|
||||
if ch == '0' && buf.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
buf.text.push(ch);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::Backspace) && !buf.text.is_empty() {
|
||||
buf.text.pop();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
buf.frames_since_change = 0;
|
||||
// Cancel any in-flight solver task — its seed is now stale.
|
||||
*pending = PendingVerification::default();
|
||||
|
||||
// Update the display node.
|
||||
if let Ok((mut text, mut color)) = displays.single_mut() {
|
||||
if buf.text.is_empty() {
|
||||
text.0 = String::new();
|
||||
color.0 = TEXT_DISABLED;
|
||||
} else {
|
||||
text.0 = buf.text.clone();
|
||||
color.0 = TEXT_PRIMARY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Increments the debounce counter each frame and spawns the solver task
|
||||
/// once the counter passes [`DEBOUNCE_FRAMES`] and the buffer holds a
|
||||
/// valid u64.
|
||||
fn tick_debounce_and_spawn_solver_task(
|
||||
screen: Query<(), With<PlayBySeedScreen>>,
|
||||
mut buffers: Query<&mut SeedInputBuffer>,
|
||||
mut pending: ResMut<PendingVerification>,
|
||||
mut badges: Query<(&mut Text, &mut TextColor), With<SolverVerdictBadge>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
if screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(mut buf) = buffers.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Always update the badge when the buffer is empty.
|
||||
if buf.text.is_empty() {
|
||||
if let Ok((mut text, mut color)) = badges.single_mut() {
|
||||
text.0 = "Type a number".to_string();
|
||||
color.0 = TEXT_SECONDARY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't spawn if a task is already running for this seed.
|
||||
let parsed = buf.text.parse::<u64>().ok();
|
||||
if pending.handle.is_some() && pending.seed == parsed {
|
||||
return;
|
||||
}
|
||||
|
||||
buf.frames_since_change = buf.frames_since_change.saturating_add(1);
|
||||
if buf.frames_since_change < DEBOUNCE_FRAMES {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(seed) = parsed else {
|
||||
return;
|
||||
};
|
||||
|
||||
let draw_mode = settings
|
||||
.as_ref()
|
||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
|
||||
let cfg = SolverConfig::default();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||
|
||||
pending.seed = Some(seed);
|
||||
pending.handle = Some(task);
|
||||
|
||||
if let Ok((mut text, mut color)) = badges.single_mut() {
|
||||
text.0 = "Verifying\u{2026}".to_string();
|
||||
color.0 = TEXT_SECONDARY;
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the in-flight solver task and updates the verdict badge on completion.
|
||||
fn poll_solver_task(
|
||||
mut pending: ResMut<PendingVerification>,
|
||||
mut badges: Query<(&mut Text, &mut TextColor), With<SolverVerdictBadge>>,
|
||||
) {
|
||||
let Some(handle) = pending.handle.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(handle)) else {
|
||||
return;
|
||||
};
|
||||
pending.handle = None;
|
||||
|
||||
let Ok((mut text, mut color)) = badges.single_mut() else {
|
||||
return;
|
||||
};
|
||||
match result {
|
||||
SolverResult::Winnable => {
|
||||
text.0 = "\u{2713} Provably winnable".to_string();
|
||||
color.0 = ACCENT_PRIMARY;
|
||||
}
|
||||
SolverResult::Inconclusive => {
|
||||
text.0 = "? Likely winnable (search timed out)".to_string();
|
||||
color.0 = TEXT_SECONDARY;
|
||||
}
|
||||
SolverResult::Unwinnable => {
|
||||
text.0 = "\u{2717} Provably unwinnable".to_string();
|
||||
color.0 = TEXT_DISABLED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires [`NewGameRequestEvent`] with the parsed seed when Play is clicked
|
||||
/// or `Enter` is pressed, then despawns the dialog. Does nothing when the
|
||||
/// buffer is empty.
|
||||
fn handle_confirm(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Query<&Interaction, (With<PlayBySeedConfirmButton>, Changed<Interaction>)>,
|
||||
buffers: Query<&SeedInputBuffer>,
|
||||
screen: Query<Entity, With<PlayBySeedScreen>>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
if screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let enter = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter);
|
||||
if !click && !enter {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(buf) = buffers.single() else { return };
|
||||
let Ok(seed) = buf.text.parse::<u64>() else { return };
|
||||
|
||||
new_game.write(NewGameRequestEvent {
|
||||
seed: Some(seed),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
|
||||
for entity in &screen {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the dialog on Cancel click or `Escape`.
|
||||
fn handle_cancel(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Query<&Interaction, (With<PlayBySeedCancelButton>, Changed<Interaction>)>,
|
||||
screen: Query<Entity, With<PlayBySeedScreen>>,
|
||||
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
|
||||
) {
|
||||
if screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
// Esc only closes this dialog when it is the topmost modal.
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && other_scrims.is_empty();
|
||||
if !click && !esc {
|
||||
return;
|
||||
}
|
||||
|
||||
for entity in &screen {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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(PlayBySeedPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn open_dialog(app: &mut App) {
|
||||
app.world_mut()
|
||||
.write_message(StartPlayBySeedRequestEvent);
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn press_key(app: &mut App, key: KeyCode) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(key);
|
||||
app.update();
|
||||
// Simulate what Bevy's PreUpdate input system does: flush just_pressed /
|
||||
// just_released so stale key state doesn't bleed into the next frame.
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
}
|
||||
|
||||
fn dialog_present(app: &mut App) -> bool {
|
||||
app.world_mut()
|
||||
.query::<&PlayBySeedScreen>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn read_buffer_text(app: &mut App) -> String {
|
||||
let mut q = app.world_mut().query::<&SeedInputBuffer>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|b| b.text.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_spawns_on_request() {
|
||||
let mut app = headless_app();
|
||||
assert!(!dialog_present(&mut app));
|
||||
open_dialog(&mut app);
|
||||
assert!(dialog_present(&mut app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digit_keys_append_to_buffer() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
press_key(&mut app, KeyCode::Digit4);
|
||||
press_key(&mut app, KeyCode::Digit2);
|
||||
|
||||
assert_eq!(read_buffer_text(&mut app), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_last_char() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
press_key(&mut app, KeyCode::Digit4);
|
||||
press_key(&mut app, KeyCode::Digit2);
|
||||
press_key(&mut app, KeyCode::Backspace);
|
||||
|
||||
assert_eq!(read_buffer_text(&mut app), "4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_does_nothing_when_buffer_is_empty() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
// Simulate Enter with empty buffer.
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
assert!(cursor.read(msgs).next().is_none(), "no NewGameRequestEvent when buffer empty");
|
||||
// Dialog should still be open.
|
||||
assert!(dialog_present(&mut app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_writes_new_game_request_with_parsed_seed() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
press_key(&mut app, KeyCode::Digit4);
|
||||
press_key(&mut app, KeyCode::Digit2);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(msgs).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].seed, Some(42));
|
||||
assert_eq!(fired[0].mode, None);
|
||||
assert!(!fired[0].confirmed);
|
||||
|
||||
// Dialog should be gone.
|
||||
assert!(!dialog_present(&mut app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_despawns_dialog_without_new_game_request() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
press_key(&mut app, KeyCode::Escape);
|
||||
|
||||
assert!(!dialog_present(&mut app));
|
||||
|
||||
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
assert!(cursor.read(msgs).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_task_spawns_after_debounce_window() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
press_key(&mut app, KeyCode::Digit4);
|
||||
press_key(&mut app, KeyCode::Digit2);
|
||||
|
||||
// Debounce window — no task yet.
|
||||
for _ in 0..DEBOUNCE_FRAMES {
|
||||
app.update();
|
||||
}
|
||||
|
||||
let pending = app.world().resource::<PendingVerification>();
|
||||
assert!(pending.handle.is_some(), "solver task should have been spawned after debounce");
|
||||
assert_eq!(pending.seed, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keypress_mid_flight_cancels_previous_solver_task() {
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
press_key(&mut app, KeyCode::Digit4);
|
||||
press_key(&mut app, KeyCode::Digit2);
|
||||
|
||||
// Let the debounce fire.
|
||||
for _ in 0..DEBOUNCE_FRAMES {
|
||||
app.update();
|
||||
}
|
||||
assert!(app.world().resource::<PendingVerification>().handle.is_some());
|
||||
|
||||
// New keypress should cancel the in-flight task.
|
||||
press_key(&mut app, KeyCode::Digit3);
|
||||
assert!(app.world().resource::<PendingVerification>().handle.is_none());
|
||||
assert_eq!(app.world().resource::<PendingVerification>().seed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_task_completes_and_updates_badge() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut app = headless_app();
|
||||
open_dialog(&mut app);
|
||||
|
||||
// Seed 42 — solver will return some verdict.
|
||||
press_key(&mut app, KeyCode::Digit4);
|
||||
press_key(&mut app, KeyCode::Digit2);
|
||||
|
||||
// Wait for the debounce to spawn the task.
|
||||
for _ in 0..DEBOUNCE_FRAMES {
|
||||
app.update();
|
||||
}
|
||||
|
||||
// Poll until the solver task resolves (cap at 15 s wall-clock).
|
||||
let deadline = Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingVerification>().handle.is_some()
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
// Badge text should no longer read "Verifying…".
|
||||
let badge_text = app
|
||||
.world_mut()
|
||||
.query::<(&Text, &SolverVerdictBadge)>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.map(|(t, _)| t.0.clone())
|
||||
.unwrap_or_default();
|
||||
assert_ne!(badge_text, "Verifying\u{2026}", "badge should have resolved to a verdict");
|
||||
assert_ne!(badge_text, "Type a number", "badge should show verdict, not idle state");
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
use solitaire_core::achievement::achievement_by_id;
|
||||
use solitaire_core::achievement::{achievement_by_id, ALL_ACHIEVEMENTS};
|
||||
use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
@@ -323,7 +323,7 @@ fn spawn_profile_screen(
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
body.spawn((
|
||||
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||
Text::new(format!("{unlocked_count} / {} unlocked", ALL_ACHIEVEMENTS.len())),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
|
||||
//! neither.
|
||||
|
||||
use bevy::input::touch::Touches;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
@@ -59,6 +60,11 @@ use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
|
||||
|
||||
/// Seconds a finger must be held on a face-up card (without crossing the
|
||||
/// drag threshold) before the radial menu opens. Matches Android's long-press
|
||||
/// gesture recogniser default.
|
||||
const LONG_PRESS_SECS: f32 = 0.5;
|
||||
|
||||
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
||||
///
|
||||
/// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render
|
||||
@@ -181,6 +187,7 @@ impl Plugin for RadialMenuPlugin {
|
||||
Update,
|
||||
(
|
||||
radial_open_on_right_click,
|
||||
radial_open_on_long_press,
|
||||
radial_track_cursor,
|
||||
radial_handle_release_or_cancel,
|
||||
radial_redraw_overlay,
|
||||
@@ -446,6 +453,68 @@ fn radial_open_on_right_click(
|
||||
};
|
||||
}
|
||||
|
||||
/// Opens the radial menu after a sustained touch hold on a face-up card.
|
||||
///
|
||||
/// Counts up while the touch is down, the drag threshold has not been
|
||||
/// crossed, and the radial is not yet active. Fires after
|
||||
/// [`LONG_PRESS_SECS`] (0.5 s). The timer resets whenever these
|
||||
/// conditions are not met, so lifting, committing a drag, or the radial
|
||||
/// already being open all clear it cleanly.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn radial_open_on_long_press(
|
||||
time: Res<Time>,
|
||||
mut hold_timer: Local<f32>,
|
||||
drag: Res<DragState>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
) {
|
||||
// Guard: only count while a touch is down, uncommitted, and radial is idle.
|
||||
let active_id = drag.active_touch_id;
|
||||
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
||||
*hold_timer = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
*hold_timer += time.delta_secs();
|
||||
if *hold_timer < LONG_PRESS_SECS {
|
||||
return;
|
||||
}
|
||||
*hold_timer = 0.0;
|
||||
|
||||
// Resolve current touch world position.
|
||||
let Some(touches) = touches else { return };
|
||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
|
||||
return;
|
||||
};
|
||||
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
||||
let Some(world) = camera.viewport_to_world_2d(cam_xf, touch.position()).ok() else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(game) = game else { return };
|
||||
|
||||
let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else {
|
||||
return;
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
|
||||
if dests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let legal_destinations = build_radial_destinations(world, dests);
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count: 1,
|
||||
cards: vec![card.id],
|
||||
legal_destinations,
|
||||
centre: world,
|
||||
hovered_index: None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Each frame while `Active`, updates `hovered_index` based on the
|
||||
/// current cursor position. Cheap — just re-runs hit-testing against
|
||||
/// the precomputed anchors. The overlay redraw system reads this index
|
||||
@@ -454,6 +523,7 @@ fn radial_track_cursor(
|
||||
cursor_override: Option<Res<RadialCursorOverride>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
touches: Option<Res<Touches>>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
) {
|
||||
let RightClickRadialState::Active {
|
||||
@@ -464,21 +534,28 @@ fn radial_track_cursor(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
// Cursor first (mouse / test override); fall back to first active touch
|
||||
// so the player can slide their held finger over radial icons on Android.
|
||||
let world = cursor_world(cursor_override.as_ref(), &windows, &cameras).or_else(|| {
|
||||
let (camera, cam_xf) = cameras.single().ok()?;
|
||||
let touch_pos = touches.as_ref()?.iter().next()?.position();
|
||||
camera.viewport_to_world_2d(cam_xf, touch_pos).ok()
|
||||
});
|
||||
let Some(world) = world else { return };
|
||||
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
|
||||
*hovered_index = radial_hovered_index(world, &anchors);
|
||||
}
|
||||
|
||||
/// Handles three exit conditions while `Active`:
|
||||
/// Handles exit conditions while `Active`:
|
||||
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
|
||||
/// 2. `Escape` → cancel.
|
||||
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
|
||||
/// 2. Touch lift (`Touches::iter_just_released`) → confirm if hovering, cancel otherwise.
|
||||
/// 3. `Escape` → cancel.
|
||||
/// 4. Left-mouse press → cancel (keeps the existing drag pipeline clean).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn radial_handle_release_or_cancel(
|
||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
) {
|
||||
@@ -495,13 +572,18 @@ fn radial_handle_release_or_cancel(
|
||||
let left_pressed = buttons
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.just_pressed(MouseButton::Left));
|
||||
// Finger lift: any touch that ended or was cancelled this frame.
|
||||
let touch_ended = touches.as_ref().is_some_and(|t| {
|
||||
t.iter_just_released().next().is_some() || t.iter_just_canceled().next().is_some()
|
||||
});
|
||||
|
||||
if !escape_pressed && !right_released && !left_pressed {
|
||||
if !escape_pressed && !right_released && !left_pressed && !touch_ended {
|
||||
return;
|
||||
}
|
||||
|
||||
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
|
||||
if right_released
|
||||
// On confirm (right-release or touch-lift while hovering), fire a move.
|
||||
let confirm = right_released || touch_ended;
|
||||
if confirm
|
||||
&& let RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count,
|
||||
@@ -719,7 +801,7 @@ mod tests {
|
||||
|
||||
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
|
||||
app.insert_resource(GameStateResource(state));
|
||||
app.insert_resource(LayoutResource(compute_layout(layout_window)));
|
||||
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0, true)));
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
|
||||
}
|
||||
|
||||
@@ -831,7 +913,7 @@ mod tests {
|
||||
fn right_click_press_on_face_up_card_opens_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
@@ -868,7 +950,7 @@ mod tests {
|
||||
fn right_click_release_over_destination_fires_move_request() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
@@ -907,7 +989,7 @@ mod tests {
|
||||
fn right_click_release_outside_any_destination_cancels() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
@@ -934,7 +1016,7 @@ mod tests {
|
||||
fn escape_cancels_active_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
@@ -957,7 +1039,7 @@ mod tests {
|
||||
fn right_click_on_face_down_card_does_not_open_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
//! Safe-area insets.
|
||||
//!
|
||||
//! Reports the OS-reserved regions around the playable surface (status
|
||||
//! bar at the top, gesture / navigation bar at the bottom on Android,
|
||||
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
|
||||
//! collisions.
|
||||
//!
|
||||
//! On non-Android targets all four edges report `0.0`. On Android the
|
||||
//! values come from `WindowInsets.getInsets(WindowInsets.Type.systemBars())`
|
||||
//! via JNI; the call is retried for the first few frames because
|
||||
//! `getRootWindowInsets()` only returns useful values after the decor
|
||||
//! view has been laid out at least once.
|
||||
//!
|
||||
//! UI that wants to respect the top inset should tag itself with the
|
||||
//! [`SafeAreaAnchoredTop`] marker carrying the layout's original top
|
||||
//! offset; [`apply_safe_area_anchors`] re-applies `base_top + insets.top`
|
||||
//! whenever the resource changes, so late inset arrival or orientation
|
||||
//! changes flow through automatically.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::ui_modal::ModalScrim;
|
||||
|
||||
/// Pixel sizes of the system-reserved regions on each edge of the
|
||||
/// surface. Zero on desktop.
|
||||
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct SafeAreaInsets {
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
impl SafeAreaInsets {
|
||||
/// `true` when any edge has a non-zero reservation. Used by the
|
||||
/// Android polling system to know it can stop querying.
|
||||
pub fn is_populated(&self) -> bool {
|
||||
self.top > 0.0 || self.bottom > 0.0 || self.left > 0.0 || self.right > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for `Node` entities whose `top` offset should be re-applied
|
||||
/// as `base_top + SafeAreaInsets::top`.
|
||||
///
|
||||
/// `base_top` is the offset the layout would have used on a surface
|
||||
/// with no system reservation (i.e. on desktop). The fix-up system
|
||||
/// adds the current top inset on top of it whenever the resource
|
||||
/// changes.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct SafeAreaAnchoredTop {
|
||||
pub base_top: f32,
|
||||
}
|
||||
|
||||
pub struct SafeAreaInsetsPlugin;
|
||||
|
||||
impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SafeAreaInsets>()
|
||||
.add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, android::refresh_insets);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-applies `base_top + insets.top` to every entity carrying the
|
||||
/// [`SafeAreaAnchoredTop`] marker whenever [`SafeAreaInsets`] changes.
|
||||
///
|
||||
/// Bevy resource change detection (`Res::is_changed`) is `true` on the
|
||||
/// frame the resource is inserted and every frame a `ResMut` borrow
|
||||
/// occurs. Combined with the Android polling loop short-circuiting
|
||||
/// once insets are populated, this runs at most a handful of times in
|
||||
/// a session.
|
||||
fn apply_safe_area_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
return;
|
||||
}
|
||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||||
// expects logical pixels (≈ dp). Divide by the window scale factor so
|
||||
// the HUD band shifts by the correct number of dp on high-DPI devices.
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let top_logical = insets.top / scale;
|
||||
for (anchor, mut node) in &mut q {
|
||||
node.top = Val::Px(anchor.base_top + top_logical);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
|
||||
/// modal cards don't extend into the Android gesture-navigation zone.
|
||||
///
|
||||
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
|
||||
/// arriving a few frames after app start) AND when a new `ModalScrim` is
|
||||
/// spawned (covers modals opened after insets have already settled).
|
||||
fn apply_safe_area_to_modal_scrims(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||
) {
|
||||
let has_new = !new_scrims.is_empty();
|
||||
if !insets.is_changed() && !has_new {
|
||||
return;
|
||||
}
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let bottom_logical = insets.bottom / scale;
|
||||
for mut node in &mut scrims {
|
||||
node.padding.bottom = Val::Px(bottom_logical);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use super::SafeAreaInsets;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Polls Android for safe-area insets until we get a non-zero
|
||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||
/// is typically frame 1–3 of a fresh launch.
|
||||
pub(super) fn refresh_insets(
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
mut tries: Local<u32>,
|
||||
) {
|
||||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||||
// devices that genuinely report zero insets.
|
||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||
|
||||
if *tries >= MAX_TRIES || insets.is_populated() {
|
||||
return;
|
||||
}
|
||||
*tries += 1;
|
||||
|
||||
match query_insets() {
|
||||
Ok(v) if v.is_populated() => {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, *tries
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Layout not ready yet; try again next frame.
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spam — log once and let polling continue silently.
|
||||
if *tries == 1 {
|
||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{objects::JObject, JavaVM};
|
||||
|
||||
let app = ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
||||
|
||||
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
|
||||
// runtime; valid for the lifetime of the process.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
|
||||
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
|
||||
// pointer — valid for the lifetime of the process.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
||||
|
||||
(|| -> jni::errors::Result<SafeAreaInsets> {
|
||||
// Window window = activity.getWindow();
|
||||
let window = env
|
||||
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||
.l()?;
|
||||
|
||||
// View decor = window.getDecorView();
|
||||
let decor = env
|
||||
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||
.l()?;
|
||||
|
||||
// WindowInsets insets = decor.getRootWindowInsets();
|
||||
let raw_insets = env
|
||||
.call_method(
|
||||
&decor,
|
||||
"getRootWindowInsets",
|
||||
"()Landroid/view/WindowInsets;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
if raw_insets.is_null() {
|
||||
return Ok(SafeAreaInsets::default());
|
||||
}
|
||||
|
||||
// int types = WindowInsets.Type.systemBars();
|
||||
// (Static method on the WindowInsets$Type inner class.
|
||||
// Available since API 30 / Android 11.)
|
||||
let type_class = env.find_class("android/view/WindowInsets$Type")?;
|
||||
let bars_type = env
|
||||
.call_static_method(&type_class, "systemBars", "()I", &[])?
|
||||
.i()?;
|
||||
|
||||
// Insets bars = insets.getInsets(types);
|
||||
let bars = env
|
||||
.call_method(
|
||||
&raw_insets,
|
||||
"getInsets",
|
||||
"(I)Landroid/graphics/Insets;",
|
||||
&[bars_type.into()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// `Insets` exposes `top`, `bottom`, `left`, `right` as public
|
||||
// `int` fields (pixel values, not dp).
|
||||
let top = env.get_field(&bars, "top", "I")?.i()? as f32;
|
||||
let bottom = env.get_field(&bars, "bottom", "I")?.i()? as f32;
|
||||
let left = env.get_field(&bars, "left", "I")?.i()? as f32;
|
||||
let right = env.get_field(&bars, "right", "I")?.i()? as f32;
|
||||
|
||||
Ok(SafeAreaInsets {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
})
|
||||
})()
|
||||
.map_err(|e| format!("safe-area JNI: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_zero_and_not_populated() {
|
||||
let i = SafeAreaInsets::default();
|
||||
assert_eq!(i.top, 0.0);
|
||||
assert_eq!(i.bottom, 0.0);
|
||||
assert!(!i.is_populated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_populated_returns_true_for_any_nonzero_edge() {
|
||||
assert!(SafeAreaInsets {
|
||||
top: 24.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
assert!(SafeAreaInsets {
|
||||
bottom: 16.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
assert!(SafeAreaInsets {
|
||||
left: 8.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
assert!(SafeAreaInsets {
|
||||
right: 8.0,
|
||||
..Default::default()
|
||||
}
|
||||
.is_populated());
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,17 @@ use solitaire_data::{
|
||||
TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
use crate::events::{InfoToastEvent, ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
|
||||
use crate::events::{
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
|
||||
use crate::assets::user_theme_dir;
|
||||
use crate::theme::{import_theme, ImportError, ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry};
|
||||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
@@ -34,7 +40,8 @@ use crate::ui_modal::{
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder,
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
|
||||
HighContrastBorder,
|
||||
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
};
|
||||
@@ -159,6 +166,11 @@ struct WinnableDealsOnlyText;
|
||||
#[derive(Component, Debug)]
|
||||
struct SmartDefaultSizeText;
|
||||
|
||||
/// Marks the `Text` node showing the current "Share usage data" (analytics)
|
||||
/// state ("ON" / "OFF") in the Privacy section.
|
||||
#[derive(Component, Debug)]
|
||||
struct AnalyticsEnabledText;
|
||||
|
||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
@@ -229,7 +241,19 @@ enum SettingsButton {
|
||||
/// flag only affects launches without saved geometry — the
|
||||
/// player's last window size always wins.
|
||||
ToggleSmartDefaultSize,
|
||||
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
|
||||
/// sync server is configured — there is no server to send to in
|
||||
/// local-only mode.
|
||||
ToggleAnalytics,
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
ScanThemes,
|
||||
SyncNow,
|
||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||
ConnectSync,
|
||||
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||||
DisconnectSync,
|
||||
/// Open the account-deletion confirmation modal.
|
||||
DeleteAccount,
|
||||
Done,
|
||||
/// Select a specific card-back by index from the picker row.
|
||||
SelectCardBack(usize),
|
||||
@@ -268,6 +292,8 @@ impl SettingsButton {
|
||||
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||
// Smart-default-size toggle — sits at the end of Gameplay.
|
||||
SettingsButton::ToggleSmartDefaultSize => 50,
|
||||
// Privacy section — just before Sync.
|
||||
SettingsButton::ToggleAnalytics => 89,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 55,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
@@ -281,8 +307,12 @@ impl SettingsButton {
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
SettingsButton::SelectBackground(_) => 80,
|
||||
SettingsButton::SelectTheme(_) => 85,
|
||||
SettingsButton::ScanThemes => 86,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
SettingsButton::ConnectSync => 91,
|
||||
SettingsButton::DisconnectSync => 92,
|
||||
SettingsButton::DeleteAccount => 93,
|
||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||
// value here is only a fallback for completeness.
|
||||
@@ -332,9 +362,13 @@ impl Plugin for SettingsPlugin {
|
||||
.init_resource::<PendingWindowGeometry>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||
.add_message::<bevy::input::touch::TouchInput>()
|
||||
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||||
// and emitted by the windowing backend under `DefaultPlugins`,
|
||||
// but we register them explicitly here so the geometry watcher
|
||||
@@ -347,6 +381,7 @@ impl Plugin for SettingsPlugin {
|
||||
handle_volume_keys,
|
||||
toggle_settings_screen,
|
||||
scroll_settings_panel,
|
||||
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
|
||||
record_window_geometry_changes,
|
||||
persist_window_geometry_after_debounce,
|
||||
),
|
||||
@@ -358,6 +393,8 @@ impl Plugin for SettingsPlugin {
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
handle_scan_themes,
|
||||
update_sync_status_text,
|
||||
update_card_back_text,
|
||||
update_background_text,
|
||||
@@ -365,16 +402,18 @@ impl Plugin for SettingsPlugin {
|
||||
update_color_blind_text,
|
||||
update_high_contrast_text,
|
||||
update_high_contrast_borders,
|
||||
update_high_contrast_backgrounds,
|
||||
update_reduce_motion_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
update_winnable_deals_only_text,
|
||||
update_smart_default_size_text,
|
||||
update_analytics_enabled_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
scroll_focus_into_view,
|
||||
),
|
||||
);
|
||||
app.add_systems(Update, scroll_focus_into_view);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -674,6 +713,41 @@ fn update_high_contrast_borders(
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints `BackgroundColor` on every entity tagged with
|
||||
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||
/// (`#a0a0a0`). Compares against the current background and only
|
||||
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||
/// repaints every frame.
|
||||
///
|
||||
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||
/// same change-suppression idiom, different colour channel —
|
||||
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||
/// separators that paint their shape directly rather than via a
|
||||
/// `BorderColor` on a wider Node.
|
||||
///
|
||||
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||
///
|
||||
/// More sites can be tagged in follow-ups by adding
|
||||
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||
pub(crate) fn update_high_contrast_backgrounds(
|
||||
settings: Res<SettingsResource>,
|
||||
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||
) {
|
||||
let high_contrast = settings.0.high_contrast_mode;
|
||||
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||
let target = if high_contrast {
|
||||
marker.hc_color
|
||||
} else {
|
||||
marker.default_color
|
||||
};
|
||||
if bg.0 != target {
|
||||
*bg = BackgroundColor(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_reduce_motion_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||
@@ -701,6 +775,20 @@ fn update_winnable_deals_only_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||||
/// whenever `SettingsResource` changes.
|
||||
fn update_analytics_enabled_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = on_off_label(settings.0.analytics_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Smart window size" toggle value whenever
|
||||
/// `SettingsResource` changes. The flag is stored negatively as
|
||||
/// `disable_smart_default_size`, so the label inverts.
|
||||
@@ -803,7 +891,6 @@ fn handle_settings_buttons(
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||
@@ -988,6 +1075,12 @@ fn handle_settings_buttons(
|
||||
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||
// on the next frame via `settings.is_changed()`.
|
||||
}
|
||||
SettingsButton::ToggleAnalytics => {
|
||||
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||
}
|
||||
SettingsButton::ToggleSmartDefaultSize => {
|
||||
settings.0.disable_smart_default_size =
|
||||
!settings.0.disable_smart_default_size;
|
||||
@@ -1016,8 +1109,14 @@ fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::SyncNow => {
|
||||
manual_sync.write(ManualSyncRequestEvent);
|
||||
SettingsButton::ScanThemes => {
|
||||
// Handled by `handle_scan_themes`.
|
||||
}
|
||||
SettingsButton::SyncNow
|
||||
| SettingsButton::ConnectSync
|
||||
| SettingsButton::DisconnectSync
|
||||
| SettingsButton::DeleteAccount => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
SettingsButton::Done => {
|
||||
screen.0 = false;
|
||||
@@ -1026,6 +1125,30 @@ fn handle_settings_buttons(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||
/// Bevy's 16-parameter system limit.
|
||||
fn handle_sync_buttons(
|
||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
match button {
|
||||
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
|
||||
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
|
||||
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
|
||||
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_mode_label(mode: &DrawMode) -> String {
|
||||
match mode {
|
||||
DrawMode::DrawOne => "Draw 1".into(),
|
||||
@@ -1390,6 +1513,7 @@ fn spawn_settings_panel(
|
||||
row_gap: VAL_SPACE_3,
|
||||
max_height: Val::Vh(60.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
padding: UiRect::bottom(Val::Px(96.0)),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
@@ -1556,10 +1680,25 @@ fn spawn_settings_panel(
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
import_themes_row(body, font_res);
|
||||
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
if settings.matomo_url.is_some() {
|
||||
section_label(body, "Privacy", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Share usage data",
|
||||
AnalyticsEnabledText,
|
||||
on_off_label(settings.analytics_enabled),
|
||||
SettingsButton::ToggleAnalytics,
|
||||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
});
|
||||
|
||||
// Done is the only action — primary so the player always knows
|
||||
@@ -2171,8 +2310,14 @@ fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Status text + manual "Sync Now" button.
|
||||
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
||||
/// Sync section row — shows different controls depending on whether a server
|
||||
/// backend is configured.
|
||||
fn sync_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
status_text: &str,
|
||||
backend: &SyncBackend,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let status_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
@@ -2183,45 +2328,105 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
// Helper closure to spawn a small settings-style pill button.
|
||||
let small_button = |row: &mut ChildSpawnerCommands,
|
||||
marker: SettingsButton,
|
||||
label: &str,
|
||||
tooltip: String,
|
||||
font: TextFont| {
|
||||
row.spawn((
|
||||
marker,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED_HI),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label.to_string()),
|
||||
font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
SyncStatusText,
|
||||
Text::new(status_text.to_string()),
|
||||
status_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
// ManualSyncRequestEvent is always registered, so this
|
||||
// button is safe to show even when SyncPlugin is absent.
|
||||
row.spawn((
|
||||
SettingsButton::SyncNow,
|
||||
Button,
|
||||
Tooltip::new(
|
||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
||||
),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED_HI),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Sync Now"),
|
||||
button_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
.with_children(|col| {
|
||||
// Status line + inline action buttons.
|
||||
col.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
SyncStatusText,
|
||||
Text::new(status_text.to_string()),
|
||||
status_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
match backend {
|
||||
SyncBackend::Local => {
|
||||
small_button(
|
||||
row,
|
||||
SettingsButton::ConnectSync,
|
||||
"Connect",
|
||||
"Connect to a self-hosted Ferrous Solitaire sync server.".to_string(),
|
||||
button_font,
|
||||
);
|
||||
}
|
||||
SyncBackend::SolitaireServer { username, .. } => {
|
||||
// Show the logged-in username as a secondary label.
|
||||
row.spawn((
|
||||
Text::new(format!("({username})")),
|
||||
TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
small_button(
|
||||
row,
|
||||
SettingsButton::SyncNow,
|
||||
"Sync Now",
|
||||
"Push and pull stats now. Runs automatically on launch and exit.".to_string(),
|
||||
button_font.clone(),
|
||||
);
|
||||
small_button(
|
||||
row,
|
||||
SettingsButton::DisconnectSync,
|
||||
"Disconnect",
|
||||
"Unlink this device from the sync server.".to_string(),
|
||||
button_font.clone(),
|
||||
);
|
||||
small_button(
|
||||
row,
|
||||
SettingsButton::DeleteAccount,
|
||||
"Delete Account",
|
||||
"Permanently delete your account and all server data. Cannot be undone.".to_string(),
|
||||
button_font,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2247,6 +2452,172 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||
/// Settings icon button ships with one because the glyph alone (`+`, `−`,
|
||||
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
|
||||
/// Scans `user_theme_dir()` for `.zip` files and calls [`import_theme`] on
|
||||
/// each one. On success, [`ThemeRegistry`] is refreshed in place and an
|
||||
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
|
||||
/// already installed) are silently skipped; all other errors produce a warning
|
||||
/// toast. A final toast tells the player to reopen Settings to see new themes.
|
||||
fn handle_scan_themes(
|
||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut registry: Option<ResMut<crate::theme::ThemeRegistry>>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
if !matches!(button, SettingsButton::ScanThemes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let themes_dir = user_theme_dir();
|
||||
|
||||
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) {
|
||||
Ok(entries) => entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|ext| ext == "zip"))
|
||||
.collect(),
|
||||
Err(_) => {
|
||||
toast.write(InfoToastEvent(
|
||||
"Themes folder not found — drop .zip files there first.".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if zips.is_empty() {
|
||||
toast.write(InfoToastEvent(
|
||||
"No .zip files found in themes folder.".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut imported = 0u32;
|
||||
let mut errors = 0u32;
|
||||
|
||||
for zip_path in &zips {
|
||||
match import_theme(zip_path) {
|
||||
Ok(theme_id) => {
|
||||
toast.write(InfoToastEvent(format!(
|
||||
"Imported theme '{}'.",
|
||||
theme_id.as_str()
|
||||
)));
|
||||
imported += 1;
|
||||
}
|
||||
Err(ImportError::IdCollision { .. }) => {
|
||||
// Already installed — silent skip.
|
||||
}
|
||||
Err(e) => {
|
||||
let name = zip_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
toast.write(InfoToastEvent(format!("Import failed ({name}): {e}")));
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if imported == 0 && errors == 0 {
|
||||
toast.write(InfoToastEvent("All themes already installed.".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if imported > 0 {
|
||||
if let Some(reg) = &mut registry {
|
||||
refresh_registry(reg, &themes_dir);
|
||||
}
|
||||
toast.write(InfoToastEvent(
|
||||
"Reopen Settings to see new themes in the picker.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A small pill-shaped settings button, matching the style used in `sync_row`.
|
||||
fn pill_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: SettingsButton,
|
||||
label: &str,
|
||||
tooltip: &'static str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED_HI),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
|
||||
});
|
||||
}
|
||||
|
||||
/// "Import Theme" row: folder-path label + "Scan for new themes" button.
|
||||
///
|
||||
/// The player drops `.zip` theme archives into the themes folder shown here,
|
||||
/// then presses the button. [`handle_scan_themes`] picks them up, validates,
|
||||
/// and installs them. Reopen Settings to see newly imported themes in the
|
||||
/// card-theme picker.
|
||||
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||||
let caption_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
parent
|
||||
.spawn((
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|col| {
|
||||
// Folder path hint.
|
||||
let path_str = user_theme_dir().to_string_lossy().into_owned();
|
||||
col.spawn((
|
||||
Text::new(format!("Drop .zip files into: {path_str}")),
|
||||
caption_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Scan button.
|
||||
col.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
pill_button(
|
||||
row,
|
||||
SettingsButton::ScanThemes,
|
||||
"Scan for new themes",
|
||||
"Scan the themes folder for .zip archives and install any that are new.",
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn icon_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
@@ -2583,19 +2954,20 @@ mod tests {
|
||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||
);
|
||||
|
||||
// Spot-check: the Sync Now button's tooltip text is the
|
||||
// canonical microcopy. We find it via the `SettingsButton`
|
||||
// discriminant — there is exactly one Sync Now entity per panel.
|
||||
let sync_tip = app
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
.iter(app.world())
|
||||
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
|
||||
.expect("Sync Now button should spawn with a Tooltip");
|
||||
.find_map(|(btn, tip)| {
|
||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||
})
|
||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||
assert_eq!(
|
||||
sync_tip.as_ref(),
|
||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
||||
"Sync Now tooltip must use the canonical microcopy"
|
||||
connect_tip.as_ref(),
|
||||
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
||||
"ConnectSync tooltip must use the canonical microcopy"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! On app start the engine spawns a fullscreen, high-Z overlay that
|
||||
//! reads the Terminal-style "boot screen" — an accent-coloured cursor block, the
|
||||
//! "Solitaire Quest" wordmark, a short fixture boot log, a progress
|
||||
//! "Ferrous Solitaire" wordmark, a short fixture boot log, a progress
|
||||
//! bar, and a footer with the design-system palette swatches and the
|
||||
//! build version. The overlay fades in over 300 ms, holds for ~1 s,
|
||||
//! then fades out for 300 ms before despawning. The deal animation
|
||||
@@ -383,7 +383,7 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
|
||||
));
|
||||
hdr.spawn((
|
||||
SplashFadable { base_color: TEXT_PRIMARY },
|
||||
Text::new("Solitaire Quest"),
|
||||
Text::new("Ferrous Solitaire"),
|
||||
title_font,
|
||||
TextColor(transparent(TEXT_PRIMARY)),
|
||||
));
|
||||
@@ -1170,7 +1170,7 @@ mod tests {
|
||||
"expected the cursor block (▌) on the splash, got: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Solitaire Quest"),
|
||||
texts.iter().any(|t| t == "Ferrous Solitaire"),
|
||||
"expected the wordmark on the splash, got: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user