Compare commits
128 Commits
v0.21.0
...
9564f54fc0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9564f54fc0 | |||
| b4ada2a07e | |||
| d44cedbea0 | |||
| 75146847f6 | |||
| 566b112d9e | |||
| 198df75f94 | |||
| 40d07122ba | |||
| 08f74d1e25 | |||
| 6e6f3ef1ff | |||
| 549a817bb1 | |||
| 613bbf8799 | |||
| b129664344 | |||
| 7d7c83ab28 | |||
| bd388fef26 | |||
| 272d31f851 | |||
| 6ce55646d8 | |||
| 432061c3ec | |||
| 22303c62ff | |||
| b1731fe68a | |||
| 2b01f741b4 | |||
| 3110702c74 | |||
| 33fb9627a8 | |||
| 4398403418 | |||
| 002d96f2c8 | |||
| cc161cc37f | |||
| 8a3e30bd16 | |||
| 2a206b994c | |||
| ae7c6c97f1 | |||
| 016fb7214d | |||
| 948864e653 | |||
| 76a754d8e5 | |||
| 9fb59c7d47 | |||
| d714a11cfb | |||
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae | |||
| a5c3188686 | |||
| 6a289b7b50 | |||
| bee712c5ab | |||
| 0db5e9dac4 | |||
| 681a54d9bb | |||
| 7894559ca7 | |||
| ab803c07af | |||
| e43b329fc1 | |||
| 7c07f71f02 | |||
| c1329bbb21 | |||
| 4303ef3f5b | |||
| 4df962ee07 | |||
| f281425b45 | |||
| 2c822ba2d7 | |||
| 7ddf2733c9 | |||
| 585570559c | |||
| 45436d0eda | |||
| 2062bd06f3 | |||
| 0cb15872b1 | |||
| 395a322adc | |||
| 5199a5e499 | |||
| 16242e6d77 | |||
| 202a64db45 | |||
| c0415eb0ee | |||
| a449f60bc5 | |||
| ad5f613277 | |||
| c50eaf81f7 | |||
| b44d2777ec | |||
| 52407e7256 | |||
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 | |||
| a2432dfe7a | |||
| 511550232c | |||
| e5c4f51a6e | |||
| 23902cdc44 | |||
| 3cc8eacafa | |||
| 90e24d9711 | |||
| decbe0bbd9 | |||
| 1873b3f9be | |||
| d11d97e677 | |||
| d322abf67b | |||
| c9e4c0b4cd | |||
| fe68861e10 | |||
| c33b39cf11 | |||
| 23ff62c397 | |||
| 0b2ffca016 | |||
| fbe48acef6 | |||
| cd79877933 | |||
| 52befa6199 | |||
| e63046700c | |||
| ab857bbb6e | |||
| 886e0cf8a1 | |||
| 3d92a91e3b | |||
| 9113cdb483 | |||
| c153363626 | |||
| 93b67f1d0b | |||
| 279e23d0af | |||
| 12fba2157a | |||
| f23df3b805 | |||
| 68d50b5021 | |||
| ec804d54c6 | |||
| d87761d451 | |||
| 2fb2d638bf | |||
| c9af1ead22 | |||
| ed152e2d8f | |||
| 279a834f9d | |||
| daa655a0af | |||
| 4d48cad4e3 | |||
| dd970215cc | |||
| ddb65403c2 | |||
| 62b61cc786 | |||
| 31139ae455 | |||
| 07e035771c | |||
| c5787c6953 | |||
| 716a025352 | |||
| 3eb3a26789 | |||
| 0c1cc40266 |
@@ -1,88 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Install Linux audio/display dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Clippy (all crates, zero warnings)
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
- name: Test (headless crates only — no display required)
|
||||
run: |
|
||||
cargo test -p solitaire_core
|
||||
cargo test -p solitaire_sync
|
||||
cargo test -p solitaire_data
|
||||
cargo test -p solitaire_server
|
||||
|
||||
build:
|
||||
name: Release Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux audio/display dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-release-
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --workspace --release
|
||||
@@ -7,3 +7,11 @@
|
||||
*.tmp
|
||||
data/
|
||||
.claude/
|
||||
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
# Android signing keystores — never commit
|
||||
*.jks
|
||||
*.jks.bak
|
||||
*.keystore
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE user_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -6957,6 +6957,8 @@ dependencies = [
|
||||
"keyring",
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
"tiny-skia 0.12.0",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6965,6 +6967,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"png 0.17.16",
|
||||
"solitaire_core",
|
||||
"solitaire_data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6982,8 +6986,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"jsonwebtoken",
|
||||
"keyring-core",
|
||||
"reqwest",
|
||||
@@ -7007,6 +7013,7 @@ dependencies = [
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"jni 0.21.1",
|
||||
"kira",
|
||||
"resvg",
|
||||
"ron",
|
||||
|
||||
@@ -31,6 +31,7 @@ keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
arboard = { version = "3", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
|
||||
@@ -42,3 +42,29 @@ git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
## Admin — Password Reset
|
||||
|
||||
If a player loses access to their account, the server binary includes a
|
||||
built-in password reset command. Run it on the host (or inside the container)
|
||||
with `DATABASE_URL` pointing at your database:
|
||||
|
||||
```bash
|
||||
# Interactive (prompts for the new password):
|
||||
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||
./solitaire_server --reset-password <username>
|
||||
|
||||
# Non-interactive (piped from a script or password manager):
|
||||
echo "new_password" | \
|
||||
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||
./solitaire_server --reset-password <username>
|
||||
|
||||
# Inside a running Docker container:
|
||||
docker compose exec server sh -c \
|
||||
'echo "new_password" | ./solitaire_server --reset-password alice'
|
||||
```
|
||||
|
||||
On success the user's `password_hash` is updated and **all active refresh
|
||||
tokens are deleted**, so every open session must log in again with the new
|
||||
password. `JWT_SECRET` does not need to be set for this command.
|
||||
|
||||
@@ -1,668 +1,160 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-08 — v0.20.0 cut and tagged at `41a009a`,
|
||||
all post-cut commits pushed to origin (HEAD = `dd101b3`), working
|
||||
tree clean.
|
||||
The cut itself shipped two through-lines: a full **Terminal visual-
|
||||
identity port** (token system, modal scaffold, gameplay-feedback,
|
||||
toasts, table / card chrome, splash cursor) and the **Android
|
||||
persistence shim** that closes the `dirs::data_dir() = None` pitfall
|
||||
flagged in CLAUDE.md §10. Since the cut, the post-tag work split
|
||||
into two arcs: (1) splash boot-screen port + replay-overlay
|
||||
banner enrichments + desktop-adaptation spec — closing Resume-prompt
|
||||
Options B and C (see "Since the v0.20.0 cut" entries below); and
|
||||
(2) **the card-face artwork regeneration arc — Option D, closed
|
||||
2026-05-08** — full Terminal cards rendering on every face, plus
|
||||
three follow-up fixes that surfaced during sign-off (default-theme
|
||||
SVG override, table backgrounds, top-bar overlap), plus a
|
||||
glyph-orientation tweak (no 180° inverted-corner rotation).
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD locally:** see `git rev-parse HEAD`. Most recent narrative
|
||||
entry below names the latest substantive commit; this status line
|
||||
intentionally avoids hard-coding the SHA so a docs-only edit
|
||||
doesn't immediately stale the handoff.
|
||||
- **HEAD on origin:** matches local. All post-cut commits pushed
|
||||
through `dd101b3`. Decide whether to roll the post-tag work
|
||||
into v0.20.1 / v0.21.0-candidates the next time a release is cut.
|
||||
- **Working tree:** clean. No WIP outstanding.
|
||||
- **`artwork/` directory:** still untracked. Intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1184 passing / 0 failing** across the workspace.
|
||||
Net delta from the 1180 baseline: splash polish added two
|
||||
(`build_scanline_image_has_expected_2x2_rgba_bytes`,
|
||||
`scanline_overlay_spawns_and_fades_with_splash`); the
|
||||
card-face migration added one (`card_face_svg_pin` integration
|
||||
test) and consolidated two (`face_colour` CBM tests folded
|
||||
into `text_colour` CBM tests, net −2 then +1 from pin);
|
||||
call it +4 net.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.20.0`. v0.20.0 is on
|
||||
`41a009a`.
|
||||
|
||||
## Since the v0.20.0 cut (un-pushed)
|
||||
|
||||
### `39b8496` `docs(ui): add Terminal desktop-adaptation spec`
|
||||
|
||||
`docs/ui-mockups/desktop-adaptation.md` — 283 lines covering
|
||||
viewport assumptions, seven universal adaptation rules, and per-
|
||||
screen geometry rules for the priority surfaces (Game Table, Win
|
||||
Summary, Settings, Help, Pause, Home, Splash, Stats, and the
|
||||
modal-pattern screens Profile / Achievements / Theme Picker /
|
||||
Daily Challenge). Closes the spec gap — 23 of 24 mockups were
|
||||
mobile-only, but the v0.20.0 token-port pass was already layout-
|
||||
agnostic so nothing shipped broken. The spec matters for *next*
|
||||
ports.
|
||||
|
||||
**Why rules > visual mockups for this gap:** Stitch's
|
||||
`generate_variants` API timed out on the layout-only adaptation
|
||||
prompt (server-side flake, not a prompt-shape issue — confirmed
|
||||
by polling `list_screens` with no new variant landing). A markdown
|
||||
rules file applies to every screen including the 9 missing-plugin
|
||||
surfaces (splash, challenge, time-attack, weekly-goals,
|
||||
leaderboard, sync, level-up, replay-overlay, radial-menu) that
|
||||
aren't in the Stitch project at all. It's also referenceable from
|
||||
code comments and commit messages without loading an image.
|
||||
|
||||
### `cacb19c` `feat(engine): port the splash to the Terminal boot-screen treatment`
|
||||
|
||||
Implements the full mockup-spec splash from
|
||||
`docs/ui-mockups/splash-mobile.html` plus the desktop adaptation
|
||||
rules:
|
||||
|
||||
- **Header**: cursor block (96 px `▌`), wordmark ("Solitaire
|
||||
Quest"), 192 px divider, "TERMINAL EDITION" subtitle.
|
||||
- **Boot log**: three ✓ check rows (`assets loaded`,
|
||||
`theme: terminal`, `progress restored`) + a `▌ ready_` line.
|
||||
Capped at 480 px width on desktop (else 70 % viewport).
|
||||
- **Progress bar**: 1 px track (`BORDER_SUBTLE`) with a 100 %-
|
||||
width cyan (`ACCENT_PRIMARY`) fill + `DONE · 247 ASSETS`
|
||||
caption. Capped at 720 px on desktop (else 80 %).
|
||||
- **Footer**: `BASE16-EIGHTIES` label, eight palette swatches
|
||||
(12 × 12 px each — one per named token in the design system),
|
||||
version line.
|
||||
|
||||
**Refactored the alpha-fade scaffold** from per-marker queries
|
||||
(`SplashTitle` / `SplashSubtitle` / `SplashCursor`) to a single
|
||||
`SplashFadable { base_color: Color }` + `SplashFadableBg`
|
||||
variant. ~15 fadable elements share one global query each;
|
||||
adding more is one component-attach, not three new query types.
|
||||
|
||||
**Skipped, with rationale captured in the commit:**
|
||||
- Scanline overlay (needs a tiled-pattern asset or custom shader).
|
||||
*Open in "Visual-identity follow-ups" below.*
|
||||
- Pulsing cursor on the "ready_" line (would fight the global
|
||||
fade timeline). *Open in "Visual-identity follow-ups" below.*
|
||||
- "RUSTY SOLITAIRE" wordmark from the mockup (the actual product
|
||||
is "Solitaire Quest"; the mockup leaked the repo name). *Closed
|
||||
— the in-engine wordmark stays "Solitaire Quest".*
|
||||
|
||||
### `c84d9f4` `feat(engine): scrub fill bar + per-frame updater for replay overlay`
|
||||
|
||||
Closes the WIP described in the prior handoff. Adds the 1 px cyan
|
||||
scrub bar called for in `docs/ui-mockups/replay-overlay-mobile.html`:
|
||||
a track in `BORDER_SUBTLE` spans the bottom edge of the banner and
|
||||
the cyan `ACCENT_PRIMARY` fill mirrors `cursor / total` via a new
|
||||
`ReplayOverlayScrubFill` component + `update_scrub_fill` system.
|
||||
The pure `scrub_pct` helper is shared between the spawn path
|
||||
(initial fill width) and the per-frame updater so the first paint
|
||||
already reflects state instead of popping `0 → cursor` on the
|
||||
first tick — same shape as the existing `format_progress` /
|
||||
`update_progress_text` split. Two new tests cover the four corners
|
||||
of `scrub_pct` and an end-to-end drive of `ReplayPlaybackState`
|
||||
asserting `Node.width` on the unique scrub-fill entity. Same
|
||||
change-detection guard as the text updaters, so an idle replay
|
||||
leaves the node untouched.
|
||||
|
||||
Header text treatment (closed by `6204db8` immediately below),
|
||||
move-log scroll, MOVE chip, and WIN MOVE callout from the same
|
||||
mockup are still open — separate commits.
|
||||
|
||||
### `6204db8` `feat(engine): port replay banner label to ▌ cursor-block treatment`
|
||||
|
||||
Aligns the replay overlay's headline with the splash boot-screen
|
||||
idiom landed in `cacb19c`: `Replay` → `▌ replay` and
|
||||
`Replay complete` → `▌ replay complete`. The cursor block (`▌`,
|
||||
U+258C) prefixed to a lowercased label reads as a Terminal output
|
||||
line rather than a generic UI title, tightening the family
|
||||
resemblance between the two top-level overlay surfaces. Pure
|
||||
text-content change; no behavioural shift, no new components, no
|
||||
new systems.
|
||||
|
||||
**Mockup deviation (intentional):** the source mockup string in
|
||||
`docs/ui-mockups/replay-overlay-mobile.html` is `▌replay.tsx`. The
|
||||
`.tsx` is a prototyping leak — Stitch renders in React, so the
|
||||
mockup author reached for a familiar filename — and was dropped
|
||||
for the in-engine version since the codebase is Rust. The `▌` +
|
||||
lowercase pattern is what reads as a Terminal-output-line; the
|
||||
extension is incidental. (Same shape as the "RUSTY SOLITAIRE"
|
||||
wordmark deviation noted under `cacb19c` — the mockup leaked the
|
||||
repo name; the actual product is "Solitaire Quest".)
|
||||
|
||||
### `54005d5` `feat(engine): add GAME #YYYY-DDD caption beneath the replay headline`
|
||||
|
||||
Adds the right-anchored game-identifier piece of the replay-overlay
|
||||
mockup, adapted to live *under* the existing "▌ replay" headline as
|
||||
a `TYPE_CAPTION` (11 px) / `TEXT_SECONDARY` subtitle. Format is
|
||||
`GAME #{year}-{ordinal:03}` (e.g. `GAME #2026-122` for a replay
|
||||
recorded 2026-05-02) — year + chrono ordinal gives a compact,
|
||||
monotonically-increasing identifier matching the mockup's
|
||||
`GAME #2024-127` motif. New `ReplayOverlayGameCaption` marker, new
|
||||
pure helper `format_game_caption(state) -> Option<String>` (None
|
||||
for Inactive / Completed since the replay is consumed in those
|
||||
branches; spawn-time fall-through to empty string).
|
||||
|
||||
**Layout impact:** `BANNER_HEIGHT` bumped 48 → 60 px so the new
|
||||
left column (headline + 2 px gap + caption ≈ 39 px content) fits
|
||||
under the scrub bar with room to spare. +12 px banner mass is the
|
||||
deliberate cost of the new content; no other plugin observes
|
||||
`BANNER_HEIGHT` so the change is local.
|
||||
|
||||
Two new tests (1180 → 1182): `format_game_caption_covers_state_corners`
|
||||
pins the three branches plus the zero-pad-to-3-digits invariant
|
||||
for early-January ordinals; `overlay_game_caption_shows_replay_date`
|
||||
drives `ReplayPlaybackState` end-to-end.
|
||||
|
||||
### `e080b49` `feat(engine): restyle replay progress text as Terminal MOVE chip`
|
||||
|
||||
Closes the centre-text half of the replay-overlay enrichments. The
|
||||
plain "Move N of M" text becomes a 1px `ACCENT_PRIMARY`-bordered
|
||||
chip containing "MOVE N/M" — uppercase + slash separator reads as
|
||||
a Terminal output line and matches the floating-chip motif in
|
||||
`docs/ui-mockups/replay-overlay-mobile.html`. The chip lives
|
||||
in-banner rather than floating above the focused card (the
|
||||
screen-takeover treatment that requires plumbing cursor → card
|
||||
identity remains deferred).
|
||||
|
||||
**Implementation note:** `BorderColor` in Bevy 0.18 is a per-side
|
||||
struct, not a tuple — `BorderColor::all(ACCENT_PRIMARY)` is the
|
||||
correct constructor. Worth pinning for next time we touch a
|
||||
border-painted UI surface. The `ReplayOverlayProgressText` marker
|
||||
stays on the inner Text rather than the new chip Node so
|
||||
`update_progress_text` keeps repainting unchanged — a deliberate
|
||||
"markers belong on the entity that updates change" choice.
|
||||
|
||||
Test count unchanged (1182); `overlay_progress_text_reflects_cursor`
|
||||
swapped its assertion from "Move 5 of 10" to "MOVE 5/10".
|
||||
|
||||
This pair (`54005d5` + `e080b49`) closes Option C from the
|
||||
SESSION_HANDOFF Resume prompt's banner-local enrichments. Floating-
|
||||
chip-above-focused-card and the full screen-takeover redesign
|
||||
remain — both data-layer or cross-plugin and intentionally still
|
||||
open.
|
||||
|
||||
### `29136d8` `feat(engine): add pulsing trailing cursor to splash "▌ ready_" line`
|
||||
|
||||
Closes the cursor-pulse half of the splash polish arc deferred in
|
||||
`cacb19c`. The "▌ ready_" line now ends with a 6×12 px cyan Node
|
||||
that pulses on a 1 s sine cadence, multiplied with the global
|
||||
splash fade timeline so the cursor never reaches full alpha while
|
||||
the rest of the splash is still fading in.
|
||||
|
||||
**The "multiply, don't override" pattern.** Two systems write the
|
||||
same `BackgroundColor` per frame: `advance_splash` writes the
|
||||
global-fade alpha, `pulse_splash_cursor` overwrites with
|
||||
`global_alpha × pulse_factor`. Both derive from `SplashAge` on the
|
||||
root, so the writes are commensurate — the second one isn't
|
||||
"fighting" the first, just refining it. This is the cleanest fix
|
||||
for the "fight the global fade timeline" warning the original
|
||||
`cacb19c` skip note flagged.
|
||||
|
||||
**Defensive division guard.** `cursor_pulse_factor(age, period, min)`
|
||||
short-circuits to `1.0` when `period <= 0.0` so a future
|
||||
misconfiguration produces a steady cursor rather than NaN
|
||||
propagation (NaN in alpha = invisible UI, hard to debug). Worth
|
||||
mirroring on every trig/division helper, not just this one.
|
||||
|
||||
One new test (1182 → 1183): `cursor_pulse_factor_corners` pins the
|
||||
peak (factor = 1 at age = period / 4), trough (factor = min at age =
|
||||
period × 3 / 4), and the zero/negative-period guard.
|
||||
|
||||
### `a27cf5a` `feat(engine): add tiled scanline overlay to splash`
|
||||
|
||||
Closes the scanline half of the splash polish arc. A fullscreen
|
||||
`ImageNode` tiles a runtime-generated 2×2 RGBA8 texture over the
|
||||
splash content — top row transparent, bottom row `#1a1a1a` at
|
||||
~30 % alpha — producing the 1 px-pitch horizontal scanline pattern
|
||||
called for in `docs/ui-mockups/splash-mobile.html`.
|
||||
|
||||
**Texture-α × tint-α composite for fade integration.** The 30 %
|
||||
alpha is baked into the texture pixels, not the `ImageNode.color`
|
||||
tint. `advance_splash`'s new third query writes
|
||||
`(1, 1, 1, global_alpha)` into the tint each tick; the GPU
|
||||
multiplies texture-α by tint-α, so the visible composite is
|
||||
`0.3 × global_alpha`. Cleaner than building a "multiplicative
|
||||
fadable" abstraction in the ECS — the GPU already does this
|
||||
multiplication for free.
|
||||
|
||||
**Bevy 0.18 API surprises (worth pinning):**
|
||||
- `RenderAssetUsages` re-exports under `bevy::asset::`, not
|
||||
`bevy::render::render_asset::`. Type name unchanged; module
|
||||
path moved.
|
||||
- `TextureFormat::pixel_size()` returns `Result<usize, _>` rather
|
||||
than the bare `usize` you'd expect for a static format query.
|
||||
Annoying enough that the `debug_assert_eq!` against the buffer
|
||||
length just hard-codes the `2 × 2 × 4 = 16` literal.
|
||||
|
||||
Headless test fixture now also `init_resource::<Assets<Image>>()`
|
||||
since `MinimalPlugins` doesn't pull `AssetPlugin` — same pattern
|
||||
`settings_plugin::tests` already used. Without it, the
|
||||
`Option<ResMut<Assets<Image>>>` parameter on `spawn_splash` would
|
||||
fall through and the scanline overlay would silently skip,
|
||||
defeating the new tests.
|
||||
|
||||
Two new tests (1183 → 1185):
|
||||
`build_scanline_image_has_expected_2x2_rgba_bytes` locks the
|
||||
texture pixels literally so a future tweak can't drift the
|
||||
appearance silently; `scanline_overlay_spawns_and_fades_with_splash`
|
||||
asserts spawn placement under `SplashRoot` and the new
|
||||
fade-images branch's correctness end-to-end.
|
||||
|
||||
This pair (`29136d8` + `a27cf5a`) closes Option B from the
|
||||
SESSION_HANDOFF Resume prompt — both splash polish pieces now
|
||||
shipped.
|
||||
|
||||
### `5623368`…`dd101b3` — Option D card-face migration arc
|
||||
|
||||
Closed 2026-05-08 across nine commits. The full Terminal card
|
||||
artwork now renders end-to-end. Detail breakdown lives in the
|
||||
"Visual-identity follow-ups" punch-list entry below; the short
|
||||
version:
|
||||
|
||||
- Migration plan + pipeline tooling: `5623368` (plan doc),
|
||||
`3a4bb63` (single-card PoC proving the `usvg`/`resvg` pipeline
|
||||
at per-card grain), `babe5cc` (full
|
||||
`solitaire_engine/examples/card_face_generator.rs` example
|
||||
emitting 52 faces + 5 backs into `assets/cards/`), `48b28d2`
|
||||
(the `card_face_svg_pin` integration test pinning rasteriser
|
||||
output via inline FNV-1a hashing of raw RGBA8 bytes — the
|
||||
pin's bootstrap pattern, "empty `EXPECTED` → run → paste",
|
||||
is the maintenance interface for future intentional changes).
|
||||
- Lockstep step 4+5: `e8bf9d7`. New PNG bytes + the 5
|
||||
`card_plugin` constants (`CARD_FACE_COLOUR`,
|
||||
`RED_SUIT_COLOUR`, `BLACK_SUIT_COLOUR`,
|
||||
`CARD_FACE_COLOUR_RED_CBM` → `RED_SUIT_COLOUR_CBM`,
|
||||
`card_back_colour`) + signature shifts in one commit.
|
||||
`face_colour` deleted — Terminal face is uniformly
|
||||
`CARD_FACE_COLOUR` regardless of CBM, so the function
|
||||
collapsed to a constant. `text_colour` gained a
|
||||
`color_blind: bool` parameter (red→cyan suit-glyph swap when
|
||||
CBM is on). Four `face_colour` CBM tests folded into two
|
||||
`text_colour` CBM tests in lockstep.
|
||||
- Three follow-ups that surfaced during sign-off, all from the
|
||||
same "fallback path the migration walked past" pattern:
|
||||
`a14200a` regenerated the embedded **default-theme SVGs** at
|
||||
`solitaire_engine/assets/themes/default/*.svg`; those bytes
|
||||
`include_bytes!()`-embed into the binary and override
|
||||
`assets/cards/*.png` at startup, so the PNG migration alone
|
||||
didn't change what production rendered. `8719f77`
|
||||
regenerated `assets/backgrounds/bg_*.png` to flat Terminal
|
||||
near-black (5 solid-colour PNGs via a new
|
||||
`solitaire_engine/examples/background_generator.rs` example).
|
||||
`ae84dc1` cleared the **top-bar overlap** at portrait/narrow
|
||||
window widths by swapping the action-button row's hardcoded
|
||||
`font_size: 16.0` to `TYPE_BODY` (a typography-migration
|
||||
miss) and stepping horizontal padding from `VAL_SPACE_3`
|
||||
to `VAL_SPACE_2`.
|
||||
- Glyph-rendering fix: `af414b6`. The bundled `FiraMono`
|
||||
doesn't carry usable U+2660-2666 glyphs at the requested
|
||||
size — `usvg` was silently substituting tiny "tofu" marks.
|
||||
Switched suit glyphs from `<text>` elements to inline SVG
|
||||
`<path>` elements via a new `suit_path_d` helper. Path-based
|
||||
rendering bypasses the font system entirely; same bytes on
|
||||
every machine, no fontdb dependency, no substitution risk.
|
||||
Same path data renders correctly whether filled (♥ ♠) or
|
||||
outlined (♦ ♣ — the always-on color-blind glyph
|
||||
differentiation).
|
||||
- Glyph-orientation tweak: `dd101b3`. Removed the 180° rotation
|
||||
from the bottom-right large suit glyph at user request. Both
|
||||
glyphs now render upright. `design-system.md` § Game Cards
|
||||
line 220 updated in lockstep — the deliberate deviation from
|
||||
the traditional inverted-corner-indicator convention is
|
||||
documented in the spec, not just the code.
|
||||
|
||||
The pin test fired exactly twice during this arc (once for the
|
||||
text→path switch, once for the unrotation) and rebaselined
|
||||
cleanly each time via the empty-then-paste pattern. The 5
|
||||
`back_*` hashes stayed identical across both rebaselines —
|
||||
secondary signal that the FNV-1a fingerprinting is purely
|
||||
deterministic on rasteriser output.
|
||||
|
||||
This arc closes Option D from the SESSION_HANDOFF Resume prompt
|
||||
and effectively completes the Terminal visual-identity port —
|
||||
only the toast warning/error variant slots remain wired-but-
|
||||
unused.
|
||||
|
||||
## What shipped in v0.20.0 (frozen at `41a009a`)
|
||||
|
||||
### Terminal visual-identity port
|
||||
|
||||
Top-down stack — every commit downstream of the token system
|
||||
reads from it, so swapping the palette is now a one-file edit:
|
||||
|
||||
- **`ui_theme` token system** (`0d477ac`). base16-eighties
|
||||
palette, 5-rung type scale, 7-rung 4-multiple spacing scale,
|
||||
3-step radius, 14-rung z-index hierarchy, full motion budget,
|
||||
4 invariant-pinning unit tests. Card-shadow alphas pinned to 0
|
||||
(Terminal achieves depth via 1px borders + tonal layering).
|
||||
- **Modal scaffold already on tokens** — `ui_modal` was ported
|
||||
in the same commit's wake; three stale "loud yellow" /
|
||||
"magenta secondary" doc comments fixed.
|
||||
- **Gameplay feedback → semantic state tokens** (`ceec4fc`).
|
||||
Selection / valid-drop tints route through `ACCENT_PRIMARY` /
|
||||
`STATE_WARNING` / `STATE_SUCCESS`.
|
||||
- **Toasts** (`a137607`). New `ToastVariant` enum
|
||||
(Info / Warning / Error / Celebration); opaque `BG_ELEVATED`
|
||||
+ 1px accent border + bottom-anchor. All ten call sites pass
|
||||
their semantic variant.
|
||||
- **`table_plugin` chrome** (`651f406`).
|
||||
`PILE_MARKER_DEFAULT_COLOUR` promoted; `cursor_plugin` imports
|
||||
it, replacing a "kept in sync" doc comment with a compile-
|
||||
enforced invariant. `HINT_PILE_HIGHLIGHT_COLOUR` →
|
||||
`STATE_WARNING`.
|
||||
- **`card_plugin` chrome** (`d752870`). Drag-elevation shadow
|
||||
routes through `CARD_SHADOW_*` tokens. `RIGHT_CLICK_HIGHLIGHT_COLOUR`
|
||||
→ `STATE_SUCCESS`. Stock recycle "↺" text → `TEXT_PRIMARY @ 0.7α`.
|
||||
Card-face / suit / card-back palette intentionally NOT migrated
|
||||
(artwork dependency — see open-list item below).
|
||||
- **Splash cursor** (`cdcadda`). The signature `▌` cyan glyph
|
||||
(96 px) added above the wordmark, matching the spec.
|
||||
*Subsequently expanded post-cut by `cacb19c` into the full
|
||||
boot-screen treatment.*
|
||||
- **Hint-source / dest pairing** (`9891ae4`). `input_plugin`'s
|
||||
source-card tint now matches the destination pile's
|
||||
`STATE_WARNING`.
|
||||
- **Design system + 24-mockup library** (`fa7f98a`).
|
||||
`docs/ui-mockups/design-system.md` + 24 Stitch mockups (HTML +
|
||||
PNG) covering every screen plus 9 missing-plugin surfaces.
|
||||
- **`card_shadow_params` test aligned** (`1d1543e`). Drag-vs-
|
||||
idle shadow assertion loosened to `>=` to accept the Terminal
|
||||
"no shadow" intent without losing the regression-guard.
|
||||
|
||||
### Android persistence
|
||||
|
||||
- **`solitaire_data::data_dir` shim** (`4b51e50`). New
|
||||
`solitaire_data::platform::data_dir()` falls through to
|
||||
`dirs::data_dir()` on desktop and returns the per-app sandbox
|
||||
at `/data/data/com.solitairequest.app/files` on Android — no
|
||||
JNI needed (package id pinned in `[package.metadata.android]`).
|
||||
Six `solitaire_data` callsites + `solitaire_engine/assets/user_dir.rs`
|
||||
migrated. Settings, stats, achievements, replays, game-state,
|
||||
time-attack sessions, and user themes now persist on Android.
|
||||
|
||||
### Inherited from earlier in the cycle (pre-session)
|
||||
|
||||
- Android build target + APK (`fb8b2ac`), runbook (`59424a3`),
|
||||
F3 FPS overlay (`690e1d2`), Smart Window Size opt-out
|
||||
(`e1b8766`), Shareable badge (`9b065e5`), Help cheat-sheet
|
||||
M/P/Enter rows (`35516d3`), `pull_failure_sets_error_status`
|
||||
flake fix (`67c150b`).
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||
|
||||
- **APK launch verification on AVD / device.** `adb install` then
|
||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
||||
The build works and persistence is wired, but no end-to-end
|
||||
device run has been logged. Shakes out runtime bugs the build +
|
||||
unit tests can't catch.
|
||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
||||
Android backend; small custom JNI call.
|
||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
||||
to a stub returning `KeychainUnavailable`; replace with Android
|
||||
Keystore via JNI when sync auth ships on mobile.
|
||||
- **Google Play Games (gpgs) integration.** Listed as a
|
||||
Phase-Android target since Phase 1; now unblocked by the build
|
||||
target.
|
||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||
Either upstream a cargo-apk fix or document `--lib` as
|
||||
canonical in the runbook.
|
||||
|
||||
### Visual-identity follow-ups (opened by v0.20.0's port)
|
||||
|
||||
- *Card-face / suit / card-back artwork regeneration — closed
|
||||
2026-05-08 by the commit chain `5623368` → `dd101b3`.* The
|
||||
Terminal spec called for dark `#1a1a1a` cards with light suit
|
||||
pips (pink for hearts/diamonds, foreground gray for spades/
|
||||
clubs). Closed across nine commits over two arcs:
|
||||
- **Plan + tooling (`5623368`–`48b28d2`):** migration plan
|
||||
doc, single-card PoC, full `card_face_generator` example
|
||||
(52 faces + 5 backs into `assets/cards/`), and the
|
||||
`card_face_svg_pin` integration test pinning rasteriser
|
||||
output via FNV-1a so future `usvg`/`resvg` upgrades surface
|
||||
as test failures rather than silent visual drift.
|
||||
- **Lockstep step 4+5 (`e8bf9d7`):** PNGs + the 5 `card_plugin`
|
||||
constants + signature shifts in one commit.
|
||||
`CARD_FACE_COLOUR_RED_CBM` renamed to `RED_SUIT_COLOUR_CBM`
|
||||
and repurposed from a face-tint to a suit-glyph swap (the
|
||||
Terminal face is uniform `CARD_FACE_COLOUR` regardless of
|
||||
CBM; CBM only swaps red suits to cyan in the glyph itself).
|
||||
`face_colour` deleted, `text_colour` gained a `color_blind`
|
||||
parameter.
|
||||
- **Three follow-ups that surfaced during sign-off:**
|
||||
`a14200a` regenerated the **default-theme SVGs** at
|
||||
`solitaire_engine/assets/themes/default/*.svg` — those
|
||||
`include_bytes!()`-embed into the binary and override
|
||||
`assets/cards/*.png` at runtime, so the PNG migration alone
|
||||
didn't change what production rendered. `8719f77`
|
||||
regenerated `assets/backgrounds/bg_*.png` to flat Terminal
|
||||
near-black (5 solid-colour PNGs via a new
|
||||
`background_generator` example). `ae84dc1` cleared the
|
||||
**top-bar overlap** at portrait/narrow window widths by
|
||||
swapping the action-button row's hardcoded `font_size: 16.0`
|
||||
to `TYPE_BODY` and stepping horizontal padding from
|
||||
`VAL_SPACE_3` to `VAL_SPACE_2`.
|
||||
- **Glyph-rendering fix (`af414b6`):** suit glyphs render as
|
||||
inline SVG paths (not `<text>`) because the bundled
|
||||
`FiraMono` doesn't carry usable U+2660-2666 at the
|
||||
requested size — `usvg` was silently substituting tiny
|
||||
"tofu" marks. Path-based rendering bypasses the font system
|
||||
entirely; same bytes on every machine. The pin test
|
||||
rebaselined cleanly via the empty-then-paste pattern.
|
||||
- **Glyph-orientation tweak (`dd101b3`):** removed the 180°
|
||||
rotation from the bottom-right large suit glyph at user
|
||||
request — both glyphs now render in the same upright
|
||||
orientation. `design-system.md` § Game Cards line 220
|
||||
updated in lockstep to document the deliberate deviation
|
||||
from the traditional inverted-corner-indicator convention.
|
||||
- *Splash boot-loader scanline overlay — closed by `a27cf5a`.*
|
||||
Runtime-generated 2 × 2 RGBA8 texture tiled via
|
||||
`NodeImageMode::Tiled`; per-pixel alpha × tint alpha gives
|
||||
multiplicative fade integration without new abstractions.
|
||||
- *Splash cursor pulse — closed by `29136d8`.* Trailing 6 × 12 px
|
||||
cyan Node, sine-pulsed, multiplied with the global splash fade
|
||||
(the "multiply, don't override" pattern that resolves the
|
||||
original `cacb19c` skip-rationale).
|
||||
- **Replay-overlay enrichments beyond the scrub bar.** Banner-local
|
||||
pieces of the mockup (`docs/ui-mockups/replay-overlay-mobile.html`)
|
||||
all shipped: scrub bar (`c84d9f4`), `▌ replay` cursor-block label
|
||||
(`6204db8`), `GAME #YYYY-DDD` caption (`54005d5`), `MOVE N/M`
|
||||
chip restyle (`e080b49`). What's still open are the cross-plugin
|
||||
/ data-layer pieces: a `MOVE N/M` chip *floating above the
|
||||
focused card* during playback (would need to thread the cursor
|
||||
through to the card layer — `update_progress_text` writes the
|
||||
banner chip but the card-position lookup belongs in `card_plugin`).
|
||||
The full mockup's screen-takeover treatment — mini-tableau
|
||||
preview, playback controls, move-log scroll, WIN MOVE marker on
|
||||
the scrub bar — is a multi-session redesign with
|
||||
data-layer impact (move-log scroller; the WIN MOVE marker
|
||||
needs a `win_move_index` field on `Replay` that doesn't yet
|
||||
exist). Banner-overlay behaviour is intentionally preserved
|
||||
for now.
|
||||
- **Toast Warning / Error variants.** The `ToastVariant` enum
|
||||
has slots for `Warning` (gold) and `Error` (pink) but no
|
||||
in-engine event uses them yet. Wire when a warning- or error-
|
||||
flavoured toast event materialises.
|
||||
|
||||
### Carried forward from v0.19.0
|
||||
|
||||
- **App icon round.** `Window::icon` not yet wired; no
|
||||
`.icns` / `.ico` / Linux hicolor PNG hierarchy. The 11-size
|
||||
icon export the v0.19 handoff referenced is *not* currently
|
||||
in `artwork/` (current `artwork/` holds the reverted Rusty
|
||||
Pixel card PNGs and is intentionally untracked); icon-export
|
||||
needs to be re-run before this item can be picked up.
|
||||
Half-day task once the PNGs are back in place. No cert
|
||||
dependency.
|
||||
|
||||
### Other small candidates
|
||||
|
||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
||||
site renders them today — the Shareable badge therefore lands
|
||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
||||
the badge will need to follow.
|
||||
- **Toast queue / immediate unification.** The two toast paths
|
||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||
for fire-and-forget) now share visual treatment but remain
|
||||
separate functions because they serve different temporal
|
||||
needs (sequential vs. parallel). If overlap becomes a UX
|
||||
issue, merge into one queue with priority lanes.
|
||||
|
||||
### Process notes
|
||||
|
||||
- **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. **Local master has unpushed post-cut commits**
|
||||
— run `git log --oneline origin/master..HEAD` for the live list;
|
||||
`git push` is the next durability step (or roll the post-cut
|
||||
commits into v0.20.1).
|
||||
|
||||
### 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`), cyan primary CTA (`#6fc2ef`), 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 only swaps red → cyan.
|
||||
**Last updated:** 2026-05-12 — WASM winning-sequence test shipped (`b4ada2a`). HEAD locally: `b4ada2a`. Push pending.
|
||||
|
||||
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||
artifacts (Dockerfile + docker-compose), replay upload on win, web replay
|
||||
player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out,
|
||||
and full server integration tests.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
- **HEAD locally:** `b4ada2a` (test: WASM winning-sequence step-through).
|
||||
- **HEAD on origin:** `d44cedb` (pushed — 2 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`.
|
||||
|
||||
---
|
||||
|
||||
## What shipped in Phase 8 (432061c – bd388fe)
|
||||
|
||||
| Commit | Summary |
|
||||
|--------|---------|
|
||||
| `432061c` | Sync setup modal (login/register/connect/disconnect) |
|
||||
| `6ce5564` | Re-auth on expired session + server deployment artifacts |
|
||||
| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor |
|
||||
| `bd388fe` | CHANGELOG v0.23.0 documentation |
|
||||
|
||||
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
||||
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
|
||||
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
|
||||
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
|
||||
- DB migration 002: `replays` table + two indexes
|
||||
- Full server integration tests for replay endpoints
|
||||
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
|
||||
- Stats panel "Copy Share Link" button reads `share_url` from replay history
|
||||
|
||||
---
|
||||
|
||||
## Open punch list (ordered by priority)
|
||||
|
||||
### 1. Documentation debt (no code)
|
||||
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
|
||||
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
|
||||
- [x] SESSION_HANDOFF.md update — this file
|
||||
|
||||
### 2. Leaderboard wiring gaps
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 5. Feature completeness
|
||||
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
|
||||
Settings Appearance section. Shows import path label, scans user_theme_dir()
|
||||
for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
|
||||
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
||||
default never overridden and never called; achievements already sync via
|
||||
`SyncPayload` push. Deleted from trait and blanket impl.
|
||||
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
|
||||
documents `wasm-pack build --target web`, cleans up pkg metadata files,
|
||||
includes dependency guard + install instructions.
|
||||
- **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.
|
||||
|
||||
### 6. Testing gaps
|
||||
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
|
||||
`jwt_refresh_on_401_succeeds` (pull) and
|
||||
`push_retries_after_401_on_expired_access_token` (push) in
|
||||
`solitaire_data/tests/sync_round_trip.rs`.
|
||||
- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver
|
||||
searches seeds 1–200 at test time; steps every move through `ReplayPlayer`;
|
||||
asserts `is_won = true` on the final `StateSnapshot`.
|
||||
|
||||
---
|
||||
|
||||
## ARCHITECTURE.md gaps (for the update pass)
|
||||
|
||||
Items missing from the doc:
|
||||
1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities)
|
||||
2. Replay API endpoints (§9 API Reference — 3 new routes)
|
||||
3. Web replay player route (`/replays/:id` + `ServeDir /web`)
|
||||
4. `SyncProvider` trait: 6 added methods
|
||||
5. Theme system in Bevy plugin table (§5)
|
||||
6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`,
|
||||
`reduce_motion_mode`, `window_geometry`, `selected_card_back`,
|
||||
`selected_background`
|
||||
7. DB migration 002 (§7)
|
||||
8. Update "Last Updated" date
|
||||
|
||||
---
|
||||
|
||||
## Process notes
|
||||
|
||||
- **Commit attribution:** use `funman300` as git user. Co-author line:
|
||||
`Co-Authored-By: Claude Sonnet 4.6 <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.
|
||||
|
||||
---
|
||||
|
||||
## 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.20.0 is tagged at 41a009a; the post-cut work
|
||||
through dd101b3 is pushed to origin (Options B, C, D all closed).
|
||||
Run `git log --oneline 41a009a..HEAD` to see what landed since the
|
||||
tag — substantives: desktop-adaptation spec, splash boot-screen
|
||||
port, replay-overlay banner enrichments, and the full card-face
|
||||
artwork arc (52 faces + 5 backs as Terminal SVG-rasterised PNGs,
|
||||
default-theme SVGs in lockstep, table backgrounds flattened,
|
||||
top-bar layout fix, glyph orientation upright).
|
||||
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`. Working tree is
|
||||
clean. All workspace tests pass (~1180+; check with
|
||||
`cargo test --workspace`), clippy clean.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
READ FIRST (in order):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.20.0] section is the most recent cut
|
||||
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. Push the post-cut commits to origin. Either as-is on master
|
||||
or rolled into a v0.20.1 cut (CHANGELOG entry + tag).
|
||||
Mechanical, but local master diverges from origin until done.
|
||||
B. *Closed by `29136d8` + `a27cf5a`.* Both splash polish
|
||||
pieces shipped (cursor pulse + scanline overlay). No further
|
||||
splash work pending unless a new mockup detail surfaces.
|
||||
C. *Closed by `54005d5` + `e080b49`.* Banner-local replay-overlay
|
||||
pieces all shipped (scrub bar, ▌ label, GAME caption, MOVE
|
||||
chip). Remaining are cross-plugin (floating MOVE chip above
|
||||
the focused card — needs cursor → card-position plumbing) or
|
||||
multi-session (full screen-takeover redesign — move-log
|
||||
scroll, mini tableau, WIN MOVE marker, data-layer impact).
|
||||
Either belongs in its own decision tree the next time replay
|
||||
work surfaces.
|
||||
D. *Closed 2026-05-08 by `5623368`…`dd101b3`.* The full
|
||||
card-face / suit / card-back / default-theme / table-
|
||||
background / top-bar / glyph-orientation arc landed across
|
||||
nine commits. Terminal cards rendering on every face (dark
|
||||
`#1a1a1a` background, pink/gray suit glyphs as inline SVG
|
||||
paths, scanline-pattern cyan-accent backs); both rendering
|
||||
paths (`assets/cards/*.png` and the bundled-default theme
|
||||
SVGs at `solitaire_engine/assets/themes/default/*.svg`) in
|
||||
lockstep; pin test (`card_face_svg_pin`) guards against
|
||||
future rasteriser drift. Visual-identity arc effectively
|
||||
complete — only the toast warning/error variant slots
|
||||
remain wired-but-unused.
|
||||
E. App icon round — re-run artwork/Icon Export.html (the
|
||||
export PNGs are not currently in `artwork/`), then wire
|
||||
Window::icon + generate .icns / .ico. Half-day task. No
|
||||
cert dependency.
|
||||
F. APK launch verification on AVD / device + the JNI bridges
|
||||
it would shake out (ClipboardManager, Keystore).
|
||||
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.
|
||||
|
||||
OPEN AT THE START: ask which of A–F. Don't pick unilaterally.
|
||||
Ask which to start. All are independent; any is a valid next arc.
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 369 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 489 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 927 B |
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the solitaire_wasm crate and install the output into
|
||||
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install wasm-pack
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
#
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||
# committed to git so self-hosters who don't touch the WASM crate can
|
||||
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||
# solitaire_core/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
echo "error: wasm-pack not found." >&2
|
||||
echo " Install with: cargo install wasm-pack" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building solitaire_wasm (target: web)..."
|
||||
wasm-pack build \
|
||||
--target web \
|
||||
--out-dir "$OUT_DIR" \
|
||||
--no-typescript \
|
||||
"$REPO_ROOT/solitaire_wasm"
|
||||
|
||||
# wasm-pack writes a package.json and .gitignore into the output dir.
|
||||
# Remove them — we manage the output directory ourselves.
|
||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||
|
||||
echo "Done. Output:"
|
||||
ls -lh "$OUT_DIR"
|
||||
@@ -0,0 +1,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.
|
||||
@@ -137,18 +137,23 @@ The palette is base16-eighties — a 16-slot terminal palette where indices 00
|
||||
|
||||
## Suit Colors
|
||||
|
||||
**Two-color traditional mapping**, with mandatory color-blind support:
|
||||
**Two-color traditional pairing**, with mandatory color-blind
|
||||
support. Saturated red for hearts + diamonds, near-white for clubs
|
||||
+ spades — the "Microsoft Solitaire on dark mode" feel of a real
|
||||
playing-card deck. (A brief 4-color-deck experiment shipped between
|
||||
v0.21.0 and the next post-cut commit; reverted to traditional
|
||||
2-color at the player's request.)
|
||||
|
||||
| Suit | Default | Color-blind mode | Glyph differentiation |
|
||||
|---|---|---|---|
|
||||
| Hearts | `#fb9fb1` (pink) | `#acc267` (lime) | Solid filled glyph |
|
||||
| Diamonds | `#fb9fb1` (pink) | `#acc267` (lime) | **Outlined glyph (1.5px stroke)** |
|
||||
| Spades | `#d0d0d0` (foreground) | `#d0d0d0` | Solid filled glyph |
|
||||
| Clubs | `#d0d0d0` (foreground) | `#d0d0d0` | **Outlined glyph (1.5px stroke)** |
|
||||
| Hearts | `#e35353` (saturated red) | `#acc267` (lime) | Solid filled glyph |
|
||||
| Diamonds | `#e35353` (saturated red) | `#acc267` (lime) | **Outlined glyph (1.5px stroke)** |
|
||||
| Spades | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | Solid filled glyph |
|
||||
| Clubs | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | **Outlined glyph (1.5px stroke)** |
|
||||
|
||||
The outlined-glyph treatment is the **primary** differentiation mechanism. Color is supplementary. This means a player viewing the game on a monochrome display, or with severe red-green deficiency, can still distinguish all four suits without context. This is a hard requirement, not an optional setting.
|
||||
|
||||
The "color-blind mode" toggle in Settings only swaps red→lime; it does not turn the outlined glyphs on or off, because outlined glyphs are always on. (Was red→cyan before the 2026-05-08 primary-accent swap; CBM moved to lime to stay hue-distinct from the new red-family primary.)
|
||||
The "color-blind mode" toggle in Settings swaps both red suits (hearts + diamonds) from `#e35353` to `#acc267` (lime); clubs + spades stay at the near-white. The toggle does not turn the outlined glyphs on or off, because outlined glyphs are always on. (Was red→cyan before the 2026-05-08 primary-accent swap; CBM moved to lime to stay hue-distinct from the new red-family primary.)
|
||||
|
||||
## Typography
|
||||
|
||||
@@ -217,7 +222,7 @@ Selection highlights use a **2px inset stroke** in `#a54242` following the host
|
||||
|
||||
Flat face design.
|
||||
- Background: `#1a1a1a`
|
||||
- Border: 1px solid in suit color (pink for hearts/diamonds, foreground gray for spades/clubs)
|
||||
- Border: none. The card shape is defined by the body fill alone against the play surface. The earlier 1px suit-coloured border was removed because it produced visible anti-aliasing artifacts at the rounded corners (a "gray sliver" where the colored stroke faded through gray pixels into the dark play surface). The 5-unit brightness gap between `#1a1a1a` body and `#151515` surface is enough to read as a card edge without an explicit stroke.
|
||||
- Top-left: rank in JetBrains Mono Bold 18px + small suit glyph (10px)
|
||||
- Bottom-right: large suit glyph (32px), upright (same orientation as the top-left small glyph — single-orientation digital play does not benefit from the traditional 180° inverted-corner indicator)
|
||||
- Corner radius: 8px
|
||||
@@ -272,7 +277,7 @@ Top-right corner of the HUD: a 6px circular dot.
|
||||
|
||||
## Accessibility
|
||||
|
||||
1. **Color-blind mode** (Settings → Gameplay): swaps red suits' default `#fb9fb1` for `#acc267` (lime). Outlined-glyph differentiation remains active in *all* modes.
|
||||
1. **Color-blind mode** (Settings → Gameplay): swaps the red suits' default `#e35353` for `#acc267` (lime). Outlined-glyph differentiation remains active in *all* modes.
|
||||
2. **High-contrast mode** (Settings → Gameplay): boosts on-surface from `#d0d0d0` to `#f5f5f5`, outline from `#505050` to `#a0a0a0`, suit-red from `#fb9fb1` to `#ff8aa0`.
|
||||
3. **Reduce-motion mode** (Settings → Gameplay): disables card-lift transition (instant z-lift), disables CRT scanline effect, disables the warning-chip pulse animation.
|
||||
4. **Tabular figures** are mandatory for any number that updates live (timer, score, moves) so they don't reflow.
|
||||
|
||||
@@ -22,16 +22,25 @@ bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
|
||||
# `keyring`'s default-store init only matters on platforms with a
|
||||
# real keychain backend (Linux Secret Service, macOS Keychain,
|
||||
# Windows Credential Store). The crate also pulls `rpassword`
|
||||
# transitively, which uses `libc::__errno_location` — a symbol
|
||||
# Android's bionic doesn't expose. Target-gating keeps
|
||||
# `cargo apk build` viable; the call site in `lib.rs` has its own
|
||||
# `cfg(not(target_os = "android"))` guard so the desktop init path
|
||||
# is unchanged.
|
||||
# Desktop-only deps. `keyring`'s default-store init only matters on
|
||||
# platforms with a real keychain backend (Linux Secret Service,
|
||||
# macOS Keychain, Windows Credential Store), and its transitive
|
||||
# `rpassword` uses `libc::__errno_location` — a symbol Android's
|
||||
# bionic doesn't expose. `winit` is promoted from a transitive
|
||||
# Bevy 0.18 → bevy_winit 0.18 → winit 0.30 dep to a direct dep so
|
||||
# the `Window::icon` wiring in `set_window_icon` can construct
|
||||
# `winit::window::Icon` values (bevy_winit 0.18 doesn't re-export
|
||||
# `Icon`). Android draws its launcher icon from the APK manifest,
|
||||
# so neither dep matters there. Target-gating keeps `cargo apk
|
||||
# build` viable; the desktop call sites have their own
|
||||
# `cfg(not(target_os = "android"))` guards.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# `tiny-skia` is already in the workspace deps for `solitaire_engine`;
|
||||
# `solitaire_app` consumes it directly only on the desktop icon path
|
||||
# (PNG → raw RGBA decode for `set_window_icon`).
|
||||
tiny-skia = { workspace = true }
|
||||
|
||||
# --- Android packaging metadata (read by `cargo-apk`) -------------------
|
||||
#
|
||||
@@ -51,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
|
||||
@@ -70,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"
|
||||
|
||||
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -18,19 +18,23 @@ use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::winit::WinitWindows;
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
|
||||
/// App entry point — builds and runs the Bevy app.
|
||||
@@ -74,6 +78,7 @@ pub fn run() {
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
@@ -114,6 +119,9 @@ pub fn run() {
|
||||
// small enough that a few stray dropped frames from
|
||||
// disabling vsync are imperceptible.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
// Android windows always fill the screen; max_width/max_height
|
||||
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
@@ -124,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()
|
||||
}),
|
||||
@@ -140,6 +157,13 @@ pub fn run() {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
|
||||
// The drop-target highlight systems (update_drop_highlights,
|
||||
// update_drop_target_overlays) live in CursorPlugin but ARE useful
|
||||
// on Android — they've been left running because their Bevy system
|
||||
// params compile and function on Android; only the CursorIcon insert
|
||||
// is inert. Gate the whole plugin if the cursor APIs ever cause
|
||||
// Android linker issues; for now it's harmless to leave it registered.
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
@@ -156,7 +180,10 @@ pub fn run() {
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(PlayBySeedPlugin)
|
||||
.add_plugins(DifficultyPlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(SafeAreaInsetsPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
@@ -166,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)
|
||||
@@ -174,6 +202,14 @@ pub fn run() {
|
||||
.add_plugins(SplashPlugin)
|
||||
.add_plugins(DiagnosticsHudPlugin);
|
||||
|
||||
// Wire the runtime window icon. Bevy 0.18 has no first-class
|
||||
// `Window::icon` field; the icon is set through the underlying
|
||||
// `winit::window::Window` via `WinitWindows`. Android draws its
|
||||
// launcher icon from the APK manifest, so the system is desktop-
|
||||
// only — same target-gate as the `winit` dep itself.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
app.add_systems(Update, set_window_icon);
|
||||
|
||||
// Smart default window sizing: when no saved geometry was loaded,
|
||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||
// monitor's logical size on the first frame. Without this, a 4K
|
||||
@@ -185,6 +221,8 @@ pub fn run() {
|
||||
// every fresh launch can flip `disable_smart_default_size` in
|
||||
// Settings to opt out. The flag is checked once at startup; a
|
||||
// mid-session change applies on the next launch.
|
||||
// Android windows are always full-screen; the OS controls sizing.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
@@ -205,6 +243,7 @@ pub fn run() {
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
@@ -251,6 +290,94 @@ fn apply_smart_default_window_size(
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// One-shot Update system that sets the primary window's taskbar /
|
||||
/// title-bar icon to the embedded 256 px Terminal-aesthetic mark
|
||||
/// generated by `solitaire_engine/examples/icon_generator.rs`.
|
||||
///
|
||||
/// Bevy 0.18 has no `Window::icon` field — the icon is set through
|
||||
/// the underlying `winit::window::Window` via the `WinitWindows`
|
||||
/// resource. The system is desktop-only (Android draws its launcher
|
||||
/// icon from the APK manifest, not from any runtime call). Returns
|
||||
/// silently and tries again next frame until both the primary
|
||||
/// window and `WinitWindows` are populated, then sets the icon
|
||||
/// once and self-disables via `Local<bool>`.
|
||||
///
|
||||
/// Icon bytes are `include_bytes!()`-embedded at compile time, same
|
||||
/// shape as the audio assets and default-theme SVGs — no runtime
|
||||
/// asset-path resolution, no `cargo run` working-directory
|
||||
/// assumptions. PNG → RGBA decode runs through `tiny_skia` (already
|
||||
/// in the build for SVG rasterisation), so this system adds zero
|
||||
/// new dependencies on top of the direct `winit` dep that's
|
||||
/// already required for `Icon` construction.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn set_window_icon(
|
||||
mut applied: Local<bool>,
|
||||
primary_window: Query<Entity, With<PrimaryWindow>>,
|
||||
// `Option<NonSend<...>>` rather than `NonSend<...>` because Bevy
|
||||
// 0.18's stricter system-param validation panics on the first
|
||||
// few frames before `WinitWindows` is inserted (the resource is
|
||||
// populated after winit's `Resumed` event, which fires after
|
||||
// the first system-tick batch). The early-return below handles
|
||||
// the `None` window-wrapper case for the same lifecycle reason.
|
||||
winit_windows: Option<NonSend<WinitWindows>>,
|
||||
) {
|
||||
if *applied {
|
||||
return;
|
||||
}
|
||||
let Some(winit_windows) = winit_windows else {
|
||||
return;
|
||||
};
|
||||
let Ok(primary_entity) = primary_window.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(window_wrapper) = winit_windows.get_window(primary_entity) else {
|
||||
// Primary window's underlying winit handle not yet
|
||||
// populated — `WinitWindows` fills in after the first
|
||||
// `Resumed` event. Try again next frame.
|
||||
return;
|
||||
};
|
||||
|
||||
// The 256 × 256 PNG is sufficient for `set_window_icon`; winit
|
||||
// scales it for the actual rendered size. Smaller PNGs in
|
||||
// `assets/icon/` exist for downstream Linux hicolor / Windows
|
||||
// `.ico` / macOS `.icns` packaging — they're not used here.
|
||||
const ICON_BYTES: &[u8] = include_bytes!("../../assets/icon/icon_256.png");
|
||||
|
||||
let pixmap = match tiny_skia::Pixmap::decode_png(ICON_BYTES) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("warn: could not decode embedded window icon PNG: {e}");
|
||||
*applied = true; // don't retry every frame
|
||||
return;
|
||||
}
|
||||
};
|
||||
let rgba = pixmap.data().to_vec();
|
||||
let icon = match winit::window::Icon::from_rgba(rgba, pixmap.width(), pixmap.height()) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("warn: could not construct window icon: {e}");
|
||||
*applied = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
window_wrapper.set_window_icon(Some(icon));
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// Android entry point called by NativeActivity after dlopen-ing the `.so`.
|
||||
/// Sets the `AndroidApp` handle that Bevy's winit backend reads before
|
||||
/// constructing the event loop, then delegates to [`run`].
|
||||
///
|
||||
/// The `#[bevy_main]` proc-macro would generate the same code but only
|
||||
/// works on a function named `main`; our shared entry point is `run`, so
|
||||
/// we emit the equivalent expansion manually.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||
run();
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
|
||||
@@ -12,6 +12,8 @@ publish = false
|
||||
[dependencies]
|
||||
png = "0.17"
|
||||
ab_glyph = "0.2"
|
||||
solitaire_core = { path = "../solitaire_core" }
|
||||
solitaire_data = { path = "../solitaire_data" }
|
||||
|
||||
[[bin]]
|
||||
name = "gen_sfx"
|
||||
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
|
||||
[[bin]]
|
||||
name = "gen_art"
|
||||
path = "src/bin/gen_art.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_seeds"
|
||||
path = "src/bin/gen_seeds.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_difficulty_seeds"
|
||||
path = "src/bin/gen_difficulty_seeds.rs"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`,
|
||||
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||
//!
|
||||
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||
//! provably-winnable seeds).
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \
|
||||
//! --start 0xD1FF0000_00000000 --per-tier 40
|
||||
//! ```
|
||||
//!
|
||||
//! Flags:
|
||||
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000)
|
||||
//! --per-tier Seeds to emit per tier (default 40)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
|
||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||
// whose budget proves it Winnable.
|
||||
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||
("Easy", 1_000, 1_000),
|
||||
("Medium", 5_000, 5_000),
|
||||
("Hard", 25_000, 25_000),
|
||||
("Expert", 100_000, 100_000),
|
||||
("Grandmaster", 200_000, 200_000),
|
||||
];
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
let mut start: u64 = 0xD1FF_0000_0000_0000;
|
||||
let mut per_tier: usize = 40;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--start" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --start requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
start = parse_u64(&val);
|
||||
}
|
||||
"--per-tier" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --per-tier requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
per_tier = val.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: --per-tier must be a positive integer");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("gen_difficulty_seeds: generate tiered seed catalogs");
|
||||
eprintln!(" --start <seed> starting seed (hex or decimal)");
|
||||
eprintln!(" --per-tier <n> seeds per tier (default 40)");
|
||||
return;
|
||||
}
|
||||
other => {
|
||||
eprintln!("error: unknown argument: {other}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if per_tier == 0 {
|
||||
eprintln!("error: --per-tier must be > 0");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let num_tiers = BUDGETS.len();
|
||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||
let mut tried: u64 = 0;
|
||||
let mut seed = start;
|
||||
|
||||
eprintln!(
|
||||
"gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …",
|
||||
per_tier
|
||||
);
|
||||
eprintln!(
|
||||
" Tiers: {}",
|
||||
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
|
||||
while buckets.iter().any(|b| b.len() < per_tier) {
|
||||
tried += 1;
|
||||
'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() {
|
||||
if buckets[i].len() >= per_tier {
|
||||
continue;
|
||||
}
|
||||
let cfg = SolverConfig { move_budget, state_budget };
|
||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||
SolverResult::Winnable => {
|
||||
buckets[i].push(seed);
|
||||
eprintln!(
|
||||
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||
buckets[i].len(),
|
||||
per_tier
|
||||
);
|
||||
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||
}
|
||||
SolverResult::Unwinnable => {
|
||||
// Definitely unsolvable — skip all remaining tiers.
|
||||
break 'tier;
|
||||
}
|
||||
SolverResult::Inconclusive => {
|
||||
// Budget exhausted without proof — try the next larger tier.
|
||||
// If this is the last tier, the seed is discarded (Inconclusive
|
||||
// at max budget means "probably but not provably winnable").
|
||||
if i == num_tiers - 1 {
|
||||
break 'tier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
|
||||
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
|
||||
|
||||
let date = current_date();
|
||||
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
|
||||
println!(
|
||||
" // Generated by solitaire_assetgen::gen_difficulty_seeds \
|
||||
(tier={tier_name}, date={date})"
|
||||
);
|
||||
for chunk in buckets[i].chunks(5) {
|
||||
for s in chunk {
|
||||
println!(
|
||||
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||
(s >> 48) & 0xFFFF,
|
||||
(s >> 32) & 0xFFFF,
|
||||
(s >> 16) & 0xFFFF,
|
||||
s & 0xFFFF,
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(s: &str) -> u64 {
|
||||
let cleaned = s.replace('_', "");
|
||||
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
cleaned.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn current_date() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86400;
|
||||
let mut y = 1970u64;
|
||||
let mut d = days;
|
||||
loop {
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let days_in_year = if leap { 366 } else { 365 };
|
||||
if d < days_in_year {
|
||||
break;
|
||||
}
|
||||
d -= days_in_year;
|
||||
y += 1;
|
||||
}
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let month_days: [u64; 12] = [
|
||||
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||||
];
|
||||
let mut m = 0usize;
|
||||
for &md in &month_days {
|
||||
if d < md {
|
||||
break;
|
||||
}
|
||||
d -= md;
|
||||
m += 1;
|
||||
}
|
||||
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||
//!
|
||||
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p solitaire_assetgen --bin gen_seeds --release -- \
|
||||
//! --start 0xCAFE_BABE_0000_0000 --count 75
|
||||
//! ```
|
||||
//!
|
||||
//! Flags:
|
||||
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xCAFEBABE00000000)
|
||||
//! --count Number of Winnable seeds to emit (default 75)
|
||||
//! --help Print this message
|
||||
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
let mut start: u64 = 0xCAFE_BABE_0000_0000;
|
||||
let mut count: usize = 75;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--start" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --start requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
start = parse_u64(&val);
|
||||
}
|
||||
"--count" => {
|
||||
let val = args.next().unwrap_or_else(|| {
|
||||
eprintln!("error: --count requires a value");
|
||||
std::process::exit(1);
|
||||
});
|
||||
count = val.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: --count must be a positive integer");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("{}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")).lines().take(20).collect::<Vec<_>>().join("\n"));
|
||||
return;
|
||||
}
|
||||
other => {
|
||||
eprintln!("error: unknown argument: {other}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
eprintln!("error: --count must be > 0");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let cfg = SolverConfig::default();
|
||||
let draw_mode = DrawMode::DrawOne;
|
||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||
let mut tried: u64 = 0;
|
||||
let mut seed = start;
|
||||
|
||||
eprintln!(
|
||||
"gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …"
|
||||
);
|
||||
|
||||
while found.len() < count {
|
||||
tried += 1;
|
||||
if matches!(
|
||||
try_solve(seed, draw_mode.clone(), &cfg),
|
||||
SolverResult::Winnable
|
||||
) {
|
||||
found.push(seed);
|
||||
eprintln!(
|
||||
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||
found.len(),
|
||||
count,
|
||||
seed,
|
||||
tried
|
||||
);
|
||||
}
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
|
||||
eprintln!("\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n");
|
||||
|
||||
println!(
|
||||
" // Generated by solitaire_assetgen::gen_seeds \
|
||||
(start=0x{start:016X}, count={count}, date={date})",
|
||||
date = current_date()
|
||||
);
|
||||
for chunk in found.chunks(5) {
|
||||
for s in chunk {
|
||||
println!(
|
||||
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||
(s >> 48) & 0xFFFF,
|
||||
(s >> 32) & 0xFFFF,
|
||||
(s >> 16) & 0xFFFF,
|
||||
s & 0xFFFF,
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(s: &str) -> u64 {
|
||||
let cleaned = s.replace('_', "");
|
||||
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
cleaned.parse().unwrap_or_else(|_| {
|
||||
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn current_date() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86400;
|
||||
// Gregorian calendar computation (Tomohiko Sakamoto's algorithm variant)
|
||||
let mut y = 1970u64;
|
||||
let mut d = days;
|
||||
loop {
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let days_in_year = if leap { 366 } else { 365 };
|
||||
if d < days_in_year {
|
||||
break;
|
||||
}
|
||||
d -= days_in_year;
|
||||
y += 1;
|
||||
}
|
||||
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||
let month_days: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let mut m = 0usize;
|
||||
for &md in &month_days {
|
||||
if d < md {
|
||||
break;
|
||||
}
|
||||
d -= md;
|
||||
m += 1;
|
||||
}
|
||||
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||
}
|
||||
@@ -50,6 +50,35 @@ pub enum DrawMode {
|
||||
DrawThree,
|
||||
}
|
||||
|
||||
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
|
||||
/// catalog is drawn from. `Random` skips verification entirely and uses a
|
||||
/// system-time seed — deals may or may not be winnable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum DifficultyLevel {
|
||||
#[default]
|
||||
Easy,
|
||||
Medium,
|
||||
Hard,
|
||||
Expert,
|
||||
Grandmaster,
|
||||
/// Unverified system-time seed — may or may not be winnable.
|
||||
Random,
|
||||
}
|
||||
|
||||
impl DifficultyLevel {
|
||||
/// Short human-readable label shown in the HUD and win summary.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Easy => "Easy",
|
||||
Self::Medium => "Medium",
|
||||
Self::Hard => "Hard",
|
||||
Self::Expert => "Expert",
|
||||
Self::Grandmaster => "Grandmaster",
|
||||
Self::Random => "Random",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||||
///
|
||||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||||
@@ -59,6 +88,8 @@ pub enum DrawMode {
|
||||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||||
/// countdown around the session and auto-deals a fresh game on every win
|
||||
/// (see `solitaire_engine::TimeAttackPlugin`).
|
||||
/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog
|
||||
/// (or system-time for `Random`). Rules identical to Classic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum GameMode {
|
||||
#[default]
|
||||
@@ -70,6 +101,8 @@ pub enum GameMode {
|
||||
Challenge,
|
||||
/// Play as many games as possible within 10 minutes.
|
||||
TimeAttack,
|
||||
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
|
||||
Difficulty(DifficultyLevel),
|
||||
}
|
||||
|
||||
/// Snapshot of game state used for undo.
|
||||
|
||||
@@ -26,6 +26,13 @@ tokio = { workspace = true }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring-core = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = { workspace = true }
|
||||
# android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
|
||||
# process-wide JavaVM handle for JNI. Must be listed here so the
|
||||
# symbol resolves when cross-compiling for Android targets.
|
||||
bevy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
solitaire_sync = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/// Android Keystore token storage via JNI.
|
||||
///
|
||||
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
|
||||
/// device-bound key from the Android Keystore, and written atomically to
|
||||
/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
///
|
||||
/// The Keystore key survives app restarts but is destroyed on uninstall (or if
|
||||
/// the user changes biometric/lock credentials, in which case decryption fails
|
||||
/// and we surface `TokenError::KeychainUnavailable` so the caller knows to
|
||||
/// prompt re-login — identical semantics to a Linux box without Secret Service).
|
||||
///
|
||||
/// Only compiled and linked on `target_os = "android"`.
|
||||
use jni::{
|
||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||
JNIEnv, JavaVM,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::auth_tokens::TokenError;
|
||||
|
||||
const KEY_ALIAS: &str = "solitaire_quest_token_key";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TokenBlob {
|
||||
username: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JVM helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||
where
|
||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||
{
|
||||
let app = bevy::android::ANDROID_APP
|
||||
.get()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?;
|
||||
|
||||
// SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime.
|
||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
||||
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
||||
|
||||
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keystore key management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load the existing AES key from the Android Keystore, or generate one if it
|
||||
/// doesn't exist yet. Returns a local reference valid for the current JNI frame.
|
||||
fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result<JObject<'local>> {
|
||||
// KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); ks.load(null);
|
||||
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let ks = env
|
||||
.call_static_method(
|
||||
&ks_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||
&[ks_type.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
let null = JObject::null();
|
||||
env.call_method(
|
||||
&ks,
|
||||
"load",
|
||||
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||
&[JValue::Object(&null)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
// Key key = ks.getKey(ALIAS, null) — char[] password is null for hardware keys
|
||||
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
let null2 = JObject::null();
|
||||
let key = env
|
||||
.call_method(
|
||||
&ks,
|
||||
"getKey",
|
||||
"(Ljava/lang/String;[C)Ljava/security/Key;",
|
||||
&[alias.borrow(), JValue::Object(&null2)],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
if !env.is_same_object(&key, JObject::null())? {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// No key yet — generate AES-256 with GCM block mode.
|
||||
let builder_class =
|
||||
env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
|
||||
let alias2 = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
// PURPOSE_ENCRYPT | PURPOSE_DECRYPT = 1 | 2 = 3
|
||||
let purpose = JValueOwned::Int(3);
|
||||
let builder = env.new_object(
|
||||
&builder_class,
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[alias2.borrow(), purpose.borrow()],
|
||||
)?;
|
||||
|
||||
let str_class = env.find_class("java/lang/String")?;
|
||||
|
||||
// builder.setBlockModes(["GCM"])
|
||||
let gcm_str = env.new_string("GCM")?;
|
||||
let block_modes: JObjectArray = env.new_object_array(1, &str_class, &gcm_str)?;
|
||||
let block_modes_val = JValueOwned::Object(block_modes.into());
|
||||
let builder = env
|
||||
.call_method(
|
||||
&builder,
|
||||
"setBlockModes",
|
||||
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||
&[block_modes_val.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// builder.setEncryptionPaddings(["NoPadding"])
|
||||
let nopad_str = env.new_string("NoPadding")?;
|
||||
let enc_pads: JObjectArray = env.new_object_array(1, &str_class, &nopad_str)?;
|
||||
let enc_pads_val = JValueOwned::Object(enc_pads.into());
|
||||
let builder = env
|
||||
.call_method(
|
||||
&builder,
|
||||
"setEncryptionPaddings",
|
||||
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||
&[enc_pads_val.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// KeyGenParameterSpec spec = builder.build()
|
||||
let spec = env
|
||||
.call_method(
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroid/security/keystore/KeyGenParameterSpec;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// KeyGenerator kg = KeyGenerator.getInstance("AES", "AndroidKeyStore")
|
||||
let kg_class = env.find_class("javax/crypto/KeyGenerator")?;
|
||||
let aes = JValueOwned::from(env.new_string("AES")?);
|
||||
let ks_name = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let kg = env
|
||||
.call_static_method(
|
||||
&kg_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;",
|
||||
&[aes.borrow(), ks_name.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// kg.init(spec); return kg.generateKey()
|
||||
let spec_val = JValueOwned::Object(spec);
|
||||
env.call_method(
|
||||
&kg,
|
||||
"init",
|
||||
"(Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||
&[spec_val.borrow()],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
env.call_method(&kg, "generateKey", "()Ljavax/crypto/SecretKey;", &[])?
|
||||
.l()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AES-GCM encrypt / decrypt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
fn encrypt_gcm(
|
||||
env: &mut JNIEnv<'_>,
|
||||
key: &JObject<'_>,
|
||||
plaintext: &[u8],
|
||||
) -> jni::errors::Result<Vec<u8>> {
|
||||
let cipher_class = env.find_class("javax/crypto/Cipher")?;
|
||||
let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?);
|
||||
let cipher = env
|
||||
.call_static_method(
|
||||
&cipher_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||
&[transform.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// cipher.init(Cipher.ENCRYPT_MODE=1, key)
|
||||
let mode = JValueOwned::Int(1);
|
||||
env.call_method(
|
||||
&cipher,
|
||||
"init",
|
||||
"(ILjava/security/Key;)V",
|
||||
&[mode.borrow(), JValue::Object(key)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
// IV is generated by Android's provider; read it back after init.
|
||||
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||
// SAFETY: the method signature guarantees a byte array return.
|
||||
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
||||
let iv = env.convert_byte_array(&iv_arr)?;
|
||||
|
||||
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||
let pt_val = JValueOwned::Object(pt_arr.into());
|
||||
let ct_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||
|
||||
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||
out.extend_from_slice(&iv);
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Expects `data` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
fn decrypt_gcm(
|
||||
env: &mut JNIEnv<'_>,
|
||||
key: &JObject<'_>,
|
||||
data: &[u8],
|
||||
) -> jni::errors::Result<Vec<u8>> {
|
||||
let (iv, ciphertext) = data.split_at(12);
|
||||
|
||||
let cipher_class = env.find_class("javax/crypto/Cipher")?;
|
||||
let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?);
|
||||
let cipher = env
|
||||
.call_static_method(
|
||||
&cipher_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||
&[transform.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
// GCMParameterSpec spec = new GCMParameterSpec(128, iv)
|
||||
let spec_class = env.find_class("javax/crypto/spec/GCMParameterSpec")?;
|
||||
let tag_len = JValueOwned::Int(128);
|
||||
let iv_arr = env.byte_array_from_slice(iv)?;
|
||||
let iv_val = JValueOwned::Object(iv_arr.into());
|
||||
let spec = env.new_object(
|
||||
&spec_class,
|
||||
"(I[B)V",
|
||||
&[tag_len.borrow(), iv_val.borrow()],
|
||||
)?;
|
||||
|
||||
// cipher.init(Cipher.DECRYPT_MODE=2, key, spec)
|
||||
let mode = JValueOwned::Int(2);
|
||||
let spec_val = JValueOwned::Object(spec);
|
||||
env.call_method(
|
||||
&cipher,
|
||||
"init",
|
||||
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||
&[mode.borrow(), JValue::Object(key), spec_val.borrow()],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
let ct_arr = env.byte_array_from_slice(ciphertext)?;
|
||||
let ct_val = JValueOwned::Object(ct_arr.into());
|
||||
let pt_jobj = env
|
||||
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||
.l()?;
|
||||
// SAFETY: doFinal([B) returns [B.
|
||||
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
||||
env.convert_byte_array(&pt_arr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn token_file_path() -> Option<PathBuf> {
|
||||
crate::platform::data_dir().map(|d| d.join("auth_tokens.bin"))
|
||||
}
|
||||
|
||||
fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
if !path.exists() {
|
||||
return Err(TokenError::NotFound(String::new()));
|
||||
}
|
||||
std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
|
||||
}
|
||||
|
||||
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, data)
|
||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||
}
|
||||
|
||||
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
|
||||
let data = read_file_bytes().map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()),
|
||||
other => other,
|
||||
})?;
|
||||
|
||||
if data.len() < 12 {
|
||||
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
|
||||
}
|
||||
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
|
||||
let blob: TokenBlob = serde_json::from_slice(&plaintext)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?;
|
||||
|
||||
if blob.username != username {
|
||||
return Err(TokenError::NotFound(username.to_string()));
|
||||
}
|
||||
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — mirrors auth_tokens desktop surface exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Encrypt and store `access_token` and `refresh_token` for `username`.
|
||||
///
|
||||
/// Overwrites any previously stored tokens.
|
||||
pub fn store_tokens(
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
let blob = TokenBlob {
|
||||
username: username.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.to_string(),
|
||||
};
|
||||
let plaintext = serde_json::to_vec(&blob)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
|
||||
|
||||
let encrypted = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
encrypt_gcm(env, &key, &plaintext)
|
||||
})?;
|
||||
|
||||
write_file_bytes(&encrypted)
|
||||
}
|
||||
|
||||
/// Return the stored access token for `username`.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
load_blob(username).map(|b| b.access_token)
|
||||
}
|
||||
|
||||
/// Return the stored refresh token for `username`.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
load_blob(username).map(|b| b.refresh_token)
|
||||
}
|
||||
|
||||
/// Delete stored tokens and remove the Keystore key for `username`.
|
||||
///
|
||||
/// Missing file or missing Keystore entry are silently ignored.
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
if let Some(path) = token_file_path() {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||
with_jvm(|env| {
|
||||
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let ks = env
|
||||
.call_static_method(
|
||||
&ks_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||
&[ks_type.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
let null = JObject::null();
|
||||
env.call_method(
|
||||
&ks,
|
||||
"load",
|
||||
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||
&[JValue::Object(&null)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
|
||||
.v()
|
||||
})
|
||||
}
|
||||
@@ -131,35 +131,29 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Android stub — same public API, always returns KeychainUnavailable.
|
||||
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
||||
// effect is "session login required every launch", same as a Linux
|
||||
// box without Secret Service.
|
||||
// Android — delegate to the JNI Keystore bridge in android_keystore.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn store_tokens(
|
||||
_username: &str,
|
||||
_access_token: &str,
|
||||
_refresh_token: &str,
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
crate::android_keystore::store_tokens(username, access_token, refresh_token)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
crate::android_keystore::load_access_token(username)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
crate::android_keystore::load_refresh_token(username)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
crate::android_keystore::delete_tokens(username)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
|
||||
0xDDDD_EEEE_FFFF_0000,
|
||||
0x0101_0101_0101_0101,
|
||||
0xA1B2_C3D4_E5F6_0718,
|
||||
// Generated by solitaire_assetgen::gen_seeds (start=0xCAFEBABE00000000, count=75, date=2026-05-09)
|
||||
0xCAFE_BABE_0000_0000,
|
||||
0xCAFE_BABE_0000_0002,
|
||||
0xCAFE_BABE_0000_0004,
|
||||
0xCAFE_BABE_0000_0008,
|
||||
0xCAFE_BABE_0000_000B,
|
||||
0xCAFE_BABE_0000_000D,
|
||||
0xCAFE_BABE_0000_000E,
|
||||
0xCAFE_BABE_0000_0010,
|
||||
0xCAFE_BABE_0000_0011,
|
||||
0xCAFE_BABE_0000_0014,
|
||||
0xCAFE_BABE_0000_0016,
|
||||
0xCAFE_BABE_0000_0019,
|
||||
0xCAFE_BABE_0000_001A,
|
||||
0xCAFE_BABE_0000_001F,
|
||||
0xCAFE_BABE_0000_0020,
|
||||
0xCAFE_BABE_0000_0021,
|
||||
0xCAFE_BABE_0000_0024,
|
||||
0xCAFE_BABE_0000_0025,
|
||||
0xCAFE_BABE_0000_0027,
|
||||
0xCAFE_BABE_0000_002B,
|
||||
0xCAFE_BABE_0000_002D,
|
||||
0xCAFE_BABE_0000_0030,
|
||||
0xCAFE_BABE_0000_0034,
|
||||
0xCAFE_BABE_0000_0036,
|
||||
0xCAFE_BABE_0000_003A,
|
||||
0xCAFE_BABE_0000_003B,
|
||||
0xCAFE_BABE_0000_003D,
|
||||
0xCAFE_BABE_0000_0042,
|
||||
0xCAFE_BABE_0000_0043,
|
||||
0xCAFE_BABE_0000_0044,
|
||||
0xCAFE_BABE_0000_004C,
|
||||
0xCAFE_BABE_0000_004D,
|
||||
0xCAFE_BABE_0000_004F,
|
||||
0xCAFE_BABE_0000_0050,
|
||||
0xCAFE_BABE_0000_0051,
|
||||
0xCAFE_BABE_0000_0054,
|
||||
0xCAFE_BABE_0000_0055,
|
||||
0xCAFE_BABE_0000_0056,
|
||||
0xCAFE_BABE_0000_0059,
|
||||
0xCAFE_BABE_0000_005B,
|
||||
0xCAFE_BABE_0000_005C,
|
||||
0xCAFE_BABE_0000_005E,
|
||||
0xCAFE_BABE_0000_0060,
|
||||
0xCAFE_BABE_0000_0062,
|
||||
0xCAFE_BABE_0000_0064,
|
||||
0xCAFE_BABE_0000_0067,
|
||||
0xCAFE_BABE_0000_0069,
|
||||
0xCAFE_BABE_0000_006A,
|
||||
0xCAFE_BABE_0000_006B,
|
||||
0xCAFE_BABE_0000_006C,
|
||||
0xCAFE_BABE_0000_006D,
|
||||
0xCAFE_BABE_0000_006E,
|
||||
0xCAFE_BABE_0000_006F,
|
||||
0xCAFE_BABE_0000_0072,
|
||||
0xCAFE_BABE_0000_0073,
|
||||
0xCAFE_BABE_0000_0074,
|
||||
0xCAFE_BABE_0000_0079,
|
||||
0xCAFE_BABE_0000_007A,
|
||||
0xCAFE_BABE_0000_007D,
|
||||
0xCAFE_BABE_0000_007E,
|
||||
0xCAFE_BABE_0000_007F,
|
||||
0xCAFE_BABE_0000_0082,
|
||||
0xCAFE_BABE_0000_0083,
|
||||
0xCAFE_BABE_0000_0084,
|
||||
0xCAFE_BABE_0000_0085,
|
||||
0xCAFE_BABE_0000_0089,
|
||||
0xCAFE_BABE_0000_008A,
|
||||
0xCAFE_BABE_0000_008D,
|
||||
0xCAFE_BABE_0000_008E,
|
||||
0xCAFE_BABE_0000_0090,
|
||||
0xCAFE_BABE_0000_0094,
|
||||
0xCAFE_BABE_0000_0095,
|
||||
0xCAFE_BABE_0000_0098,
|
||||
0xCAFE_BABE_0000_0099,
|
||||
0xCAFE_BABE_0000_009F,
|
||||
];
|
||||
|
||||
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Pre-verified seed catalogs for each [`DifficultyLevel`] tier.
|
||||
//!
|
||||
//! Each slice contains seeds that are provably winnable in Draw-One mode and
|
||||
//! that required a specific solver-budget range to solve — the **smallest**
|
||||
//! budget that returns `Winnable` determines the tier. See
|
||||
//! `solitaire_assetgen/src/bin/gen_difficulty_seeds.rs` for the generator.
|
||||
//!
|
||||
//! # Tiers and budget boundaries
|
||||
//!
|
||||
//! | Tier | move_budget | state_budget |
|
||||
//! |-------------|-------------|--------------|
|
||||
//! | Easy | 1 000 | 1 000 |
|
||||
//! | Medium | 5 000 | 5 000 |
|
||||
//! | Hard | 25 000 | 25 000 |
|
||||
//! | Expert | 100 000 | 100 000 |
|
||||
//! | Grandmaster | 200 000 | 200 000 |
|
||||
//!
|
||||
//! [`DifficultyLevel::Random`] has no catalog — the engine picks a system-time
|
||||
//! seed and skips verification.
|
||||
|
||||
use solitaire_core::game_state::DifficultyLevel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalogs (populated by gen_difficulty_seeds)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 40 seeds proven winnable within the Easy budget (≤ 1 000 states).
|
||||
pub const EASY_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Easy, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0001,
|
||||
0xD1FF_0000_0000_0002,
|
||||
0xD1FF_0000_0000_0007,
|
||||
0xD1FF_0000_0000_0008,
|
||||
0xD1FF_0000_0000_0009,
|
||||
0xD1FF_0000_0000_000E,
|
||||
0xD1FF_0000_0000_0013,
|
||||
0xD1FF_0000_0000_0015,
|
||||
0xD1FF_0000_0000_0018,
|
||||
0xD1FF_0000_0000_001D,
|
||||
0xD1FF_0000_0000_0021,
|
||||
0xD1FF_0000_0000_0022,
|
||||
0xD1FF_0000_0000_0026,
|
||||
0xD1FF_0000_0000_002C,
|
||||
0xD1FF_0000_0000_002E,
|
||||
0xD1FF_0000_0000_002F,
|
||||
0xD1FF_0000_0000_0035,
|
||||
0xD1FF_0000_0000_0036,
|
||||
0xD1FF_0000_0000_003C,
|
||||
0xD1FF_0000_0000_0045,
|
||||
0xD1FF_0000_0000_0046,
|
||||
0xD1FF_0000_0000_0048,
|
||||
0xD1FF_0000_0000_0049,
|
||||
0xD1FF_0000_0000_004D,
|
||||
0xD1FF_0000_0000_004F,
|
||||
0xD1FF_0000_0000_0050,
|
||||
0xD1FF_0000_0000_0051,
|
||||
0xD1FF_0000_0000_0053,
|
||||
0xD1FF_0000_0000_0054,
|
||||
0xD1FF_0000_0000_0057,
|
||||
0xD1FF_0000_0000_0058,
|
||||
0xD1FF_0000_0000_005A,
|
||||
0xD1FF_0000_0000_005B,
|
||||
0xD1FF_0000_0000_005C,
|
||||
0xD1FF_0000_0000_005D,
|
||||
0xD1FF_0000_0000_005F,
|
||||
0xD1FF_0000_0000_0061,
|
||||
0xD1FF_0000_0000_0062,
|
||||
0xD1FF_0000_0000_0063,
|
||||
0xD1FF_0000_0000_0069,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Medium budget (≤ 5 000 states).
|
||||
pub const MEDIUM_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Medium, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0000,
|
||||
0xD1FF_0000_0000_0012,
|
||||
0xD1FF_0000_0000_0016,
|
||||
0xD1FF_0000_0000_001B,
|
||||
0xD1FF_0000_0000_001C,
|
||||
0xD1FF_0000_0000_0020,
|
||||
0xD1FF_0000_0000_002A,
|
||||
0xD1FF_0000_0000_0034,
|
||||
0xD1FF_0000_0000_003A,
|
||||
0xD1FF_0000_0000_0041,
|
||||
0xD1FF_0000_0000_0043,
|
||||
0xD1FF_0000_0000_0060,
|
||||
0xD1FF_0000_0000_006A,
|
||||
0xD1FF_0000_0000_006C,
|
||||
0xD1FF_0000_0000_006E,
|
||||
0xD1FF_0000_0000_006F,
|
||||
0xD1FF_0000_0000_0071,
|
||||
0xD1FF_0000_0000_0072,
|
||||
0xD1FF_0000_0000_0075,
|
||||
0xD1FF_0000_0000_0076,
|
||||
0xD1FF_0000_0000_007B,
|
||||
0xD1FF_0000_0000_007E,
|
||||
0xD1FF_0000_0000_0081,
|
||||
0xD1FF_0000_0000_0083,
|
||||
0xD1FF_0000_0000_0084,
|
||||
0xD1FF_0000_0000_0087,
|
||||
0xD1FF_0000_0000_0090,
|
||||
0xD1FF_0000_0000_0092,
|
||||
0xD1FF_0000_0000_0093,
|
||||
0xD1FF_0000_0000_0098,
|
||||
0xD1FF_0000_0000_0099,
|
||||
0xD1FF_0000_0000_009A,
|
||||
0xD1FF_0000_0000_009E,
|
||||
0xD1FF_0000_0000_00A5,
|
||||
0xD1FF_0000_0000_00A8,
|
||||
0xD1FF_0000_0000_00AA,
|
||||
0xD1FF_0000_0000_00AB,
|
||||
0xD1FF_0000_0000_00AE,
|
||||
0xD1FF_0000_0000_00AF,
|
||||
0xD1FF_0000_0000_00B0,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Hard budget (≤ 25 000 states).
|
||||
pub const HARD_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Hard, date=2026-05-09)
|
||||
0xD1FF_0000_0000_001F,
|
||||
0xD1FF_0000_0000_0024,
|
||||
0xD1FF_0000_0000_0025,
|
||||
0xD1FF_0000_0000_0031,
|
||||
0xD1FF_0000_0000_0032,
|
||||
0xD1FF_0000_0000_003E,
|
||||
0xD1FF_0000_0000_004A,
|
||||
0xD1FF_0000_0000_006D,
|
||||
0xD1FF_0000_0000_0079,
|
||||
0xD1FF_0000_0000_007C,
|
||||
0xD1FF_0000_0000_0080,
|
||||
0xD1FF_0000_0000_008A,
|
||||
0xD1FF_0000_0000_0097,
|
||||
0xD1FF_0000_0000_00B1,
|
||||
0xD1FF_0000_0000_00B2,
|
||||
0xD1FF_0000_0000_00B3,
|
||||
0xD1FF_0000_0000_00B5,
|
||||
0xD1FF_0000_0000_00B7,
|
||||
0xD1FF_0000_0000_00B8,
|
||||
0xD1FF_0000_0000_00B9,
|
||||
0xD1FF_0000_0000_00BA,
|
||||
0xD1FF_0000_0000_00BB,
|
||||
0xD1FF_0000_0000_00BC,
|
||||
0xD1FF_0000_0000_00BD,
|
||||
0xD1FF_0000_0000_00C2,
|
||||
0xD1FF_0000_0000_00C3,
|
||||
0xD1FF_0000_0000_00C5,
|
||||
0xD1FF_0000_0000_00CC,
|
||||
0xD1FF_0000_0000_00CE,
|
||||
0xD1FF_0000_0000_00D1,
|
||||
0xD1FF_0000_0000_00D2,
|
||||
0xD1FF_0000_0000_00D6,
|
||||
0xD1FF_0000_0000_00D7,
|
||||
0xD1FF_0000_0000_00DC,
|
||||
0xD1FF_0000_0000_00DF,
|
||||
0xD1FF_0000_0000_00E0,
|
||||
0xD1FF_0000_0000_00E1,
|
||||
0xD1FF_0000_0000_00E4,
|
||||
0xD1FF_0000_0000_00E6,
|
||||
0xD1FF_0000_0000_00E7,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable within the Expert budget (≤ 100 000 states).
|
||||
pub const EXPERT_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Expert, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0006,
|
||||
0xD1FF_0000_0000_000B,
|
||||
0xD1FF_0000_0000_0019,
|
||||
0xD1FF_0000_0000_0082,
|
||||
0xD1FF_0000_0000_00CB,
|
||||
0xD1FF_0000_0000_00D5,
|
||||
0xD1FF_0000_0000_00D8,
|
||||
0xD1FF_0000_0000_00E8,
|
||||
0xD1FF_0000_0000_00EA,
|
||||
0xD1FF_0000_0000_00EB,
|
||||
0xD1FF_0000_0000_00EC,
|
||||
0xD1FF_0000_0000_00ED,
|
||||
0xD1FF_0000_0000_00F2,
|
||||
0xD1FF_0000_0000_00F3,
|
||||
0xD1FF_0000_0000_00F4,
|
||||
0xD1FF_0000_0000_00FE,
|
||||
0xD1FF_0000_0000_00FF,
|
||||
0xD1FF_0000_0000_0102,
|
||||
0xD1FF_0000_0000_0103,
|
||||
0xD1FF_0000_0000_0104,
|
||||
0xD1FF_0000_0000_0105,
|
||||
0xD1FF_0000_0000_0106,
|
||||
0xD1FF_0000_0000_0109,
|
||||
0xD1FF_0000_0000_010B,
|
||||
0xD1FF_0000_0000_010C,
|
||||
0xD1FF_0000_0000_0110,
|
||||
0xD1FF_0000_0000_0113,
|
||||
0xD1FF_0000_0000_0114,
|
||||
0xD1FF_0000_0000_011B,
|
||||
0xD1FF_0000_0000_011C,
|
||||
0xD1FF_0000_0000_011E,
|
||||
0xD1FF_0000_0000_0120,
|
||||
0xD1FF_0000_0000_0121,
|
||||
0xD1FF_0000_0000_0122,
|
||||
0xD1FF_0000_0000_0123,
|
||||
0xD1FF_0000_0000_0124,
|
||||
0xD1FF_0000_0000_0126,
|
||||
0xD1FF_0000_0000_012B,
|
||||
0xD1FF_0000_0000_012C,
|
||||
0xD1FF_0000_0000_012E,
|
||||
];
|
||||
|
||||
/// 40 seeds proven winnable only within the Grandmaster budget (≤ 200 000 states).
|
||||
pub const GRANDMASTER_SEEDS: &[u64] = &[
|
||||
// Generated by solitaire_assetgen::gen_difficulty_seeds (tier=Grandmaster, date=2026-05-09)
|
||||
0xD1FF_0000_0000_0027,
|
||||
0xD1FF_0000_0000_00A0,
|
||||
0xD1FF_0000_0000_00C4,
|
||||
0xD1FF_0000_0000_00D4,
|
||||
0xD1FF_0000_0000_00DE,
|
||||
0xD1FF_0000_0000_00F9,
|
||||
0xD1FF_0000_0000_0107,
|
||||
0xD1FF_0000_0000_0108,
|
||||
0xD1FF_0000_0000_0130,
|
||||
0xD1FF_0000_0000_0132,
|
||||
0xD1FF_0000_0000_0133,
|
||||
0xD1FF_0000_0000_0134,
|
||||
0xD1FF_0000_0000_0135,
|
||||
0xD1FF_0000_0000_0137,
|
||||
0xD1FF_0000_0000_0139,
|
||||
0xD1FF_0000_0000_013A,
|
||||
0xD1FF_0000_0000_013D,
|
||||
0xD1FF_0000_0000_013F,
|
||||
0xD1FF_0000_0000_0140,
|
||||
0xD1FF_0000_0000_0141,
|
||||
0xD1FF_0000_0000_0142,
|
||||
0xD1FF_0000_0000_0143,
|
||||
0xD1FF_0000_0000_0145,
|
||||
0xD1FF_0000_0000_0146,
|
||||
0xD1FF_0000_0000_014A,
|
||||
0xD1FF_0000_0000_014B,
|
||||
0xD1FF_0000_0000_014C,
|
||||
0xD1FF_0000_0000_014D,
|
||||
0xD1FF_0000_0000_014F,
|
||||
0xD1FF_0000_0000_0150,
|
||||
0xD1FF_0000_0000_0151,
|
||||
0xD1FF_0000_0000_0152,
|
||||
0xD1FF_0000_0000_0153,
|
||||
0xD1FF_0000_0000_0157,
|
||||
0xD1FF_0000_0000_0158,
|
||||
0xD1FF_0000_0000_015B,
|
||||
0xD1FF_0000_0000_015C,
|
||||
0xD1FF_0000_0000_015E,
|
||||
0xD1FF_0000_0000_0162,
|
||||
0xD1FF_0000_0000_0164,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Type alias for the catalog lookup return: a static slice or `None` for `Random`.
|
||||
pub type DifficultySeeds = Option<&'static [u64]>;
|
||||
|
||||
/// Return the seed catalog for `level`, or `None` for `Random` (caller must
|
||||
/// use a system-time seed instead).
|
||||
pub fn seeds_for(level: DifficultyLevel) -> DifficultySeeds {
|
||||
match level {
|
||||
DifficultyLevel::Easy => Some(EASY_SEEDS),
|
||||
DifficultyLevel::Medium => Some(MEDIUM_SEEDS),
|
||||
DifficultyLevel::Hard => Some(HARD_SEEDS),
|
||||
DifficultyLevel::Expert => Some(EXPERT_SEEDS),
|
||||
DifficultyLevel::Grandmaster => Some(GRANDMASTER_SEEDS),
|
||||
DifficultyLevel::Random => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_difficulty_seeds_are_unique() {
|
||||
let all: Vec<u64> = [
|
||||
EASY_SEEDS,
|
||||
MEDIUM_SEEDS,
|
||||
HARD_SEEDS,
|
||||
EXPERT_SEEDS,
|
||||
GRANDMASTER_SEEDS,
|
||||
]
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().copied())
|
||||
.collect();
|
||||
|
||||
let mut sorted = all.clone();
|
||||
sorted.sort_unstable();
|
||||
let before = sorted.len();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_for_random_returns_none() {
|
||||
assert!(seeds_for(DifficultyLevel::Random).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_for_non_random_returns_some() {
|
||||
for level in [
|
||||
DifficultyLevel::Easy,
|
||||
DifficultyLevel::Medium,
|
||||
DifficultyLevel::Hard,
|
||||
DifficultyLevel::Expert,
|
||||
DifficultyLevel::Grandmaster,
|
||||
] {
|
||||
assert!(
|
||||
seeds_for(level).is_some(),
|
||||
"{level:?} should return Some catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,6 @@ pub trait SyncProvider: Send + Sync {
|
||||
fn backend_name(&self) -> &'static str;
|
||||
/// Returns true if the user is currently authenticated with this backend.
|
||||
fn is_authenticated(&self) -> bool;
|
||||
/// Mirror an achievement unlock to this backend (no-op for most backends).
|
||||
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
|
||||
Ok(())
|
||||
}
|
||||
/// Fetch the global leaderboard from this backend. Returns an empty list
|
||||
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
|
||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||
@@ -83,9 +79,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
fn is_authenticated(&self) -> bool {
|
||||
(**self).is_authenticated()
|
||||
}
|
||||
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
|
||||
(**self).mirror_achievement(id).await
|
||||
}
|
||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||
(**self).fetch_leaderboard().await
|
||||
}
|
||||
@@ -138,6 +131,9 @@ pub use weekly::{
|
||||
pub mod challenge;
|
||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
|
||||
pub mod difficulty_seeds;
|
||||
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
@@ -147,6 +143,9 @@ pub use settings::{
|
||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_keystore;
|
||||
|
||||
pub mod auth_tokens;
|
||||
pub use auth_tokens::{
|
||||
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||
|
||||
@@ -147,12 +147,38 @@ pub struct Replay {
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||
/// the win condition (i.e. completed the last foundation pile).
|
||||
///
|
||||
/// For replays recorded by the live engine this is always
|
||||
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||
/// the field is stored explicitly so the playback UI can read it
|
||||
/// directly without re-deriving "the last move was the win" each
|
||||
/// time, and to leave room for future recording semantics that
|
||||
/// might capture post-win state.
|
||||
///
|
||||
/// `None` for replays loaded from disk that pre-date this field.
|
||||
/// `#[serde(default)]` keeps older `latest_replay.json` /
|
||||
/// `replays.json` files loadable without bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
|
||||
/// field, not a schema-breaking change.
|
||||
///
|
||||
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
|
||||
/// (B-2 screen-takeover redesign) when present.
|
||||
#[serde(default)]
|
||||
pub win_move_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl Replay {
|
||||
/// Construct a fresh replay with the current schema version. The
|
||||
/// caller fills in the recorded fields; this is the canonical
|
||||
/// constructor used by the engine on win.
|
||||
///
|
||||
/// [`win_move_index`](Self::win_move_index) and
|
||||
/// [`share_url`](Self::share_url) default to `None` — the engine
|
||||
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
|
||||
/// recording site to set the former, and `sync_plugin` writes the
|
||||
/// latter directly when the upload task resolves.
|
||||
pub fn new(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
@@ -172,8 +198,24 @@ impl Replay {
|
||||
recorded_at,
|
||||
moves,
|
||||
share_url: None,
|
||||
win_move_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
|
||||
/// Returns `self` so the recording site can chain it onto
|
||||
/// [`Replay::new`]:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||
/// ```
|
||||
///
|
||||
/// `None` is a valid input — useful for tests that don't care about
|
||||
/// the WIN MOVE marker's scrub-bar position.
|
||||
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
|
||||
self.win_move_index = idx;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling history of the player's most recent winning replays.
|
||||
@@ -737,4 +779,71 @@ mod tests {
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// win_move_index — additive optional field for the WIN MOVE marker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn replay_new_defaults_win_move_index_to_none() {
|
||||
let r = sample_replay();
|
||||
assert_eq!(r.win_move_index, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_win_move_index_sets_value() {
|
||||
let r = sample_replay().with_win_move_index(Some(3));
|
||||
assert_eq!(r.win_move_index, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_win_move_index_accepts_none() {
|
||||
// Passing None through the builder is a valid no-op — useful for
|
||||
// tests / synthetic replays that don't care about the marker.
|
||||
let r = sample_replay().with_win_move_index(None);
|
||||
assert_eq!(r.win_move_index, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_with_win_move_index_round_trips_on_disk() {
|
||||
let path = tmp_path("win_move_index_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let original = sample_replay().with_win_move_index(Some(3));
|
||||
save_latest_replay_to(&path, &original).expect("save");
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, Some(3));
|
||||
assert_eq!(loaded, original);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Older replay files written before this field was added must still
|
||||
/// load — `#[serde(default)]` keeps `win_move_index` optional and
|
||||
/// defaults missing fields to `None`. This is the contract that lets
|
||||
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
|
||||
#[test]
|
||||
fn replay_without_win_move_index_loads_with_none() {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||
let v2_no_field = r#"{
|
||||
"schema_version": 2,
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": []
|
||||
}"#;
|
||||
fs::write(&path, v2_no_field).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
@@ -117,6 +117,24 @@ pub struct Settings {
|
||||
/// solely on colour.
|
||||
#[serde(default)]
|
||||
pub color_blind_mode: bool,
|
||||
/// When `true`, boost foreground text + suit-red glyphs to higher-
|
||||
/// luminance variants for better legibility on low-quality displays
|
||||
/// or for low-vision users. Per `design-system.md` §Accessibility:
|
||||
/// on-surface `#d0d0d0` → `#f5f5f5`, suit-red `#fb9fb1` → `#ff8aa0`,
|
||||
/// outline `#505050` → `#a0a0a0`. Older `settings.json` files
|
||||
/// written before this field existed deserialize cleanly to
|
||||
/// `false` thanks to `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub high_contrast_mode: bool,
|
||||
/// When `true`, suppresses non-essential motion: card-lift slide
|
||||
/// transitions become instant snaps, splash scanline / cursor pulse
|
||||
/// animations are disabled, and the warning-chip pulse holds at
|
||||
/// rest. Per `design-system.md` §Accessibility — the WCAG-required
|
||||
/// reduce-motion mode. Older `settings.json` files written before
|
||||
/// this field existed deserialize cleanly to `false` thanks to
|
||||
/// `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub reduce_motion_mode: bool,
|
||||
/// Window size and screen position to restore on next launch. `None`
|
||||
/// means "use platform defaults" — set on first run, then populated
|
||||
/// as the player resizes / moves the window. Older `settings.json`
|
||||
@@ -206,6 +224,13 @@ pub struct Settings {
|
||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||
#[serde(default = "default_replay_move_interval_secs")]
|
||||
pub replay_move_interval_secs: f32,
|
||||
/// Last difficulty tier the player selected. `None` means the player has
|
||||
/// never used the difficulty picker. When `Some`, the difficulty section in
|
||||
/// the home overlay opens pre-expanded and highlights this tier. Older
|
||||
/// `settings.json` files written before this field existed deserialize
|
||||
/// cleanly to `None` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub last_difficulty: Option<DifficultyLevel>,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -314,6 +339,8 @@ impl Default for Settings {
|
||||
selected_background: 0,
|
||||
first_run_complete: false,
|
||||
color_blind_mode: false,
|
||||
high_contrast_mode: false,
|
||||
reduce_motion_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: default_theme_id(),
|
||||
shown_achievement_onboarding: false,
|
||||
@@ -322,6 +349,7 @@ impl Default for Settings {
|
||||
winnable_deals_only: false,
|
||||
disable_smart_default_size: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
last_difficulty: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot {
|
||||
// Time Attack uses its own session-level scoring; a per-game best
|
||||
// wouldn't compose with the other modes' single-game numbers.
|
||||
GameMode::TimeAttack => {}
|
||||
// Difficulty games pool into the Classic best-score/time buckets per
|
||||
// the user's stats preference.
|
||||
GameMode::Difficulty(_) => {
|
||||
self.classic_best_score = self.classic_best_score.max(score_u32);
|
||||
self.classic_fastest_win_seconds =
|
||||
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
|
||||
}
|
||||
}
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
@@ -83,18 +83,96 @@ impl SolitaireServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with a username + password and return `(access_token, refresh_token)`.
|
||||
///
|
||||
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||
/// The client's `username` field is used as the credential — the caller must
|
||||
/// construct the client with the correct username before calling this.
|
||||
pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/login", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"username": self.username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
Self::extract_auth_tokens(resp).await
|
||||
}
|
||||
|
||||
/// Register a new account with a username + password and return `(access_token, refresh_token)`.
|
||||
///
|
||||
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||
pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/register", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"username": self.username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
Self::extract_auth_tokens(resp).await
|
||||
}
|
||||
|
||||
/// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response.
|
||||
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let msg = body["error"]
|
||||
.as_str()
|
||||
.or_else(|| body["message"].as_str())
|
||||
.unwrap_or("authentication failed");
|
||||
return Err(if status == reqwest::StatusCode::CONFLICT {
|
||||
SyncError::Auth("username already taken".into())
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
SyncError::Auth("invalid credentials".into())
|
||||
} else if status == reqwest::StatusCode::BAD_REQUEST {
|
||||
SyncError::Auth(msg.to_string())
|
||||
} else {
|
||||
SyncError::Network(format!("server returned {status}"))
|
||||
});
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
let access = body["access_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing access_token".into()))?
|
||||
.to_string();
|
||||
let refresh = body["refresh_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))?
|
||||
.to_string();
|
||||
Ok((access, refresh))
|
||||
}
|
||||
|
||||
/// Attempt to refresh the access token using the stored refresh token.
|
||||
///
|
||||
/// On success the new access token is persisted to the OS keychain,
|
||||
/// replacing the previous one. The refresh token itself is unchanged.
|
||||
/// The server rotates refresh tokens on each call: the response includes a
|
||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||
/// to the OS keychain on success.
|
||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||
let refresh = load_refresh_token(&self.username)
|
||||
let old_refresh = load_refresh_token(&self.username)
|
||||
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/auth/refresh", self.base_url))
|
||||
.json(&serde_json::json!({ "refresh_token": refresh }))
|
||||
.json(&serde_json::json!({ "refresh_token": old_refresh }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
@@ -112,9 +190,11 @@ impl SolitaireServerClient {
|
||||
.as_str()
|
||||
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
||||
|
||||
// store_tokens replaces both access and refresh; we keep the old
|
||||
// refresh token unchanged so its 30-day TTL is preserved.
|
||||
store_tokens(&self.username, new_access, &refresh)
|
||||
// Server rotates refresh tokens — store the new one.
|
||||
// Fall back to the old token if the field is absent (pre-rotation server).
|
||||
let new_refresh = body["refresh_token"].as_str().unwrap_or(&old_refresh);
|
||||
|
||||
store_tokens(&self.username, new_access, new_refresh)
|
||||
.map_err(|e| SyncError::Auth(e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -418,3 +418,56 @@ async fn pull_after_account_deletion_returns_default_or_error() {
|
||||
|
||||
let _ = delete_tokens(username);
|
||||
}
|
||||
|
||||
/// **Push retry on 401.**
|
||||
///
|
||||
/// Mirrors `jwt_refresh_on_401_succeeds` but for the `push()` path.
|
||||
/// We install an expired access token so the first push attempt returns 401,
|
||||
/// the client refreshes, and the retry push succeeds.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn push_retries_after_401_on_expired_access_token() {
|
||||
ensure_mock_keyring();
|
||||
|
||||
let base = spawn_test_server().await;
|
||||
let username = "rt_push_expiring";
|
||||
|
||||
let (_real_access, real_refresh) =
|
||||
register_user_raw(&base, username, "pushexpirepass1!").await;
|
||||
let user_id = decode_sub(&_real_access);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
kind: String,
|
||||
}
|
||||
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||
let expired_access = encode(
|
||||
&Header::default(),
|
||||
&Claims {
|
||||
sub: user_id.clone(),
|
||||
exp,
|
||||
kind: "access".into(),
|
||||
},
|
||||
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||
)
|
||||
.expect("failed to encode expired access token");
|
||||
|
||||
store_tokens(username, &expired_access, &real_refresh)
|
||||
.expect("storing tokens in mock keyring must succeed");
|
||||
|
||||
let client = SolitaireServerClient::new(&base, username);
|
||||
let payload = make_payload(&user_id, 17);
|
||||
|
||||
// Push: server returns 401, client refreshes, retries, succeeds.
|
||||
let push_resp = client
|
||||
.push(&payload)
|
||||
.await
|
||||
.expect("push must succeed after the client transparently refreshes the access token");
|
||||
assert_eq!(
|
||||
push_resp.merged.stats.games_played, 17,
|
||||
"merged games_played must reflect what was pushed after auto-refresh"
|
||||
);
|
||||
|
||||
let _ = delete_tokens(username);
|
||||
}
|
||||
|
||||