Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
@@ -1,168 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Triggered by pushing a version tag, e.g. `git tag v0.22.0 && git push origin v0.22.0`.
|
||||
# Builds a Linux x86_64 tarball and a signed Android APK, then publishes
|
||||
# both as assets on a GitHub Release. Obtainium can track this repo's
|
||||
# releases and download the APK automatically.
|
||||
#
|
||||
# Required repository secrets (Settings → Secrets and variables → Actions):
|
||||
# ANDROID_KEYSTORE_BASE64 base64-encoded .jks file (see README for gen command)
|
||||
# ANDROID_KEYSTORE_PASSWORD password used with -storepass when creating the keystore
|
||||
# ANDROID_KEY_ALIAS alias used with -alias when creating the keystore
|
||||
# ANDROID_KEY_PASSWORD password used with -keypass when creating the keystore
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write # gh release create needs write access
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 1: Linux x86_64 binary + assets tarball
|
||||
# ---------------------------------------------------------------------------
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Build · Linux x86_64
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system deps
|
||||
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 + build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: linux-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: linux-release-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release -p solitaire_app
|
||||
|
||||
- name: Package tarball
|
||||
run: |
|
||||
mkdir solitaire-quest
|
||||
cp target/release/solitaire_app solitaire-quest/
|
||||
cp -r assets solitaire-quest/
|
||||
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux
|
||||
path: solitaire-quest-linux-x86_64.tar.gz
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 2: Android APK (multi-arch) — release-built and signed via cargo-apk
|
||||
# ---------------------------------------------------------------------------
|
||||
build-android:
|
||||
name: Build · Android APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable + Android targets
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||
|
||||
- name: Expose NDK root to cargo-apk
|
||||
# ANDROID_NDK_LATEST_HOME is set by the GitHub-hosted runner.
|
||||
# cargo-apk reads ANDROID_NDK_ROOT; write it to GITHUB_ENV so
|
||||
# all subsequent steps in this job inherit it.
|
||||
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache cargo registry + cargo-apk binary + build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
target
|
||||
key: android-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: android-release-
|
||||
|
||||
- name: Install cargo-apk
|
||||
# --locked: use the dependency versions cargo-apk was tested with.
|
||||
# cargo install is a no-op when the cached binary is already current.
|
||||
run: cargo install --locked cargo-apk
|
||||
|
||||
- name: Inject release signing config
|
||||
# cargo-apk --release requires [package.metadata.android.signing.release]
|
||||
# in solitaire_app/Cargo.toml. Appended at CI time so secrets never
|
||||
# live in the repo. printf keeps every line inside the YAML run block,
|
||||
# avoiding the YAML parse error a heredoc with column-0 content causes.
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
|
||||
{
|
||||
printf '\n[package.metadata.android.signing.release]\n'
|
||||
printf 'path = "%s"\n' "${GITHUB_WORKSPACE}/release.keystore"
|
||||
printf 'keystore_password = "%s"\n' "$ANDROID_KEYSTORE_PASSWORD"
|
||||
printf 'key_alias = "%s"\n' "$ANDROID_KEY_ALIAS"
|
||||
printf 'key_password = "%s"\n' "$ANDROID_KEY_PASSWORD"
|
||||
} >> solitaire_app/Cargo.toml
|
||||
|
||||
- name: Build and sign APK (release profile)
|
||||
run: cargo apk build -p solitaire_app --release
|
||||
|
||||
- name: Stage APK for upload
|
||||
run: |
|
||||
cp target/release/apk/solitaire-quest.apk \
|
||||
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||
rm release.keystore
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android
|
||||
path: solitaire-quest-${{ github.ref_name }}.apk
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 3: Create the GitHub Release once both builds succeed
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-android]
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Solitaire Quest ${{ github.ref_name }}" \
|
||||
--generate-notes \
|
||||
"solitaire-quest-linux-x86_64.tar.gz" \
|
||||
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||
@@ -10,3 +10,8 @@ data/
|
||||
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
# Android signing keystores — never commit
|
||||
*.jks
|
||||
*.jks.bak
|
||||
*.keystore
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
|
||||
}
|
||||
+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": "DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
|
||||
}
|
||||
+81
-5
@@ -1,9 +1,9 @@
|
||||
# Solitaire Quest — 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
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +86,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 +161,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 +276,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 |
|
||||
@@ -365,10 +382,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 +483,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 +626,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 +660,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 +677,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 |
|
||||
@@ -945,6 +1020,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` |
|
||||
|
||||
@@ -6,6 +6,89 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
Generated
+1
@@ -6986,6 +6986,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
|
||||
+124
-304
@@ -1,338 +1,158 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
|
||||
nine post-cut commits on master. Push pending.
|
||||
**Last updated:** 2026-05-12 — Sync rate limiting + mirror_achievement removal + theme import scan shipped (`6e6f3ef`). HEAD locally: `6e6f3ef`. Push pending.
|
||||
|
||||
v0.21.8 closes the last optional polish items in the B-2
|
||||
replay screen-takeover arc: **notch-label centering** (middle
|
||||
three scrub-bar labels now centred on their notch ticks via the
|
||||
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
|
||||
MOVE HC legibility** (lime stays lime under HC mode via the
|
||||
extended `HighContrastBackground::with_hc` constructor and a
|
||||
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
|
||||
overlay arc is now fully closed with no known open items.
|
||||
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.
|
||||
|
||||
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
|
||||
file from here on focuses on what's *open* post-cut and how to
|
||||
resume.
|
||||
---
|
||||
|
||||
## Status at pause
|
||||
## Current state
|
||||
|
||||
- **HEAD locally:** `f281425` (Android Keystore JNI).
|
||||
Docs ride on top; push pending.
|
||||
- **HEAD on origin:** `395a322` (double-tap commit — last pushed).
|
||||
- **Working tree:** clean (docs uncommitted). No WIP outstanding.
|
||||
- **`artwork/` directory:** still untracked. Intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1292 passing / 0 failing** across the workspace.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
|
||||
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
|
||||
x86_64). All desktop-only systems (handle_fullscreen) now gated.
|
||||
See Phase Android punch list for remaining work.
|
||||
- **HEAD locally:** `6e6f3ef` (feat: sync rate limiting).
|
||||
- **HEAD on origin:** `b129664` (pushed — 4 commits ahead).
|
||||
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
||||
- **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`.
|
||||
|
||||
## Since the v0.21.8 cut
|
||||
---
|
||||
|
||||
Seven commits since the v0.21.8 tag:
|
||||
- `a449f60` — Stats Prev/Next selector spawn site
|
||||
- `202a64d` — Android launch fixes (android_main, resize_constraints,
|
||||
apply_smart_default_window_size) — **closes APK launch verification**
|
||||
- `16242e6` — Ignore .idea/ IDE files
|
||||
- `395a322` — double-tap auto-move for touch input
|
||||
- `0cb1587` — Play-by-Seed dialog + HomeMode card
|
||||
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
|
||||
- `45436d0` — gate handle_fullscreen to non-Android
|
||||
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
|
||||
- `f281425` — Android Keystore AES-GCM token storage via JNI
|
||||
## What shipped in Phase 8 (432061c – bd388fe)
|
||||
|
||||
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
|
||||
| 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 |
|
||||
|
||||
Open next-step menu:
|
||||
1. **Phase 8 (sync)** — the biggest open arc. Local storage
|
||||
scaffolding, self-hosted Axum server, GPGS stub.
|
||||
2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
|
||||
GPGS. Launch verification and double-tap both closed; these
|
||||
are the remaining Phase Android items.
|
||||
3. **Move Log auto-scroll** — only relevant if the panel
|
||||
row count grows beyond the current 5-row fixed window.
|
||||
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
||||
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
|
||||
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
|
||||
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
|
||||
- DB migration 002: `replays` table + two indexes
|
||||
- Full server integration tests for replay endpoints
|
||||
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
|
||||
- Stats panel "Copy Share Link" button reads `share_url` from replay history
|
||||
|
||||
## Open punch list
|
||||
---
|
||||
|
||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||
## Open punch list (ordered by priority)
|
||||
|
||||
- *APK launch verification — closed 2026-05-08 by `202a64d`.*
|
||||
Three fixes shipped: `android_main` export (missing NativeActivity
|
||||
entry point), `resize_constraints` gated to non-Android (max=0
|
||||
panic), `apply_smart_default_window_size` gated to non-Android
|
||||
(clamp panic on zero-dimension window event). Verified booting on
|
||||
Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
|
||||
runtime without crash. B0004 ECS hierarchy warnings remain
|
||||
(non-fatal; entity parent/child component mismatch); investigate
|
||||
if they surface gameplay bugs.
|
||||
- *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
|
||||
`handle_double_tap` fires `MoveRequestEvent` on two rapid
|
||||
`TouchPhase::Ended` events within 0.5 s. Prefers foundation;
|
||||
falls back to tableau stack move. Fires `MoveRejectedEvent` when
|
||||
no legal destination exists. System runs before `touch_end_drag`
|
||||
in the chain so drag state is readable.
|
||||
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
|
||||
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
|
||||
imports are `#[cfg(not(target_os = "android"))]`-gated. The
|
||||
`add_systems` call is a separate statement (not mid-chain).
|
||||
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
|
||||
`android_clipboard::set_text(url)` calls `ClipboardManager` via
|
||||
JNI. Stats share-link button now writes to the clipboard with a
|
||||
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
|
||||
error. Requires AVD functional test (see verification steps in
|
||||
the approved plan).
|
||||
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
|
||||
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
|
||||
tokens serialised to JSON and stored atomically at
|
||||
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
|
||||
`auth_tokens.rs` Android stubs now delegate to it. Key
|
||||
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
|
||||
Requires AVD functional test before Phase 8 sync goes live on
|
||||
### 1. Documentation debt (no code)
|
||||
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
|
||||
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
|
||||
- [x] SESSION_HANDOFF.md update — this file
|
||||
|
||||
### 2. Leaderboard wiring gaps
|
||||
- **Best-score auto-post missing.** `POST /api/sync/push` merges stats/achievements/
|
||||
progress but never touches the `leaderboard` table. Players who opt in never
|
||||
have their `best_time_secs` / `best_score` updated automatically. Fix: update
|
||||
the leaderboard row inside the server's sync push handler (or on `GameWonEvent`
|
||||
via a new async task in `sync_plugin`).
|
||||
- **Display name = username.** `handle_opt_in_button` uses the `SyncBackend`
|
||||
username as the leaderboard display name. Consider adding
|
||||
`leaderboard_display_name: Option<String>` to `Settings` for players who
|
||||
want a different public identity.
|
||||
|
||||
### 3. Security hardening
|
||||
- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
|
||||
(migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
|
||||
tests.
|
||||
- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
|
||||
decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
|
||||
steady-state; integration test passes.
|
||||
|
||||
### 4. Android validation
|
||||
- **Android Keystore functional test** — JNI AES-GCM code ships (`f281425`) but
|
||||
no AVD round-trip test has been run. Required before Phase 8 sync goes live on
|
||||
Android.
|
||||
- **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.
|
||||
- **JNI clipboard functional test** — same status (`2c822ba`). Note: `adb tap`
|
||||
doesn't work in headless AVD (see memory); requires a touch-gesture path.
|
||||
- **`cargo apk build --lib` noisy stderr** — post-sign panic doesn't affect the
|
||||
APK but pollutes CI output. Document `--lib` as canonical or upstream a fix.
|
||||
|
||||
### 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.
|
||||
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
|
||||
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
|
||||
invocation to regenerate it.
|
||||
- **Server password reset.** No admin endpoint or CLI tool for resetting a
|
||||
user's password. Self-hosters have no recovery path short of direct SQLite
|
||||
edits.
|
||||
|
||||
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:
|
||||
### 6. Testing gaps
|
||||
- **Server 401 → refresh → retry path** — the `pull`/`push` retry logic in
|
||||
`SolitaireServerClient` has no integration test.
|
||||
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
|
||||
a test stepping through a full winning sequence would catch
|
||||
`GameState`/`ReplayMove` compatibility regressions.
|
||||
|
||||
- *Replay-overlay screen-takeover redesign — closed 2026-05-08
|
||||
across 13 commits (v0.21.4–v0.21.7).* The full mockup
|
||||
(`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
|
||||
banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
|
||||
MOVE scrub-bar marker (post-v0.21.3), playback controls /
|
||||
Space accelerator (post-v0.21.3), scrub notches + labels +
|
||||
keybind footer + ESC / ← / → accelerators + HC border
|
||||
(v0.21.5), Move Log panel + HC scrub track + continuous
|
||||
scrub (v0.21.6), and full-screen 50 % opacity dim layer
|
||||
(v0.21.7). Every major B-2 sub-piece is now closed. The
|
||||
only remaining items are minor polish: notch-label centering
|
||||
and WIN MOVE HC contrast bump (see Open next-step menu).*
|
||||
- *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).
|
||||
---
|
||||
|
||||
### Carried forward from v0.19.0
|
||||
## ARCHITECTURE.md gaps (for the update pass)
|
||||
|
||||
- *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.
|
||||
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
|
||||
|
||||
### Other small candidates
|
||||
---
|
||||
|
||||
- *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
|
||||
`PlayBySeedPlugin` adds a numeric-input modal with async solver
|
||||
preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
|
||||
`StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
|
||||
seeds (`2062bd0`) expand `CHALLENGE_SEEDS` via the new
|
||||
`solitaire_assetgen::gen_seeds` binary.
|
||||
- *Prev/Next selector chips spawn site — closed 2026-05-08 by
|
||||
`a449f60`.* `ReplayPrevButton` / `ReplayNextButton` /
|
||||
`ReplaySelectorCaption` / `ReplaySelectorDetail` now spawn in
|
||||
`spawn_stats_screen` as a compact chip row above the Watch
|
||||
Replay action. The Shareable badge is in the detail line.
|
||||
The click handler and repaint systems were already live since
|
||||
v0.19.0; this was purely the missing spawn site.
|
||||
- **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.
|
||||
## Process notes
|
||||
|
||||
### Process notes
|
||||
- **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.
|
||||
|
||||
- **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
|
||||
|
||||
`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.
|
||||
|
||||
### 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.8 is tagged at c50eaf8 (cut 2026-05-08,
|
||||
replay-overlay polish). Seven post-cut commits are on master (see
|
||||
"Since the v0.21.8 cut" above); push of the last four pending.
|
||||
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
|
||||
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
|
||||
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
|
||||
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
|
||||
pending. See CHANGELOG.md § [0.21.9] for full detail.
|
||||
Working directory: <Rusty_Solitaire clone path>.
|
||||
Branch: master. v0.23.0 is the current version (HEAD locally: bd388fe).
|
||||
Phase 8 sync is fully shipped. ARCHITECTURE.md is now v1.3 (all Phase 8 gaps closed).
|
||||
Push to origin pending (bd388fe + ARCHITECTURE.md + SESSION_HANDOFF.md commits).
|
||||
|
||||
State: HEAD locally — see `git rev-parse HEAD`. Workspace
|
||||
tests: 1292 passing / 0 failing. Clippy clean.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
READ FIRST (in order):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.21.9] section has the pending-cut items
|
||||
2. CHANGELOG.md — [0.23.0] section has the full Phase 8 detail
|
||||
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)
|
||||
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. Android follow-ups — JNI ClipboardManager bridge (arboard
|
||||
has no Android backend), Android Keystore (blocked on Phase 8).
|
||||
Launch verification + double-tap are closed.
|
||||
B. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||
Axum server, `SolitaireServerClient` impl. The biggest open
|
||||
arc by scope; rolls up Android dependencies (Keystore,
|
||||
ClipboardManager).
|
||||
C. Play-by-Seed polish — the dialog is functional but has no
|
||||
visual preview of the solver verdict in the UI yet; the
|
||||
HomeMode card is wired but the dialog spawn site and verdict
|
||||
display could use a second pass.
|
||||
OPEN WORK (in priority order):
|
||||
D. Android AVD functional tests (Keystore + clipboard)
|
||||
E. Theme importer UI button in Settings
|
||||
F. mirror_achievement: decide + implement or remove from trait
|
||||
G. Sync endpoint rate limiting (POST /api/sync/push has no per-user throttle)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Ask which to start. All are independent; any is a valid next arc.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -79,6 +88,14 @@ name = "android.permission.INTERNET"
|
||||
|
||||
[package.metadata.android.application]
|
||||
label = "Solitaire Quest"
|
||||
# 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 |
@@ -30,8 +30,9 @@ use solitaire_engine::{
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
@@ -131,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()
|
||||
}),
|
||||
@@ -173,6 +183,7 @@ pub fn run() {
|
||||
.add_plugins(PlayBySeedPlugin)
|
||||
.add_plugins(DifficultyPlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(SafeAreaInsetsPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
@@ -182,6 +193,7 @@ pub fn run() {
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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((
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||
assert!(
|
||||
(after - expected).abs() < 1e-3,
|
||||
|
||||
@@ -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);
|
||||
// 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)))
|
||||
.insert_resource(DragState::default())
|
||||
.add_systems(Update, update_drop_target_overlays);
|
||||
app
|
||||
|
||||
@@ -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 /
|
||||
@@ -266,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.
|
||||
///
|
||||
|
||||
@@ -990,18 +990,26 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
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());
|
||||
}
|
||||
// Only the top waste card is playable.
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stock cards are face-down and cannot be placed directly; drawing is
|
||||
// only useful if the drawn card can subsequently be placed, which the
|
||||
// waste-card check above already covers for the currently visible card.
|
||||
// Including all stock cards would produce false positives for unplayable
|
||||
// face-down cards (the test has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards
|
||||
// explicitly guards this case).
|
||||
|
||||
for card in &sources {
|
||||
for slot in 0..4_u8 {
|
||||
@@ -1064,9 +1072,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() {
|
||||
@@ -1730,6 +1740,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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -250,9 +250,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),
|
||||
|
||||
@@ -1385,8 +1385,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),
|
||||
|
||||
@@ -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,
|
||||
@@ -239,6 +242,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,6 +281,16 @@ pub struct MenuButton;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct MenuPopover;
|
||||
|
||||
/// 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)]
|
||||
@@ -322,11 +340,15 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<WinStreakMilestoneEvent>()
|
||||
.init_resource::<PreviousScore>()
|
||||
.init_resource::<HudActionFade>()
|
||||
// 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, 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 +372,13 @@ 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,
|
||||
handle_menu_button,
|
||||
handle_menu_option_click,
|
||||
handle_menu_backdrop_click,
|
||||
paint_action_buttons,
|
||||
),
|
||||
)
|
||||
@@ -376,11 +401,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 +418,7 @@ 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 },
|
||||
));
|
||||
}
|
||||
|
||||
@@ -413,7 +441,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 +467,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 +486,21 @@ 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 },
|
||||
))
|
||||
.with_children(|hud| {
|
||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||
@@ -568,94 +620,83 @@ 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;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
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()
|
||||
};
|
||||
// Android labels use only FiraMono-safe glyphs (≡ ← ‖ → ▾), so the same
|
||||
// embedded font works — no system font fallback required.
|
||||
#[cfg(target_os = "android")]
|
||||
let font = TextFont { 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 (hamburger look-alike, in FiraMono)
|
||||
/* undo */ "\u{2190}", // ← leftwards arrow (in FiraMono)
|
||||
/* pause */ "\u{2016}", // ‖ double vertical line (in FiraMono general-punct)
|
||||
/* help */ "?",
|
||||
/* hint */ "\u{2192}", // → rightwards arrow (in FiraMono)
|
||||
/* modes */ "\u{25BE}", // ▾ small down-pointing triangle (in 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 },
|
||||
))
|
||||
.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.
|
||||
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0);
|
||||
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1);
|
||||
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2);
|
||||
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3);
|
||||
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4);
|
||||
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5);
|
||||
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -681,32 +722,42 @@ fn spawn_action_button<M: Component>(
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
) {
|
||||
// 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)),
|
||||
@@ -777,12 +828,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 +878,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,
|
||||
@@ -896,6 +981,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 +1011,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 +1044,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 +1059,7 @@ 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>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
@@ -963,6 +1071,9 @@ fn handle_menu_button(
|
||||
}
|
||||
if let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
} else {
|
||||
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||
}
|
||||
@@ -1050,6 +1161,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,6 +1186,7 @@ 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>,
|
||||
@@ -1092,9 +1221,46 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the cursor is in the play area (below the HUD band) and back in when
|
||||
/// the cursor approaches the top of the window — same UX as a video
|
||||
@@ -1938,6 +2104,46 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2443,6 +2649,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),
|
||||
] {
|
||||
@@ -2551,6 +2758,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."
|
||||
@@ -2670,14 +2881,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]
|
||||
|
||||
@@ -33,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::{
|
||||
@@ -522,8 +520,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)>,
|
||||
@@ -538,6 +538,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 };
|
||||
|
||||
@@ -614,7 +623,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, _)) =
|
||||
@@ -875,7 +884,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, _)) =
|
||||
@@ -1047,8 +1056,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 {
|
||||
@@ -1058,9 +1067,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;
|
||||
}
|
||||
@@ -1195,7 +1204,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;
|
||||
@@ -1217,9 +1226,10 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
||||
/// Maximum seconds between two clicks to count as a double-click.
|
||||
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||
|
||||
/// Maximum seconds between two taps to count as a double-tap.
|
||||
/// Slightly wider than the mouse window — touch screens have higher latency.
|
||||
const DOUBLE_TAP_WINDOW: f32 = 0.5;
|
||||
/// 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.
|
||||
///
|
||||
@@ -1375,63 +1385,51 @@ fn handle_double_click(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
|
||||
// Tap-to-move (touch equivalent of mouse auto-move)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
|
||||
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
|
||||
/// 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.
|
||||
///
|
||||
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
|
||||
/// !drag.committed`: the touch began (so `touch_start_drag` populated
|
||||
/// `drag`) but the drag threshold was never crossed.
|
||||
///
|
||||
/// Move priority matches [`handle_double_click`]:
|
||||
/// 1. Move the single top card to its best foundation (or tableau).
|
||||
/// 2. If no single-card move exists and the selection spans multiple
|
||||
/// face-up cards, move the whole stack to the best tableau column.
|
||||
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
|
||||
/// feedback.
|
||||
/// 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>>,
|
||||
time: Res<Time>,
|
||||
radial: Option<Res<RightClickRadialState>>,
|
||||
drag: Res<DragState>,
|
||||
game: Res<GameStateResource>,
|
||||
mut last_tap: Local<HashMap<u32, f32>>,
|
||||
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;
|
||||
}
|
||||
|
||||
// Only active when a touch is tracked and hasn't crossed the drag threshold.
|
||||
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 {
|
||||
if event.id != active_id || event.phase != TouchPhase::Ended {
|
||||
continue;
|
||||
}
|
||||
match event.phase {
|
||||
TouchPhase::Canceled => {
|
||||
// Cancelled touch — clear any pending tap state for these cards.
|
||||
for &id in &drag.cards {
|
||||
last_tap.remove(&id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
TouchPhase::Ended => {}
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
// Uncommitted touch ended = pure tap.
|
||||
let Some(&top_card_id) = drag.cards.last() else { return };
|
||||
@@ -1445,50 +1443,54 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
||||
// 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;
|
||||
}
|
||||
|
||||
if now - prev <= DOUBLE_TAP_WINDOW {
|
||||
last_tap.remove(&top_card_id);
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
// 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: 1,
|
||||
count,
|
||||
});
|
||||
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(),
|
||||
)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile.clone(),
|
||||
count: drag.cards.len(),
|
||||
});
|
||||
} else {
|
||||
last_tap.insert(top_card_id, now);
|
||||
}
|
||||
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile.clone(),
|
||||
count: drag.cards.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1630,7 +1632,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);
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
@@ -1644,7 +1646,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);
|
||||
|
||||
// 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
|
||||
@@ -1665,7 +1667,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);
|
||||
|
||||
// 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
|
||||
@@ -1704,7 +1706,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);
|
||||
// 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
|
||||
@@ -1736,7 +1738,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);
|
||||
// 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);
|
||||
@@ -1749,7 +1751,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);
|
||||
// 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();
|
||||
@@ -1761,7 +1763,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);
|
||||
let pos = layout.pile_positions[&PileType::Tableau(3)];
|
||||
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
|
||||
assert_eq!(target, None);
|
||||
@@ -1770,7 +1772,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);
|
||||
// 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
|
||||
@@ -1795,7 +1797,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);
|
||||
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;
|
||||
@@ -1811,7 +1813,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);
|
||||
// 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)];
|
||||
@@ -1822,7 +1824,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);
|
||||
for pile in [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(2),
|
||||
@@ -2323,7 +2325,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),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
@@ -2345,13 +2347,5 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Task #27b — double-tap constants
|
||||
#[test]
|
||||
fn double_tap_window_is_wider_than_double_click_window() {
|
||||
// Compile-time check: touch needs a wider window than mouse due to
|
||||
// higher input latency. `const { assert! }` catches regressions at
|
||||
// build time rather than waiting for a test run.
|
||||
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+266
-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,7 +146,7 @@ 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) -> Layout {
|
||||
let window = window.max(MIN_WINDOW);
|
||||
|
||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||
@@ -113,7 +169,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 - HUD_BAND_HEIGHT).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 +189,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 - HUD_BAND_HEIGHT - 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 +209,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 +270,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));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[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);
|
||||
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0);
|
||||
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 +288,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).
|
||||
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0);
|
||||
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0);
|
||||
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);
|
||||
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);
|
||||
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 +333,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);
|
||||
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 +346,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);
|
||||
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 +358,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);
|
||||
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 +369,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);
|
||||
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 +388,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);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
layout.card_size.x < width_based,
|
||||
@@ -293,7 +404,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);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||
@@ -307,7 +418,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);
|
||||
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 +437,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);
|
||||
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 +449,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);
|
||||
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0);
|
||||
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);
|
||||
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);
|
||||
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 +500,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);
|
||||
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 +519,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);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0);
|
||||
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);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0);
|
||||
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);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0);
|
||||
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);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0);
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,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;
|
||||
@@ -138,6 +140,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,
|
||||
};
|
||||
@@ -148,6 +151,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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)));
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||
|
||||
@@ -944,6 +944,7 @@ fn spawn_overlay(
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
footer.spawn((
|
||||
Text::new(keybind_footer_hint_text()),
|
||||
TextFont {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! 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::*;
|
||||
|
||||
/// 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);
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -230,7 +236,15 @@ enum SettingsButton {
|
||||
/// flag only affects launches without saved geometry — the
|
||||
/// player's last window size always wins.
|
||||
ToggleSmartDefaultSize,
|
||||
/// 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),
|
||||
@@ -282,8 +296,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.
|
||||
@@ -333,6 +351,9 @@ 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>()
|
||||
@@ -359,6 +380,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,
|
||||
@@ -840,7 +863,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>)>,
|
||||
@@ -1053,8 +1075,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;
|
||||
@@ -1063,6 +1091,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(),
|
||||
@@ -1593,10 +1645,11 @@ fn spawn_settings_panel(
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
import_themes_row(body, 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
|
||||
@@ -2208,8 +2261,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,
|
||||
@@ -2220,45 +2279,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 Solitaire Quest 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2284,6 +2403,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,
|
||||
@@ -2620,19 +2905,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 Solitaire Quest sync server.",
|
||||
"ConnectSync tooltip must use the canonical microcopy"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ use solitaire_data::{
|
||||
use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
|
||||
use crate::events::{
|
||||
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
||||
SyncConfigureRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
||||
@@ -104,6 +107,8 @@ impl Plugin for SyncPlugin {
|
||||
.init_resource::<PendingReplayUpload>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -130,7 +135,14 @@ fn start_pull(
|
||||
) {
|
||||
let provider = provider.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
provider.pull().await
|
||||
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
|
||||
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
|
||||
// a short-lived single-threaded runtime for this network round-trip.
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.pull())
|
||||
});
|
||||
task_res.0 = Some(task);
|
||||
status.0 = SyncStatus::Syncing;
|
||||
@@ -153,7 +165,11 @@ fn handle_manual_sync_request(
|
||||
}
|
||||
let provider = provider.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
provider.pull().await
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.pull())
|
||||
});
|
||||
task_res.0 = Some(task);
|
||||
status.0 = SyncStatus::Syncing;
|
||||
@@ -180,6 +196,8 @@ fn poll_pull_result(
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
progress_path: Res<ProgressStoragePath>,
|
||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else {
|
||||
return;
|
||||
@@ -229,10 +247,19 @@ fn poll_pull_result(
|
||||
warn!("sync pull failed: {e}");
|
||||
let msg = match &e {
|
||||
SyncError::Network(_) => "Can't reach server — check your connection".to_string(),
|
||||
SyncError::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(),
|
||||
SyncError::Auth(_) => "Session expired — please reconnect".to_string(),
|
||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||
};
|
||||
// On auth failure, reopen the Connect modal so the player can
|
||||
// re-enter credentials without having to navigate through Settings.
|
||||
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||
// the modal is already on screen, so repeated pull failures don't
|
||||
// stack multiple modals.
|
||||
if matches!(e, SyncError::Auth(_)) {
|
||||
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
status.0 = SyncStatus::Error(msg.clone());
|
||||
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
||||
}
|
||||
@@ -259,11 +286,18 @@ fn push_on_exit(
|
||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let provider = provider.0.clone();
|
||||
|
||||
// Prefer an existing tokio runtime; fall back to futures_lite block_on
|
||||
// for environments (e.g. tests) that don't have one.
|
||||
// Prefer an existing tokio runtime; fall back to a temporary one for
|
||||
// environments (e.g. tests, Android's non-Tokio async executor) where
|
||||
// reqwest/hyper would otherwise panic with "no reactor running".
|
||||
let result = match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
||||
Err(_) => future::block_on(provider.push(&payload)),
|
||||
Err(_) => match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt.block_on(provider.push(&payload)),
|
||||
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
|
||||
},
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
@@ -314,8 +348,13 @@ fn push_replay_on_win(
|
||||
recording.moves.clone(),
|
||||
);
|
||||
let provider = provider.0.clone();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { provider.push_replay(&replay).await });
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.push_replay(&replay))
|
||||
});
|
||||
// If a previous upload is still in flight, drop it — the most
|
||||
// recent win is the one whose share link the player will care
|
||||
// about. Bevy's `Task` Drop cancels cooperatively.
|
||||
|
||||
@@ -0,0 +1,876 @@
|
||||
//! Sync-server configuration UI: login / register modal, provider hot-swap,
|
||||
//! and disconnect handler.
|
||||
//!
|
||||
//! # Flow (connect)
|
||||
//!
|
||||
//! 1. Player clicks "Connect" in the Settings sync section.
|
||||
//! 2. `SyncConfigureRequestEvent` → `open_sync_setup_modal` spawns the form.
|
||||
//! 3. Player fills URL / Username / Password; Tab cycles fields.
|
||||
//! 4. "Log In" or "Register" → `handle_auth_button` → async task on
|
||||
//! `AsyncComputeTaskPool` calling `SolitaireServerClient::login` or
|
||||
//! `::register`.
|
||||
//! 5. `poll_auth_task` harvests the result:
|
||||
//! - **Ok**: store tokens → update `SettingsResource` → swap
|
||||
//! `SyncProviderResource` → fire `ManualSyncRequestEvent` → toast + close.
|
||||
//! - **Err**: display error inline; form stays open.
|
||||
//!
|
||||
//! # Flow (disconnect)
|
||||
//!
|
||||
//! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets
|
||||
//! `SyncBackend::Local`, swaps provider, closes settings, shows toast.
|
||||
//!
|
||||
//! # Flow (delete account)
|
||||
//!
|
||||
//! 1. Player clicks "Delete Account" in Settings.
|
||||
//! 2. `DeleteAccountRequestEvent` → `open_delete_confirm_modal` spawns a
|
||||
//! two-button confirmation modal.
|
||||
//! 3. "Cancel" → despawn modal.
|
||||
//! 4. "Delete Forever" → `handle_delete_confirm` → async task on
|
||||
//! `AsyncComputeTaskPool` calling `SyncProvider::delete_account`.
|
||||
//! 5. `poll_delete_task` harvests the result:
|
||||
//! - **Ok**: fire `SyncLogoutRequestEvent` (clears tokens + resets backend)
|
||||
//! + toast.
|
||||
//! - **Err**: display error in a toast; modal is already closed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::{
|
||||
auth_tokens::{delete_tokens, store_tokens},
|
||||
settings::SyncBackend,
|
||||
save_settings_to,
|
||||
sync_client::{LocalOnlyProvider, SolitaireServerClient},
|
||||
SyncError,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
SyncLogoutRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::spawn_modal;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, 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,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the sync-setup modal scrim (despawn root).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct SyncSetupScreen;
|
||||
|
||||
/// Discriminant attached to each input-field container and inner text entity.
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SyncFieldKind {
|
||||
Url,
|
||||
Username,
|
||||
Password,
|
||||
}
|
||||
|
||||
/// Per-field raw-text buffer, stored on the inner text entity.
|
||||
#[derive(Component, Default, Debug)]
|
||||
struct SyncFieldBuffer(String);
|
||||
|
||||
/// Marker on the error-message text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct SyncAuthError;
|
||||
|
||||
/// Marks the "Log In" button.
|
||||
#[derive(Component, Debug)]
|
||||
struct SyncLoginButton;
|
||||
|
||||
/// Marks the "Register" button.
|
||||
#[derive(Component, Debug)]
|
||||
struct SyncRegisterButton;
|
||||
|
||||
/// Marks the "Cancel" button.
|
||||
#[derive(Component, Debug)]
|
||||
struct SyncCancelButton;
|
||||
|
||||
/// Marks the spinner / busy overlay node shown while the auth task is running.
|
||||
#[derive(Component, Debug)]
|
||||
struct SyncBusyOverlay;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which field in the sync-setup modal currently has keyboard focus.
|
||||
#[derive(Resource, Default, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SyncFocusedField {
|
||||
#[default]
|
||||
Url,
|
||||
Username,
|
||||
Password,
|
||||
}
|
||||
|
||||
impl SyncFocusedField {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Url => Self::Username,
|
||||
Self::Username => Self::Password,
|
||||
Self::Password => Self::Url,
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(self) -> SyncFieldKind {
|
||||
match self {
|
||||
Self::Url => SyncFieldKind::Url,
|
||||
Self::Username => SyncFieldKind::Username,
|
||||
Self::Password => SyncFieldKind::Password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-flight login/register task. `url` and `username` are preserved so the
|
||||
/// poll system can update settings and provider on success without re-reading
|
||||
/// the (already-despawned or cleared) form fields.
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingAuthTask {
|
||||
task: Option<Task<Result<(String, String), SyncError>>>,
|
||||
url: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
/// Marker on the account-deletion confirmation modal root.
|
||||
#[derive(Component, Debug)]
|
||||
struct DeleteConfirmScreen;
|
||||
|
||||
/// Marks the "Delete Forever" confirmation button.
|
||||
#[derive(Component, Debug)]
|
||||
struct DeleteConfirmButton;
|
||||
|
||||
/// Marks the cancel button inside the delete-confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct DeleteCancelButton;
|
||||
|
||||
/// In-flight account-deletion task.
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingDeleteTask(Option<Task<Result<(), SyncError>>>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the sync configuration UI systems and resources.
|
||||
pub struct SyncSetupPlugin;
|
||||
|
||||
impl Plugin for SyncSetupPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SyncFocusedField>()
|
||||
.init_resource::<PendingAuthTask>()
|
||||
.init_resource::<PendingDeleteTask>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
open_sync_setup_modal,
|
||||
handle_text_input,
|
||||
update_field_borders,
|
||||
handle_auth_button,
|
||||
poll_auth_task,
|
||||
handle_cancel,
|
||||
handle_logout,
|
||||
open_delete_confirm_modal,
|
||||
handle_delete_cancel,
|
||||
handle_delete_confirm,
|
||||
poll_delete_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
|
||||
fn open_sync_setup_modal(
|
||||
mut events: MessageReader<SyncConfigureRequestEvent>,
|
||||
existing: Query<(), With<SyncSetupScreen>>,
|
||||
mut commands: Commands,
|
||||
mut focused: ResMut<SyncFocusedField>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
events.clear();
|
||||
if !existing.is_empty() {
|
||||
return; // Already open.
|
||||
}
|
||||
*focused = SyncFocusedField::Url;
|
||||
spawn_sync_setup_modal(&mut commands, font_res.as_deref());
|
||||
}
|
||||
|
||||
/// Routes keyboard input to the focused field while the modal is open.
|
||||
fn handle_text_input(
|
||||
screen: Query<(), With<SyncSetupScreen>>,
|
||||
mut key_events: MessageReader<KeyboardInput>,
|
||||
mut focused: ResMut<SyncFocusedField>,
|
||||
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer, &mut Text, &mut TextColor)>,
|
||||
pending: Res<PendingAuthTask>,
|
||||
) {
|
||||
if screen.is_empty() || pending.task.is_some() {
|
||||
// Swallow events while modal is closed or auth is in flight.
|
||||
key_events.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for ev in key_events.read() {
|
||||
if ev.state != ButtonState::Pressed {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tab / Shift-Tab cycle focus.
|
||||
if ev.key_code == KeyCode::Tab {
|
||||
let shift = ev.logical_key == bevy::input::keyboard::Key::Tab; // no-shift
|
||||
let _ = shift; // handled below via modifier check
|
||||
// Bevy doesn't give us the shift modifier state on KeyboardInput directly,
|
||||
// so we check key_code == Tab and trust that shift produces a separate event.
|
||||
// Use ButtonInput<KeyCode> alternative: we check Tab key here and rely on
|
||||
// the SyncFocusedField cycling being called per press.
|
||||
*focused = focused.next();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ev.key_code == KeyCode::Backspace {
|
||||
for (kind, mut buf, mut text, _) in &mut fields {
|
||||
if *kind == focused.kind() {
|
||||
buf.0.pop();
|
||||
text.0 = display_text(&buf.0, *kind);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Printable character — append to focused buffer.
|
||||
if let Some(ch) = ev.text.as_deref().and_then(printable_char) {
|
||||
for (kind, mut buf, mut text, mut color) in &mut fields {
|
||||
if *kind == focused.kind() {
|
||||
if buf.0.len() < 256 {
|
||||
buf.0.push(ch);
|
||||
}
|
||||
text.0 = display_text(&buf.0, *kind);
|
||||
color.0 = TEXT_PRIMARY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the border colour of each input field based on which field is focused.
|
||||
fn update_field_borders(
|
||||
screen: Query<(), With<SyncSetupScreen>>,
|
||||
focused: Res<SyncFocusedField>,
|
||||
mut borders: Query<(&SyncFieldKind, &mut BorderColor), Without<SyncFieldBuffer>>,
|
||||
) {
|
||||
if screen.is_empty() || !focused.is_changed() {
|
||||
return;
|
||||
}
|
||||
for (kind, mut border) in &mut borders {
|
||||
*border = BorderColor::all(if *kind == focused.kind() {
|
||||
ACCENT_PRIMARY
|
||||
} else {
|
||||
BORDER_SUBTLE
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires an async auth task when Login or Register is clicked.
|
||||
fn handle_auth_button(
|
||||
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||
mut pending: ResMut<PendingAuthTask>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||
) {
|
||||
let login_clicked = login_q
|
||||
.iter()
|
||||
.any(|i| *i == Interaction::Pressed);
|
||||
let register_clicked = register_q
|
||||
.iter()
|
||||
.any(|i| *i == Interaction::Pressed);
|
||||
|
||||
if !login_clicked && !register_clicked {
|
||||
return;
|
||||
}
|
||||
if pending.task.is_some() {
|
||||
return; // Already in flight.
|
||||
}
|
||||
|
||||
// Collect field values.
|
||||
let mut url = String::new();
|
||||
let mut username = String::new();
|
||||
let mut password = String::new();
|
||||
for (kind, buf) in &fields {
|
||||
match kind {
|
||||
SyncFieldKind::Url => url = buf.0.trim().to_string(),
|
||||
SyncFieldKind::Username => username = buf.0.trim().to_string(),
|
||||
SyncFieldKind::Password => password = buf.0.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// Basic validation before hitting the network.
|
||||
let validation_error = if url.is_empty() {
|
||||
Some("Server URL is required")
|
||||
} else if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
Some("URL must start with http:// or https://")
|
||||
} else if username.is_empty() {
|
||||
Some("Username is required")
|
||||
} else if password.is_empty() {
|
||||
Some("Password is required")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(msg) = validation_error {
|
||||
for (mut text, mut color) in &mut error_nodes {
|
||||
text.0 = msg.to_string();
|
||||
color.0 = STATE_DANGER;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear error and show busy indicator.
|
||||
for (mut text, _) in &mut error_nodes {
|
||||
text.0 = "Connecting…".to_string();
|
||||
}
|
||||
for mut vis in &mut busy_nodes {
|
||||
*vis = Visibility::Visible;
|
||||
}
|
||||
|
||||
let is_register = register_clicked;
|
||||
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
||||
let pw = password.clone();
|
||||
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(async {
|
||||
if is_register {
|
||||
client.register(&pw).await
|
||||
} else {
|
||||
client.login(&pw).await
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
pending.task = Some(task);
|
||||
pending.url = url;
|
||||
pending.username = username;
|
||||
}
|
||||
|
||||
/// Polls the in-flight auth task. On success updates settings + provider.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_auth_task(
|
||||
mut pending: ResMut<PendingAuthTask>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
settings_path: Res<SettingsStoragePath>,
|
||||
mut provider: ResMut<SyncProviderResource>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||
screen: Query<Entity, With<SyncSetupScreen>>,
|
||||
mut settings_screen: ResMut<SettingsScreen>,
|
||||
mut commands: Commands,
|
||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = pending.task.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return;
|
||||
};
|
||||
pending.task = None;
|
||||
|
||||
for mut vis in &mut busy_nodes {
|
||||
*vis = Visibility::Hidden;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok((access_token, refresh_token)) => {
|
||||
let url = pending.url.clone();
|
||||
let username = pending.username.clone();
|
||||
|
||||
// Persist tokens to the OS keychain / Android Keystore.
|
||||
if let Err(e) = store_tokens(&username, &access_token, &refresh_token) {
|
||||
for (mut text, mut color) in &mut error_nodes {
|
||||
text.0 = format!("Token storage failed: {e}");
|
||||
color.0 = STATE_DANGER;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update settings and persist.
|
||||
settings.0.sync_backend = SyncBackend::SolitaireServer {
|
||||
url: url.clone(),
|
||||
username: username.clone(),
|
||||
};
|
||||
if let Some(path) = &settings_path.0
|
||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||
{
|
||||
warn!("sync setup: failed to persist settings: {e}");
|
||||
}
|
||||
|
||||
// Hot-swap the provider so pull/push use the new credentials.
|
||||
provider.0 = Arc::new(SolitaireServerClient::new(url, username.clone()));
|
||||
|
||||
// Kick off an immediate pull with the new provider.
|
||||
manual_sync.write(ManualSyncRequestEvent);
|
||||
|
||||
// Close both the setup modal and the settings panel.
|
||||
for entity in &screen {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
settings_screen.0 = false;
|
||||
|
||||
toast.write(InfoToastEvent(format!("Connected as {username}")));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = match e {
|
||||
SyncError::Auth(m) => m,
|
||||
SyncError::Network(m) => format!("Network error: {m}"),
|
||||
SyncError::Serialization(m) => format!("Unexpected response: {m}"),
|
||||
SyncError::UnsupportedPlatform => "Unsupported platform".into(),
|
||||
};
|
||||
for (mut text, mut color) in &mut error_nodes {
|
||||
text.0 = msg.clone();
|
||||
color.0 = STATE_DANGER;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses the sync-setup modal on Cancel click or Escape.
|
||||
fn handle_cancel(
|
||||
cancel_q: Query<&Interaction, (Changed<Interaction>, With<SyncCancelButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screen: Query<Entity, With<SyncSetupScreen>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|
||||
|| keys.just_pressed(KeyCode::Escape);
|
||||
if !cancelled || screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
for entity in &screen {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears stored tokens, resets the backend to `Local`, and hot-swaps the
|
||||
/// provider. Triggered by "Disconnect" in the settings sync section.
|
||||
fn handle_logout(
|
||||
mut events: MessageReader<SyncLogoutRequestEvent>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
settings_path: Res<SettingsStoragePath>,
|
||||
mut provider: ResMut<SyncProviderResource>,
|
||||
mut settings_screen: ResMut<SettingsScreen>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
events.clear();
|
||||
|
||||
// Extract username before resetting so we can clear the right keychain key.
|
||||
let username = match &settings.0.sync_backend {
|
||||
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
|
||||
SyncBackend::Local => None,
|
||||
};
|
||||
|
||||
if let Some(u) = username
|
||||
&& let Err(e) = delete_tokens(&u)
|
||||
{
|
||||
warn!("sync logout: failed to clear tokens: {e}");
|
||||
}
|
||||
|
||||
settings.0.sync_backend = SyncBackend::Local;
|
||||
if let Some(path) = &settings_path.0
|
||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||
{
|
||||
warn!("sync logout: failed to persist settings: {e}");
|
||||
}
|
||||
|
||||
provider.0 = Arc::new(LocalOnlyProvider);
|
||||
settings_screen.0 = false;
|
||||
toast.write(InfoToastEvent("Disconnected from sync server".to_string()));
|
||||
}
|
||||
|
||||
/// Opens the account-deletion confirmation modal when `DeleteAccountRequestEvent` fires.
|
||||
fn open_delete_confirm_modal(
|
||||
mut events: MessageReader<DeleteAccountRequestEvent>,
|
||||
existing: Query<(), With<DeleteConfirmScreen>>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
events.clear();
|
||||
if !existing.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
|
||||
}
|
||||
|
||||
/// Despawns the delete-confirm modal on the cancel button or Escape.
|
||||
fn handle_delete_cancel(
|
||||
cancel_q: Query<&Interaction, (Changed<Interaction>, With<DeleteCancelButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|
||||
|| keys.just_pressed(KeyCode::Escape);
|
||||
if !cancelled || screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
for entity in &screen {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the async delete-account task when "Delete Forever" is clicked.
|
||||
fn handle_delete_confirm(
|
||||
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
mut pending: ResMut<PendingDeleteTask>,
|
||||
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !confirm_q.iter().any(|i| *i == Interaction::Pressed) || pending.0.is_some() {
|
||||
return;
|
||||
}
|
||||
// Despawn the confirmation modal immediately so the player can't double-click.
|
||||
for entity in &screen {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
let provider = provider.0.clone();
|
||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.delete_account())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Polls the in-flight delete-account task. On success fires `SyncLogoutRequestEvent`.
|
||||
fn poll_delete_task(
|
||||
mut pending: ResMut<PendingDeleteTask>,
|
||||
mut logout: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = pending.0.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return;
|
||||
};
|
||||
pending.0 = None;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
logout.write(SyncLogoutRequestEvent);
|
||||
toast.write(InfoToastEvent("Account deleted".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = match e {
|
||||
SyncError::Auth(_) => "Not authorised — try reconnecting first".to_string(),
|
||||
SyncError::Network(m) => format!("Network error: {m}"),
|
||||
other => format!("Delete failed: {other}"),
|
||||
};
|
||||
toast.write(InfoToastEvent(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, SyncSetupScreen, Z_MODAL_PANEL + 1, |card| {
|
||||
// Header.
|
||||
card.spawn(Node {
|
||||
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|h| {
|
||||
h.spawn((
|
||||
Text::new("Connect to Server"),
|
||||
make_font(font_res, TYPE_BODY_LG),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Scrollable body — three labeled input fields + error line.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_3,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
flex_grow: 1.0,
|
||||
..default()
|
||||
})
|
||||
.with_children(|body| {
|
||||
spawn_field(
|
||||
body,
|
||||
SyncFieldKind::Url,
|
||||
"Server URL",
|
||||
"https://your-server.example.com",
|
||||
true, // focused initially
|
||||
font_res,
|
||||
);
|
||||
spawn_field(
|
||||
body,
|
||||
SyncFieldKind::Username,
|
||||
"Username",
|
||||
"your-username",
|
||||
false,
|
||||
font_res,
|
||||
);
|
||||
spawn_field(
|
||||
body,
|
||||
SyncFieldKind::Password,
|
||||
"Password",
|
||||
"••••••••",
|
||||
false,
|
||||
font_res,
|
||||
);
|
||||
|
||||
// Error / status line.
|
||||
body.spawn(Node {
|
||||
min_height: Val::Px(18.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
SyncAuthError,
|
||||
SyncBusyOverlay,
|
||||
Text::new(String::new()),
|
||||
make_font(font_res, TYPE_CAPTION),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
Visibility::Hidden,
|
||||
));
|
||||
});
|
||||
|
||||
// Tab hint.
|
||||
body.spawn((
|
||||
Text::new("Tab = next field"),
|
||||
make_font(font_res, TYPE_CAPTION),
|
||||
TextColor(TEXT_DISABLED),
|
||||
));
|
||||
});
|
||||
|
||||
// Action row.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
column_gap: VAL_SPACE_2,
|
||||
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
|
||||
..default()
|
||||
})
|
||||
.with_children(|actions| {
|
||||
spawn_action_button(actions, SyncCancelButton, "Cancel", false, font_res);
|
||||
spawn_action_button(actions, SyncRegisterButton, "Register", false, font_res);
|
||||
spawn_action_button(actions, SyncLoginButton, "Log In", true, font_res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_field(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
kind: SyncFieldKind,
|
||||
label: &str,
|
||||
placeholder: &str,
|
||||
focused: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(4.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|col| {
|
||||
// Label.
|
||||
col.spawn((
|
||||
Text::new(label.to_string()),
|
||||
make_font(font_res, TYPE_CAPTION),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Input border container — carries kind for the border-update system.
|
||||
col.spawn((
|
||||
kind,
|
||||
Node {
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
padding: UiRect::axes(VAL_SPACE_2, Val::Px(6.0)),
|
||||
min_height: Val::Px(32.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(if focused { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|border| {
|
||||
// Inner text / buffer entity.
|
||||
border.spawn((
|
||||
kind,
|
||||
SyncFieldBuffer(String::new()),
|
||||
Text::new(placeholder.to_string()),
|
||||
make_font(font_res, TYPE_BODY),
|
||||
TextColor(TEXT_DISABLED),
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_action_button<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
primary: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let bg = if primary { ACCENT_PRIMARY } else { BG_ELEVATED_HI };
|
||||
let fg = TEXT_PRIMARY;
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
Button,
|
||||
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),
|
||||
BorderColor::all(if primary { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label.to_string()),
|
||||
make_font(font_res, TYPE_BODY),
|
||||
TextColor(fg),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_font(font_res: Option<&FontResource>, size: f32) -> TextFont {
|
||||
TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: size,
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_delete_confirm_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, DeleteConfirmScreen, Z_MODAL_PANEL + 2, |card| {
|
||||
// Header.
|
||||
card.spawn(Node {
|
||||
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|h| {
|
||||
h.spawn((
|
||||
Text::new("Delete Account"),
|
||||
make_font(font_res, TYPE_BODY_LG),
|
||||
TextColor(STATE_DANGER),
|
||||
));
|
||||
});
|
||||
|
||||
// Body.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|body| {
|
||||
body.spawn((
|
||||
Text::new(
|
||||
"This permanently deletes your account and all server data.\n\
|
||||
Local progress is kept. This cannot be undone.",
|
||||
),
|
||||
make_font(font_res, TYPE_BODY),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Actions.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
column_gap: VAL_SPACE_2,
|
||||
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
|
||||
..default()
|
||||
})
|
||||
.with_children(|actions| {
|
||||
spawn_action_button(actions, DeleteCancelButton, "Cancel", false, font_res);
|
||||
// "Delete Forever" button — danger styling (STATE_DANGER background).
|
||||
actions
|
||||
.spawn((
|
||||
DeleteConfirmButton,
|
||||
Button,
|
||||
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(STATE_DANGER),
|
||||
BorderColor::all(STATE_DANGER),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Delete Forever"),
|
||||
make_font(font_res, TYPE_BODY),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the display string for a field — password fields show bullets.
|
||||
fn display_text(raw: &str, kind: SyncFieldKind) -> String {
|
||||
if kind == SyncFieldKind::Password {
|
||||
"•".repeat(raw.len())
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a printable ASCII character from a SmolStr keypress text.
|
||||
fn printable_char(text: &str) -> Option<char> {
|
||||
let ch = text.chars().next()?;
|
||||
// Accept printable ASCII: 0x20 (space) through 0x7e (~).
|
||||
(' '..='~').contains(&ch).then_some(ch)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
|
||||
use crate::safe_area::SafeAreaInsets;
|
||||
use crate::resources::GameStateResource;
|
||||
#[cfg(test)]
|
||||
use crate::layout::TABLE_COLOUR;
|
||||
@@ -82,6 +83,7 @@ impl Plugin for TablePlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
||||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||||
apply_theme_on_settings_change,
|
||||
apply_hint_pile_highlight,
|
||||
@@ -146,18 +148,38 @@ fn setup_table(
|
||||
existing_camera: Query<(), With<Camera>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
bg_images: Option<Res<BackgroundImageSet>>,
|
||||
safe_area: Option<Res<SafeAreaInsets>>,
|
||||
) {
|
||||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||||
// may have added one in tests).
|
||||
// may have added one in tests). Use the felt-green clear colour so the
|
||||
// background reads as green even before the background PNG finishes
|
||||
// loading (which is asynchronous and can lag by several frames on
|
||||
// Android).
|
||||
if existing_camera.is_empty() {
|
||||
commands.spawn(Camera2d);
|
||||
commands.spawn((
|
||||
Camera2d,
|
||||
Camera {
|
||||
clear_color: ClearColorConfig::Custom(Color::srgb(
|
||||
crate::layout::TABLE_COLOUR[0],
|
||||
crate::layout::TABLE_COLOUR[1],
|
||||
crate::layout::TABLE_COLOUR[2],
|
||||
)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let window_size = windows
|
||||
.iter()
|
||||
.next()
|
||||
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
|
||||
let layout = compute_layout(window_size);
|
||||
let (window_size, scale) = windows.iter().next().map_or(
|
||||
(Vec2::new(1280.0, 800.0), 1.0f32),
|
||||
|w| (default_window_size(w), w.scale_factor()),
|
||||
);
|
||||
// Safe-area insets arrive from JNI asynchronously; they are almost always
|
||||
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
|
||||
// arrive and issues a synthetic WindowResized to re-snap all game objects.
|
||||
let insets = safe_area.as_deref().copied().unwrap_or_default();
|
||||
let safe_area_top = insets.top / scale;
|
||||
let safe_area_bottom = insets.bottom / scale;
|
||||
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
|
||||
|
||||
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
|
||||
|
||||
@@ -258,20 +280,31 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
PileMarker(pile.clone()),
|
||||
));
|
||||
|
||||
// Foundation slots no longer carry a suit letter — any Ace can claim
|
||||
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
|
||||
// foundation markers render as plain translucent rectangles.
|
||||
|
||||
// Task #43 — King indicator on empty tableau placeholders.
|
||||
if let PileType::Tableau(_) = &pile {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("K"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
// Tableau markers show "K" (only a King may start an empty column).
|
||||
// Foundation markers show "A" (only an Ace may claim an empty slot).
|
||||
// Neither label carries a suit because any suit may start any slot.
|
||||
match &pile {
|
||||
PileType::Tableau(_) => {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("K"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
PileType::Foundation(_) => {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("A"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +312,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn on_window_resized(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
safe_area: Option<Res<SafeAreaInsets>>,
|
||||
windows: Query<&Window>,
|
||||
mut layout_res: Option<ResMut<LayoutResource>>,
|
||||
mut backgrounds: Query<
|
||||
(&mut Sprite, &mut Transform),
|
||||
@@ -290,7 +325,11 @@ fn on_window_resized(
|
||||
return;
|
||||
};
|
||||
let window_size = Vec2::new(ev.width, ev.height);
|
||||
let new_layout = compute_layout(window_size);
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let insets = safe_area.as_deref().copied().unwrap_or_default();
|
||||
let safe_area_top = insets.top / scale;
|
||||
let safe_area_bottom = insets.bottom / scale;
|
||||
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
|
||||
|
||||
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||||
layout_res.0 = new_layout.clone();
|
||||
@@ -318,6 +357,33 @@ fn on_window_resized(
|
||||
// and forth" jitter).
|
||||
}
|
||||
|
||||
/// Bridges the asynchronous safe-area inset update into the synchronous
|
||||
/// window-resize pipeline. When Android's JNI delivers the real inset values
|
||||
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
|
||||
/// `WindowResized` event carrying the current window size. `on_window_resized`
|
||||
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
|
||||
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
|
||||
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
|
||||
/// will snap card sprites to the corrected positions.
|
||||
fn on_safe_area_changed(
|
||||
safe_area: Option<Res<SafeAreaInsets>>,
|
||||
windows: Query<(Entity, &Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
) {
|
||||
let Some(safe_area) = safe_area else { return; };
|
||||
if !safe_area.is_changed() {
|
||||
return;
|
||||
}
|
||||
let Some((entity, window)) = windows.iter().next() else {
|
||||
return;
|
||||
};
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: window.resolution.width(),
|
||||
height: window.resolution.height(),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #6 — Hint pile-marker highlight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -328,6 +328,8 @@ pub fn spawn_modal_button<M: Component>(
|
||||
variant: ButtonVariant,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
#[cfg(target_os = "android")]
|
||||
let hotkey: Option<&'static str> = None;
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_label = TextFont {
|
||||
font: font_handle.clone(),
|
||||
|
||||
@@ -167,10 +167,11 @@ pub struct SessionAchievements {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WinSummaryOverlay;
|
||||
|
||||
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
|
||||
#[derive(Component, Debug)]
|
||||
enum WinSummaryButton {
|
||||
PlayAgain,
|
||||
WatchReplay,
|
||||
}
|
||||
|
||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||
@@ -602,26 +603,58 @@ fn spawn_win_summary_after_delay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the win-summary modal and fires `NewGameRequestEvent` when
|
||||
/// the player presses "Play Again".
|
||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||||
fn handle_win_summary_buttons(
|
||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
|
||||
let pressed: Vec<&WinSummaryButton> = interaction_query
|
||||
.iter()
|
||||
.filter(|(i, _)| **i == Interaction::Pressed)
|
||||
.map(|(_, b)| b)
|
||||
.collect();
|
||||
|
||||
for button in pressed {
|
||||
match button {
|
||||
WinSummaryButton::PlayAgain => {
|
||||
// Despawn the modal.
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
new_game.write(NewGameRequestEvent::default());
|
||||
}
|
||||
WinSummaryButton::WatchReplay => {
|
||||
let latest = history
|
||||
.as_ref()
|
||||
.and_then(|h| h.0.replays.last())
|
||||
.cloned();
|
||||
match (latest, playback.as_mut()) {
|
||||
(Some(replay), Some(pb)) => {
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
crate::replay_playback::start_replay_playback(
|
||||
&mut commands,
|
||||
pb,
|
||||
replay,
|
||||
);
|
||||
}
|
||||
(Some(_), None) => {
|
||||
toast.write(InfoToastEvent(
|
||||
"Replay playback not available".to_string(),
|
||||
));
|
||||
}
|
||||
(None, _) => {
|
||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -811,28 +844,56 @@ fn spawn_overlay(
|
||||
spawn_achievements_section(card, &session.names);
|
||||
}
|
||||
|
||||
// Play Again button
|
||||
card.spawn((
|
||||
WinSummaryButton::PlayAgain,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(28.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
// Append the Enter / Return glyph so keyboard players see
|
||||
// the accelerator on the button itself — mirrors the
|
||||
// chip-style hints on every modal button helper.
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
// Button row: Watch Replay + Play Again side by side.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Watch Replay (secondary style)
|
||||
row.spawn((
|
||||
WinSummaryButton::WatchReplay,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
BorderColor::all(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Watch Replay"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Play Again (primary style)
|
||||
row.spawn((
|
||||
WinSummaryButton::PlayAgain,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copy this file to .env and fill in the values.
|
||||
# The server reads these on startup via dotenvy.
|
||||
|
||||
# SQLite database path. For local dev use a file path; for Docker use the
|
||||
# volume-mounted path (see docker-compose.yml).
|
||||
DATABASE_URL=sqlite://sol.db
|
||||
|
||||
# HS256 signing secret for JWT tokens. Use at least 32 random characters.
|
||||
# Generate one with: openssl rand -hex 32
|
||||
JWT_SECRET=change-me-use-openssl-rand-hex-32
|
||||
|
||||
# TCP port to listen on (optional, default 8080).
|
||||
# SERVER_PORT=8080
|
||||
@@ -0,0 +1,57 @@
|
||||
# --- Build stage ---
|
||||
FROM rust:1.95-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install musl tools for a fully static binary and sqlx-cli for compile-time
|
||||
# query checking (SQLX_OFFLINE=true skips the live-DB check at build time).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only the files needed to build the server crate.
|
||||
# Layer order: workspace manifests first so dependency fetches are cached.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
|
||||
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
|
||||
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
|
||||
|
||||
# Stub every crate source so `cargo fetch` succeeds without full source.
|
||||
RUN mkdir -p solitaire_sync/src solitaire_server/src solitaire_core/src && \
|
||||
echo "pub fn _stub() {}" > solitaire_sync/src/lib.rs && \
|
||||
echo "pub fn _stub() {}" > solitaire_core/src/lib.rs && \
|
||||
echo "pub fn _stub() {}" > solitaire_server/src/lib.rs && \
|
||||
echo "fn main() {}" > solitaire_server/src/main.rs
|
||||
|
||||
RUN cargo fetch --locked
|
||||
|
||||
# Now copy real source and build in release mode.
|
||||
COPY solitaire_core/src ./solitaire_core/src
|
||||
COPY solitaire_sync/src ./solitaire_sync/src
|
||||
COPY solitaire_server/src ./solitaire_server/src
|
||||
COPY solitaire_server/migrations ./solitaire_server/migrations
|
||||
# sqlx offline query cache — required when SQLX_OFFLINE=true so the
|
||||
# compile-time macros don't need a live database.
|
||||
COPY .sqlx ./.sqlx
|
||||
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cargo build --release --locked -p solitaire_server --bin solitaire_server
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/solitaire_server ./solitaire_server
|
||||
# Migrations are embedded via sqlx::migrate!("./migrations") relative to the
|
||||
# crate root at compile time — they do not need to be copied here.
|
||||
|
||||
ENV SERVER_PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./solitaire_server"]
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: solitaire_server/Dockerfile
|
||||
image: solitaire-quest-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
# SQLite database persisted outside the container.
|
||||
- db-data:/app/data
|
||||
environment:
|
||||
DATABASE_URL: sqlite:///app/data/sol.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8080
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migration 003: refresh token rotation table
|
||||
--
|
||||
-- One row per live refresh token. Issued at login/register and rotated
|
||||
-- (old row deleted, new row inserted) on every POST /api/auth/refresh call.
|
||||
-- Cascade on user deletion means no manual cleanup is needed when an
|
||||
-- account is removed.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
jti TEXT PRIMARY KEY, -- UUID v4 embedded in the JWT
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL -- ISO 8601, mirrors the JWT exp claim
|
||||
);
|
||||
|
||||
-- Expired-row pruning (done inline in the refresh handler) uses this index
|
||||
-- to avoid a full table scan on every refresh call.
|
||||
CREATE INDEX IF NOT EXISTS refresh_tokens_expires_at_idx
|
||||
ON refresh_tokens(expires_at);
|
||||
+112
-20
@@ -37,10 +37,13 @@ pub struct AuthResponse {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Successful refresh response — contains only the new access token.
|
||||
/// Successful refresh response — contains the new access token and the rotated
|
||||
/// refresh token. The refresh token is always rotated: the client must store
|
||||
/// the new value and discard the old one.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RefreshResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,21 +76,47 @@ pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: "access".to_string(),
|
||||
jti: None,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
|
||||
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||
///
|
||||
/// Returns `(jwt_string, jti)`. The caller must insert the jti into
|
||||
/// `refresh_tokens` before returning the JWT to the client.
|
||||
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<(String, String), AppError> {
|
||||
let jti = Uuid::new_v4().to_string();
|
||||
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: "refresh".to_string(),
|
||||
jti: Some(jti.clone()),
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
Ok((token, jti))
|
||||
}
|
||||
|
||||
/// Insert a jti row into `refresh_tokens`. Must be called immediately after
|
||||
/// [`make_refresh_token`] and before the token is sent to the client.
|
||||
async fn store_refresh_jti(
|
||||
pool: &sqlx::SqlitePool,
|
||||
jti: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let expires_at = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
sqlx::query!(
|
||||
"INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||
jti,
|
||||
user_id,
|
||||
expires_at
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,9 +189,13 @@ pub async fn register(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
||||
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
||||
store_refresh_jti(&state.pool, &refresh_jti, &user_id).await?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: make_access_token(&user_id, &state.jwt_secret)?,
|
||||
refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?,
|
||||
access_token,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -190,27 +223,74 @@ pub async fn login(
|
||||
return Err(AppError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let access_token = make_access_token(&row_id, &state.jwt_secret)?;
|
||||
let (refresh_token, refresh_jti) = make_refresh_token(&row_id, &state.jwt_secret)?;
|
||||
store_refresh_jti(&state.pool, &refresh_jti, &row_id).await?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: make_access_token(&row_id, &state.jwt_secret)?,
|
||||
refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?,
|
||||
access_token,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
||||
/// `POST /api/auth/refresh` — exchange a valid refresh token for a new token pair.
|
||||
///
|
||||
/// The incoming refresh token is consumed (its jti row is deleted) and a new
|
||||
/// refresh token is issued. Using a consumed token returns 401. Tokens issued
|
||||
/// before rotation was enabled (no `jti` claim) are also rejected with 401 —
|
||||
/// the player must re-login once after upgrading the server.
|
||||
///
|
||||
/// Expired rows from other sessions are pruned on each successful call.
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, AppError> {
|
||||
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
|
||||
|
||||
// Tokens without jti predate rotation — require re-login.
|
||||
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
// Verify this jti is still live (not yet consumed or from a deleted account).
|
||||
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
|
||||
let exists: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||
jti
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// Consume the old token before issuing new ones. If the insert below
|
||||
// fails, the user loses this session (must re-login) — safe by design.
|
||||
sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
|
||||
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
|
||||
store_refresh_jti(&state.pool, &new_jti, &claims.sub).await?;
|
||||
|
||||
// Prune expired rows from all sessions on each successful rotation.
|
||||
// The expires_at index makes this a cheap index-backed scan.
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query!("DELETE FROM refresh_tokens WHERE expires_at < ?", now)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(RefreshResponse {
|
||||
access_token: make_access_token(&claims.sub, &state.jwt_secret)?,
|
||||
access_token: new_access,
|
||||
refresh_token: new_refresh,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
||||
///
|
||||
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
|
||||
/// All related rows (sync_state, refresh_tokens, leaderboard) are removed
|
||||
/// via `ON DELETE CASCADE` in the schema.
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
@@ -229,7 +309,7 @@ mod tests {
|
||||
|
||||
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
|
||||
|
||||
fn decode_token(token: &str) -> Claims {
|
||||
fn decode_claims(token: &str) -> Claims {
|
||||
let mut validation = Validation::default();
|
||||
validation.leeway = 60;
|
||||
decode::<Claims>(
|
||||
@@ -244,27 +324,39 @@ mod tests {
|
||||
#[test]
|
||||
fn make_access_token_decodes_with_correct_claims() {
|
||||
let token = make_access_token("user-123", TEST_SECRET).unwrap();
|
||||
let claims = decode_token(&token);
|
||||
let claims = decode_claims(&token);
|
||||
assert_eq!(claims.sub, "user-123");
|
||||
assert_eq!(claims.kind, "access");
|
||||
assert!(claims.jti.is_none(), "access token must not carry a jti");
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
// expiry should be roughly 24 hours in the future (allow ±60s for test execution)
|
||||
assert!(claims.exp > now + 86_400 - 60);
|
||||
assert!(claims.exp < now + 86_400 + 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_refresh_token_decodes_with_correct_claims() {
|
||||
let token = make_refresh_token("user-456", TEST_SECRET).unwrap();
|
||||
let claims = decode_token(&token);
|
||||
let (token, jti) = make_refresh_token("user-456", TEST_SECRET).unwrap();
|
||||
let claims = decode_claims(&token);
|
||||
assert_eq!(claims.sub, "user-456");
|
||||
assert_eq!(claims.kind, "refresh");
|
||||
assert_eq!(
|
||||
claims.jti.as_deref(),
|
||||
Some(jti.as_str()),
|
||||
"jti in JWT must match returned jti"
|
||||
);
|
||||
assert!(!jti.is_empty(), "jti must be non-empty");
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
// expiry should be roughly 30 days in the future (allow ±60s for test execution)
|
||||
assert!(claims.exp > now + 30 * 86_400 - 60);
|
||||
assert!(claims.exp < now + 30 * 86_400 + 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_refresh_token_generates_unique_jtis() {
|
||||
let (_, jti1) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||
let (_, jti2) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||
assert_ne!(jti1, jti2, "each call must produce a unique jti");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_access_token_wrong_secret_fails_decode() {
|
||||
let token = make_access_token("user-789", TEST_SECRET).unwrap();
|
||||
@@ -279,9 +371,9 @@ mod tests {
|
||||
#[test]
|
||||
fn access_and_refresh_tokens_have_different_kinds() {
|
||||
let access = make_access_token("u", TEST_SECRET).unwrap();
|
||||
let refresh = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||
let a_claims = decode_token(&access);
|
||||
let r_claims = decode_token(&refresh);
|
||||
let (refresh, _jti) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||
let a_claims = decode_claims(&access);
|
||||
let r_claims = decode_claims(&refresh);
|
||||
assert_ne!(a_claims.kind, r_claims.kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,15 +19,61 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use tower_governor::{
|
||||
errors::GovernorError,
|
||||
governor::GovernorConfigBuilder,
|
||||
key_extractor::SmartIpKeyExtractor,
|
||||
key_extractor::{KeyExtractor, SmartIpKeyExtractor},
|
||||
GovernorLayer,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
/// Rate-limiting key extractor for authenticated endpoints.
|
||||
///
|
||||
/// Extracts the authenticated user's UUID from the `Authorization: Bearer` JWT
|
||||
/// so each user gets their own bucket. Falls back to the client IP address when
|
||||
/// the header is absent or the token fails signature verification — this
|
||||
/// protects the server from unauthenticated request floods while ensuring
|
||||
/// legitimate users are always identified by identity rather than IP.
|
||||
///
|
||||
/// Expiry is intentionally **not** checked here: `require_auth` validates the
|
||||
/// full token (including `exp`) and returns 401. Counting an expired token
|
||||
/// against the user's bucket is harmless and avoids returning 500 (the
|
||||
/// `UnableToExtractKey` outcome) for a request that would get 401 anyway.
|
||||
#[derive(Clone)]
|
||||
struct UserIdKeyExtractor {
|
||||
jwt_secret: String,
|
||||
}
|
||||
|
||||
impl KeyExtractor for UserIdKeyExtractor {
|
||||
type Key = String;
|
||||
|
||||
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
|
||||
if let Some(user_id) = self.try_extract_user_id(req.headers()) {
|
||||
return Ok(user_id);
|
||||
}
|
||||
// Fall back to IP so unauthenticated bursts don't bypass throttling.
|
||||
SmartIpKeyExtractor
|
||||
.extract(req)
|
||||
.map(|ip| ip.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl UserIdKeyExtractor {
|
||||
fn try_extract_user_id(&self, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers.get("Authorization")?.to_str().ok()?;
|
||||
let token = value.strip_prefix("Bearer ")?;
|
||||
let key = DecodingKey::from_secret(self.jwt_secret.as_bytes());
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = false;
|
||||
decode::<middleware::Claims>(token, &key, &validation)
|
||||
.ok()
|
||||
.map(|d| d.claims.sub)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
|
||||
///
|
||||
/// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup
|
||||
@@ -64,7 +110,7 @@ pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||
|
||||
fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
// Protected routes require a valid JWT (injected by require_auth middleware).
|
||||
let protected = Router::new()
|
||||
let protected_base = Router::new()
|
||||
.route("/api/sync/pull", get(sync::pull))
|
||||
.route("/api/sync/push", post(sync::push))
|
||||
.route("/api/replays", post(replays::upload))
|
||||
@@ -77,6 +123,25 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
middleware::require_auth,
|
||||
));
|
||||
|
||||
// Per-user rate limit on protected endpoints: 10-request burst, then 1
|
||||
// token replenished every 10 seconds (6/min steady-state). This prevents
|
||||
// a single compromised account from hammering the 1 MB sync/push endpoint.
|
||||
let protected = if rate_limit {
|
||||
let governor_conf = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(UserIdKeyExtractor {
|
||||
jwt_secret: state.jwt_secret.clone(),
|
||||
})
|
||||
.per_second(10)
|
||||
.burst_size(10)
|
||||
.finish()
|
||||
.expect("invalid sync governor config"),
|
||||
);
|
||||
protected_base.layer(GovernorLayer::new(governor_conf))
|
||||
} else {
|
||||
protected_base
|
||||
};
|
||||
|
||||
// Auth endpoints — rate-limited in production, unrestricted in tests.
|
||||
let auth_routes = Router::new()
|
||||
.route("/api/auth/register", post(auth::register))
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::AppError, AppState};
|
||||
|
||||
/// The claims encoded in our JWT access tokens.
|
||||
/// The claims encoded in our JWTs.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Subject — the user's UUID string.
|
||||
@@ -24,6 +24,10 @@ pub struct Claims {
|
||||
pub exp: usize,
|
||||
/// Token kind: `"access"` or `"refresh"`.
|
||||
pub kind: String,
|
||||
/// JWT ID — UUID v4 embedded in refresh tokens for rotation tracking.
|
||||
/// Access tokens omit this field (`None`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jti: Option<String>,
|
||||
}
|
||||
|
||||
/// The authenticated user identity injected into request extensions after
|
||||
@@ -135,6 +139,7 @@ mod tests {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: kind.to_string(),
|
||||
jti: None,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
|
||||
}
|
||||
|
||||
@@ -347,9 +347,10 @@ async fn login_with_unknown_username_returns_401() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
|
||||
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with both
|
||||
/// a new access token and a rotated refresh token.
|
||||
#[tokio::test]
|
||||
async fn refresh_returns_new_access_token() {
|
||||
async fn refresh_returns_new_access_and_refresh_tokens() {
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
@@ -368,6 +369,80 @@ async fn refresh_returns_new_access_token() {
|
||||
body["access_token"].is_string(),
|
||||
"refresh must return a new access_token"
|
||||
);
|
||||
assert!(
|
||||
body["refresh_token"].is_string(),
|
||||
"refresh must return a rotated refresh_token"
|
||||
);
|
||||
let rotated = body["refresh_token"].as_str().unwrap();
|
||||
assert_ne!(
|
||||
rotated, refresh,
|
||||
"rotated refresh token must differ from the original"
|
||||
);
|
||||
}
|
||||
|
||||
/// After a successful rotation, the old refresh token must be rejected (consumed).
|
||||
#[tokio::test]
|
||||
async fn consumed_refresh_token_is_rejected() {
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (_access, original_refresh) =
|
||||
register_user(app.clone(), "grace_rot", "rotation_pass").await;
|
||||
|
||||
// First refresh — consumes original_refresh, returns a new one.
|
||||
let resp1 = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": original_refresh }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp1.status(), StatusCode::OK, "first rotation must succeed");
|
||||
|
||||
// Second attempt with the now-consumed original token must fail.
|
||||
let resp2 = post_json(
|
||||
app,
|
||||
"/api/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": original_refresh }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp2.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"consumed refresh token must return 401"
|
||||
);
|
||||
}
|
||||
|
||||
/// The rotated refresh token must be usable for a subsequent refresh.
|
||||
#[tokio::test]
|
||||
async fn rotated_refresh_token_can_be_used_again() {
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (_access, refresh) = register_user(app.clone(), "helen_rot", "pass_word_1").await;
|
||||
|
||||
// First rotation.
|
||||
let resp1 = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": refresh }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp1.status(), StatusCode::OK);
|
||||
let rotated = body_json(resp1).await;
|
||||
let second_refresh = rotated["refresh_token"].as_str().unwrap().to_string();
|
||||
|
||||
// Second rotation using the first rotated token.
|
||||
let resp2 = post_json(
|
||||
app,
|
||||
"/api/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": second_refresh }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp2.status(),
|
||||
StatusCode::OK,
|
||||
"rotated token must work for a second rotation"
|
||||
);
|
||||
let body2 = body_json(resp2).await;
|
||||
assert!(body2["access_token"].is_string());
|
||||
}
|
||||
|
||||
/// Supplying an access token to `POST /api/auth/refresh` must be rejected because
|
||||
@@ -1448,6 +1523,68 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The 11th `POST /api/sync/push` from the same authenticated user within the
|
||||
/// rate-limit window must return 429 Too Many Requests.
|
||||
///
|
||||
/// Uses [`solitaire_server::build_router`] (rate limiting ON) so the
|
||||
/// GovernorLayer is applied. We register a fresh account, then send 10 pushes
|
||||
/// (consuming the burst allowance), and assert the 11th is throttled.
|
||||
///
|
||||
/// Note: the push body deliberately omits valid `SyncPayload` structure —
|
||||
/// that would return 422, but the rate limiter fires before deserialization,
|
||||
/// so the response code for the first 10 is 422 and for the 11th is 429.
|
||||
/// The test only asserts `!= 429` for requests 1–10 and `== 429` for request 11.
|
||||
#[tokio::test]
|
||||
async fn sync_push_rate_limit_returns_429_on_11th_request() {
|
||||
let state = solitaire_server::AppState {
|
||||
pool: test_pool().await,
|
||||
jwt_secret: TEST_SECRET.to_string(),
|
||||
};
|
||||
let app = solitaire_server::build_router(state);
|
||||
|
||||
// Register a user to obtain a valid JWT for the UserIdKeyExtractor.
|
||||
let (token, _) = register_user(app.clone(), "sync_ratelimit_user", "p4ssword!").await;
|
||||
|
||||
let stub_body = serde_json::to_vec(&serde_json::json!({})).unwrap();
|
||||
|
||||
// First 10 requests consume the burst allowance (burst_size = 10).
|
||||
// The body is intentionally invalid — the rate limiter fires before
|
||||
// deserialization, so we get 422 rather than 200. We only assert != 429.
|
||||
for i in 0..10 {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/sync/push")
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(stub_body.clone()))
|
||||
.expect("failed to build request");
|
||||
let resp = app.clone().oneshot(req).await.expect("oneshot failed");
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"request {} of 10 must not be rate-limited",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
|
||||
// The 11th request must be throttled.
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/sync/push")
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(stub_body))
|
||||
.expect("failed to build 11th request");
|
||||
let resp = app.clone().oneshot(req).await.expect("oneshot failed");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"11th sync push must be rate-limited with 429"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay endpoints
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user