Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 04f9bf9be3 | |||
| a292a7ead0 | |||
| d109c32b75 | |||
| dd101b3d54 | |||
| af414b6aed | |||
| ae84dc1504 | |||
| 8719f77ec2 | |||
| a14200ac2f | |||
| e8bf9d79da | |||
| 48b28d29f8 | |||
| babe5cc9c8 | |||
| 3a4bb63a6f | |||
| 56233687b0 | |||
| 73ac67d76b | |||
| a27cf5a020 | |||
| 29136d815d | |||
| ef54cdeb65 | |||
| e080b49914 | |||
| 54005d5494 | |||
| 44f5972edd | |||
| 13ae16051d | |||
| a65e5b8c7b | |||
| 6204db8bb1 | |||
| c84d9f445c | |||
| cacb19c03f | |||
| 39b84965b6 |
@@ -0,0 +1,168 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Triggered by pushing a version tag, e.g. `git tag v0.22.0 && git push origin v0.22.0`.
|
||||||
|
# Builds a Linux x86_64 tarball and a signed Android APK, then publishes
|
||||||
|
# both as assets on a GitHub Release. Obtainium can track this repo's
|
||||||
|
# releases and download the APK automatically.
|
||||||
|
#
|
||||||
|
# Required repository secrets (Settings → Secrets and variables → Actions):
|
||||||
|
# ANDROID_KEYSTORE_BASE64 base64-encoded .jks file (see README for gen command)
|
||||||
|
# ANDROID_KEYSTORE_PASSWORD password used with -storepass when creating the keystore
|
||||||
|
# ANDROID_KEY_ALIAS alias used with -alias when creating the keystore
|
||||||
|
# ANDROID_KEY_PASSWORD password used with -keypass when creating the keystore
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # gh release create needs write access
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job 1: Linux x86_64 binary + assets tarball
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
jobs:
|
||||||
|
build-linux:
|
||||||
|
name: Build · Linux x86_64
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install system deps
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Cache cargo registry + build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: linux-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: linux-release-
|
||||||
|
|
||||||
|
- name: Build release binary
|
||||||
|
run: cargo build --release -p solitaire_app
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
run: |
|
||||||
|
mkdir solitaire-quest
|
||||||
|
cp target/release/solitaire_app solitaire-quest/
|
||||||
|
cp -r assets solitaire-quest/
|
||||||
|
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
path: solitaire-quest-linux-x86_64.tar.gz
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job 2: Android APK (multi-arch) — release-built and signed via cargo-apk
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-android:
|
||||||
|
name: Build · Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable + Android targets
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Expose NDK root to cargo-apk
|
||||||
|
# ANDROID_NDK_LATEST_HOME is set by the GitHub-hosted runner.
|
||||||
|
# cargo-apk reads ANDROID_NDK_ROOT; write it to GITHUB_ENV so
|
||||||
|
# all subsequent steps in this job inherit it.
|
||||||
|
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache cargo registry + cargo-apk binary + build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
~/.cargo/bin
|
||||||
|
target
|
||||||
|
key: android-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: android-release-
|
||||||
|
|
||||||
|
- name: Install cargo-apk
|
||||||
|
# --locked: use the dependency versions cargo-apk was tested with.
|
||||||
|
# cargo install is a no-op when the cached binary is already current.
|
||||||
|
run: cargo install --locked cargo-apk
|
||||||
|
|
||||||
|
- name: Inject release signing config
|
||||||
|
# cargo-apk --release requires [package.metadata.android.signing.release]
|
||||||
|
# in solitaire_app/Cargo.toml. Appended at CI time so secrets never
|
||||||
|
# live in the repo. printf keeps every line inside the YAML run block,
|
||||||
|
# avoiding the YAML parse error a heredoc with column-0 content causes.
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
|
||||||
|
{
|
||||||
|
printf '\n[package.metadata.android.signing.release]\n'
|
||||||
|
printf 'path = "%s"\n' "${GITHUB_WORKSPACE}/release.keystore"
|
||||||
|
printf 'keystore_password = "%s"\n' "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
printf 'key_alias = "%s"\n' "$ANDROID_KEY_ALIAS"
|
||||||
|
printf 'key_password = "%s"\n' "$ANDROID_KEY_PASSWORD"
|
||||||
|
} >> solitaire_app/Cargo.toml
|
||||||
|
|
||||||
|
- name: Build and sign APK (release profile)
|
||||||
|
run: cargo apk build -p solitaire_app --release
|
||||||
|
|
||||||
|
- name: Stage APK for upload
|
||||||
|
run: |
|
||||||
|
cp target/release/apk/solitaire-quest.apk \
|
||||||
|
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||||
|
rm release.keystore
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
|
path: solitaire-quest-${{ github.ref_name }}.apk
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job 3: Create the GitHub Release once both builds succeed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
release:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-linux, build-android]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "${{ github.ref_name }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Solitaire Quest ${{ github.ref_name }}" \
|
||||||
|
--generate-notes \
|
||||||
|
"solitaire-quest-linux-x86_64.tar.gz" \
|
||||||
|
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||||
@@ -7,3 +7,6 @@
|
|||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# IDE project files
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -6957,6 +6957,8 @@ dependencies = [
|
|||||||
"keyring",
|
"keyring",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
|
"tiny-skia 0.12.0",
|
||||||
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6965,6 +6967,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
|
"solitaire_core",
|
||||||
|
"solitaire_data",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6984,6 +6988,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -7007,6 +7012,7 @@ dependencies = [
|
|||||||
"bevy",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"kira",
|
"kira",
|
||||||
"resvg",
|
"resvg",
|
||||||
"ron",
|
"ron",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ keyring = "4"
|
|||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
|
|||||||
@@ -1,171 +1,189 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-07 — v0.20.0 cut. Two through-lines closed
|
**Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
|
||||||
in this cycle: a full **Terminal visual-identity port** (token system
|
nine post-cut commits on master. Push pending.
|
||||||
in `ui_theme` plus downstream chrome migrations across modal scaffold,
|
|
||||||
gameplay-feedback, toasts, and the table / card / splash surfaces)
|
v0.21.8 closes the last optional polish items in the B-2
|
||||||
and the **Android persistence shim** that closes the
|
replay screen-takeover arc: **notch-label centering** (middle
|
||||||
`dirs::data_dir() = None` pitfall flagged in CLAUDE.md §10. The
|
three scrub-bar labels now centred on their notch ticks via the
|
||||||
Android *build* target landed earlier in the cycle (`fb8b2ac`); this
|
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
|
||||||
session paid down the persistence half so a real APK can survive a
|
MOVE HC legibility** (lime stays lime under HC mode via the
|
||||||
cold start. The 24 Stitch-rendered mockups are now in-tree under
|
extended `HighContrastBackground::with_hc` constructor and a
|
||||||
`docs/ui-mockups/`; future plugin work diffs against the matching
|
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
|
||||||
mockup before touching pixels.
|
overlay arc is now fully closed with no known open items.
|
||||||
|
|
||||||
|
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
|
||||||
|
file from here on focuses on what's *open* post-cut and how to
|
||||||
|
resume.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD on origin:** the v0.20.0 docs commit (the one that lands
|
- **HEAD locally:** `f281425` (Android Keystore JNI).
|
||||||
this file + CHANGELOG cut). Tag not yet pushed; cut whenever
|
Docs ride on top; push pending.
|
||||||
feels right.
|
- **HEAD on origin:** `395a322` (double-tap commit — last pushed).
|
||||||
- **Working tree:** clean apart from the still-untracked `artwork/`
|
- **Working tree:** clean (docs uncommitted). No WIP outstanding.
|
||||||
directory (intentional — the card PNGs there are mid-flight for
|
- **`artwork/` directory:** still untracked. Intentional.
|
||||||
the Terminal aesthetic and committing now would freeze a
|
|
||||||
transitional state).
|
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
clean.
|
clean.
|
||||||
- **Tests:** **1176 passing / 0 failing** across the workspace.
|
- **Tests:** **1292 passing / 0 failing** across the workspace.
|
||||||
Six new tests this cycle: four `ui_theme` invariant guards
|
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
|
||||||
(type / spacing / z-index scales + `scaled_duration`), one
|
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
|
||||||
toast-variant-border-mapping pair, and four palette-tracking
|
x86_64). All desktop-only systems (handle_fullscreen) now gated.
|
||||||
guards on `MARKER_VALID` / `HINT_PILE_HIGHLIGHT_COLOUR` /
|
See Phase Android punch list for remaining work.
|
||||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR` / toast-border distinctness. No
|
|
||||||
known flakes.
|
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.19.0`. v0.20.0 not yet
|
|
||||||
tagged.
|
|
||||||
|
|
||||||
## What shipped in v0.20.0
|
## Since the v0.21.8 cut
|
||||||
|
|
||||||
### Terminal visual-identity port
|
Seven commits since the v0.21.8 tag:
|
||||||
|
- `a449f60` — Stats Prev/Next selector spawn site
|
||||||
|
- `202a64d` — Android launch fixes (android_main, resize_constraints,
|
||||||
|
apply_smart_default_window_size) — **closes APK launch verification**
|
||||||
|
- `16242e6` — Ignore .idea/ IDE files
|
||||||
|
- `395a322` — double-tap auto-move for touch input
|
||||||
|
- `0cb1587` — Play-by-Seed dialog + HomeMode card
|
||||||
|
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
|
||||||
|
- `45436d0` — gate handle_fullscreen to non-Android
|
||||||
|
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
|
||||||
|
- `f281425` — Android Keystore AES-GCM token storage via JNI
|
||||||
|
|
||||||
Top-down stack — every commit downstream of the token system
|
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
|
||||||
reads from it, so swapping the palette is now a one-file edit:
|
|
||||||
|
|
||||||
- **`ui_theme` token system** (`0d477ac`). base16-eighties
|
Open next-step menu:
|
||||||
palette, 5-rung type scale, 7-rung 4-multiple spacing scale,
|
1. **Phase 8 (sync)** — the biggest open arc. Local storage
|
||||||
3-step radius, 14-rung z-index hierarchy, full motion budget,
|
scaffolding, self-hosted Axum server, GPGS stub.
|
||||||
4 invariant-pinning unit tests. Card-shadow alphas pinned to 0
|
2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
|
||||||
(Terminal achieves depth via 1px borders + tonal layering).
|
GPGS. Launch verification and double-tap both closed; these
|
||||||
- **Modal scaffold already on tokens** — `ui_modal` was ported
|
are the remaining Phase Android items.
|
||||||
in the same commit's wake; three stale "loud yellow" /
|
3. **Move Log auto-scroll** — only relevant if the panel
|
||||||
"magenta secondary" doc comments fixed.
|
row count grows beyond the current 5-row fixed window.
|
||||||
- **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.
|
|
||||||
- **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
|
## Open punch list
|
||||||
|
|
||||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||||
|
|
||||||
- **APK launch verification on AVD / device.** `adb install` then
|
- *APK launch verification — closed 2026-05-08 by `202a64d`.*
|
||||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
Three fixes shipped: `android_main` export (missing NativeActivity
|
||||||
The build works and persistence is wired, but no end-to-end
|
entry point), `resize_constraints` gated to non-Android (max=0
|
||||||
device run has been logged. Shakes out runtime bugs the build +
|
panic), `apply_smart_default_window_size` gated to non-Android
|
||||||
unit tests can't catch.
|
(clamp panic on zero-dimension window event). Verified booting on
|
||||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
|
||||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
runtime without crash. B0004 ECS hierarchy warnings remain
|
||||||
Android backend; small custom JNI call.
|
(non-fatal; entity parent/child component mismatch); investigate
|
||||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
if they surface gameplay bugs.
|
||||||
to a stub returning `KeychainUnavailable`; replace with Android
|
- *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
|
||||||
Keystore via JNI when sync auth ships on mobile.
|
`handle_double_tap` fires `MoveRequestEvent` on two rapid
|
||||||
- **Google Play Games (gpgs) integration.** Listed as a
|
`TouchPhase::Ended` events within 0.5 s. Prefers foundation;
|
||||||
Phase-Android target since Phase 1; now unblocked by the build
|
falls back to tableau stack move. Fires `MoveRejectedEvent` when
|
||||||
target.
|
no legal destination exists. System runs before `touch_end_drag`
|
||||||
|
in the chain so drag state is readable.
|
||||||
|
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
|
||||||
|
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
|
||||||
|
imports are `#[cfg(not(target_os = "android"))]`-gated. The
|
||||||
|
`add_systems` call is a separate statement (not mid-chain).
|
||||||
|
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
|
||||||
|
`android_clipboard::set_text(url)` calls `ClipboardManager` via
|
||||||
|
JNI. Stats share-link button now writes to the clipboard with a
|
||||||
|
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
|
||||||
|
error. Requires AVD functional test (see verification steps in
|
||||||
|
the approved plan).
|
||||||
|
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
|
||||||
|
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
|
||||||
|
tokens serialised to JSON and stored atomically at
|
||||||
|
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
|
||||||
|
`auth_tokens.rs` Android stubs now delegate to it. Key
|
||||||
|
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
|
||||||
|
Requires AVD functional test before Phase 8 sync goes live on
|
||||||
|
Android.
|
||||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
||||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||||
Either upstream a cargo-apk fix or document `--lib` as
|
Either upstream a cargo-apk fix or document `--lib` as
|
||||||
canonical in the runbook.
|
canonical in the runbook.
|
||||||
|
|
||||||
### Visual-identity follow-ups (opened by v0.20.0's port)
|
### Visual-identity follow-ups (post-v0.21.0)
|
||||||
|
|
||||||
- **Card-face / suit / card-back artwork regeneration.** The
|
The visual-identity arc is effectively complete: token system,
|
||||||
Terminal spec calls for dark `#1a1a1a` cards with light suit
|
chrome migration, splash boot screen, replay-overlay banner,
|
||||||
pips (pink for hearts/diamonds, foreground gray for spades/
|
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
||||||
clubs); the runtime path still renders the legacy white-card
|
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||||
PNG artwork. The fallback constants in `card_plugin`
|
|
||||||
(`CARD_FACE_COLOUR`, `RED_SUIT_COLOUR`, `BLACK_SUIT_COLOUR`,
|
- *Replay-overlay screen-takeover redesign — closed 2026-05-08
|
||||||
`CARD_FACE_COLOUR_RED_CBM`, `card_back_colour` palette) are
|
across 13 commits (v0.21.4–v0.21.7).* The full mockup
|
||||||
intentionally unmigrated and should swap in lockstep with the
|
(`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
|
||||||
artwork. Largest visible payoff remaining in the visual-
|
banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
|
||||||
identity arc.
|
MOVE scrub-bar marker (post-v0.21.3), playback controls /
|
||||||
- **Splash boot-loader richness.** The mockup
|
Space accelerator (post-v0.21.3), scrub notches + labels +
|
||||||
(`docs/ui-mockups/splash-mobile.html`) calls for a scanline
|
keybind footer + ESC / ← / → accelerators + HC border
|
||||||
overlay, ✓ lime check log lines, pulsing cursor, ROOT@SOLITAIRE
|
(v0.21.5), Move Log panel + HC scrub track + continuous
|
||||||
prompt, and a loading bar — none of which v0.20.0's
|
scrub (v0.21.6), and full-screen 50 % opacity dim layer
|
||||||
cursor-glyph-only port pulled in. Aesthetic feature, its own
|
(v0.21.7). Every major B-2 sub-piece is now closed. The
|
||||||
commit.
|
only remaining items are minor polish: notch-label centering
|
||||||
- **Replay-overlay redesign.** The mockup
|
and WIN MOVE HC contrast bump (see Open next-step menu).*
|
||||||
(`docs/ui-mockups/replay-overlay-mobile.html`) envisions a
|
- *Floating `MOVE N/M` chip above the focused card during
|
||||||
much richer surface (terminal `▌replay.tsx` header, move log
|
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
||||||
scroll, MOVE 47/87 chip, WIN MOVE callout, status bar) versus
|
`Text2d` entity sibling to the banner overlay; uses the same
|
||||||
the current top banner. Aesthetic feature.
|
`LayoutResource` pile coordinates so it survives window
|
||||||
- **Toast Warning / Error variants.** The new `ToastVariant`
|
resizes without UI/camera math.
|
||||||
enum has slots for `Warning` (gold) and `Error` (pink) but no
|
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
|
||||||
in-engine event uses them yet (the four current toast events
|
Daily-challenge-expiry toast fires once per `daily.date` when
|
||||||
all map to Info or Celebration). Wire when a warning- or
|
within 30 min of UTC midnight reset and today is incomplete.
|
||||||
error-flavoured toast event materialises.
|
`ToastVariant` is now fully load-bearing (every variant has at
|
||||||
|
least one real driver). Future Warning drivers can either reuse
|
||||||
|
the generic `WarningToastEvent(String)` carrier or add their
|
||||||
|
own domain message + `animation_plugin` handler.
|
||||||
|
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
|
||||||
|
`MoveRejectedEvent` now fires a 2-second pink-bordered
|
||||||
|
"Invalid move" toast as the third leg of the
|
||||||
|
audio + visual + text rejection-feedback stool.
|
||||||
|
- *High-contrast accessibility mode — closed 2026-05-08 by
|
||||||
|
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
|
||||||
|
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
|
||||||
|
dynamic-paint rollout (`c153363`).* Card text rendering plus
|
||||||
|
8 static-border chrome surfaces (modal scaffold, tooltip,
|
||||||
|
onboarding key chips, help panel key chips, stats panel
|
||||||
|
cells, home Level/XP/Score row, home mode buttons, home
|
||||||
|
mode-hotkey chips, 4 settings panel surfaces) all boost
|
||||||
|
borders to `BORDER_SUBTLE_HC` under HC via the
|
||||||
|
`HighContrastBorder` marker. The previously-carved-out
|
||||||
|
dynamic-paint sites are now also covered: HUD action buttons
|
||||||
|
and modal buttons take the same marker (their paint cycles
|
||||||
|
only mutate `BackgroundColor`, so no race); the radial menu
|
||||||
|
rim folds HC into its per-frame spawn via
|
||||||
|
`radial_rim_outline` so the focused rim boosts to
|
||||||
|
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
|
||||||
|
hierarchy that naive marker substitution would invert).
|
||||||
|
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
|
||||||
|
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
|
||||||
|
card animations; `pulse_splash_cursor` skips the per-frame
|
||||||
|
pulse multiplier; `spawn_splash` skips the scanline overlay
|
||||||
|
entirely. Future scope: gate any future card-lift z-bump
|
||||||
|
animation, warning-chip pulse (when one materialises).
|
||||||
|
|
||||||
### Carried forward from v0.19.0
|
### Carried forward from v0.19.0
|
||||||
|
|
||||||
- **App icon round.** `Window::icon` not yet wired; no
|
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
|
||||||
`.icns` / `.ico` / Linux hicolor PNG hierarchy. The 11-size
|
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
|
||||||
icon export the v0.19 handoff referenced is *not* currently
|
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
|
||||||
in `artwork/` (current `artwork/` holds the reverted Rusty
|
hicolor + downstream `.icns`/`.ico` packaging needs. The
|
||||||
Pixel card PNGs and is intentionally untracked); icon-export
|
`.ico` and `.icns` bundle-format files themselves are *not*
|
||||||
needs to be re-run before this item can be picked up.
|
generated — both would need new crate deps (`ico` and
|
||||||
Half-day task once the PNGs are back in place. No cert
|
`icns` respectively) and only matter at app-bundle time
|
||||||
dependency.
|
(cargo-bundle / packaging), not at `cargo run`. Open if the
|
||||||
|
project later ships as a packaged macOS / Windows app.
|
||||||
|
|
||||||
### Other small candidates
|
### Other small candidates
|
||||||
|
|
||||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
- *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
|
||||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
`PlayBySeedPlugin` adds a numeric-input modal with async solver
|
||||||
site renders them today — the Shareable badge therefore lands
|
preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
|
||||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
`StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
|
||||||
the badge will need to follow.
|
seeds (`2062bd0`) expand `CHALLENGE_SEEDS` via the new
|
||||||
|
`solitaire_assetgen::gen_seeds` binary.
|
||||||
|
- *Prev/Next selector chips spawn site — closed 2026-05-08 by
|
||||||
|
`a449f60`.* `ReplayPrevButton` / `ReplayNextButton` /
|
||||||
|
`ReplaySelectorCaption` / `ReplaySelectorDetail` now spawn in
|
||||||
|
`spawn_stats_screen` as a compact chip row above the Watch
|
||||||
|
Replay action. The Shareable badge is in the detail line.
|
||||||
|
The click handler and repaint systems were already live since
|
||||||
|
v0.19.0; this was purely the missing spawn site.
|
||||||
- **Toast queue / immediate unification.** The two toast paths
|
- **Toast queue / immediate unification.** The two toast paths
|
||||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||||
for fire-and-forget) now share visual treatment but remain
|
for fire-and-forget) now share visual treatment but remain
|
||||||
@@ -175,8 +193,22 @@ reads from it, so swapping the palette is now a one-file edit:
|
|||||||
|
|
||||||
### Process notes
|
### 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
|
- **Token-port pattern.** v0.20.0's chrome-migration commits
|
||||||
set a reusable shape for "centralized design system applied
|
set a reusable shape for "centralised design system applied
|
||||||
across N plugins":
|
across N plugins":
|
||||||
1. Constants module (`ui_theme.rs`) is the source of truth.
|
1. Constants module (`ui_theme.rs`) is the source of truth.
|
||||||
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
||||||
@@ -192,55 +224,71 @@ reads from it, so swapping the palette is now a one-file edit:
|
|||||||
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
||||||
as literals with a comment naming the rationale; only UI
|
as literals with a comment naming the rationale; only UI
|
||||||
chrome routes through tokens.
|
chrome routes through tokens.
|
||||||
- **Audit before migrating wide.** Before touching any plugin,
|
- **`SplashFadable` scaffolding pattern** (introduced in
|
||||||
grep for the literal pattern (`Color::srgb\(|Color::srgba\(|
|
`cacb19c`). Any future overlay that needs to fade `N >> 3`
|
||||||
Color::WHITE|Color::BLACK`) and classify each hit as domain
|
elements together should follow the same shape: one tiny
|
||||||
vs. chrome. Most plugins after the modal scaffold port turned
|
marker carrying the full-alpha base colour, one global query
|
||||||
out to be 100 % token-correct already; the audit prevents
|
that lerps every marker's alpha each frame, no per-element
|
||||||
wasted churn.
|
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
|
||||||
|
query exclusion pattern that the old splash was hitting at
|
||||||
|
three siblings.
|
||||||
|
|
||||||
### Canonical remote
|
### Canonical remote
|
||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||||
Always push there.
|
Always push there. As of v0.21.0 origin matches local; the next
|
||||||
|
push happens when post-cut work accumulates and is ready to roll
|
||||||
|
into a v0.21.1 / v0.22.0 cut.
|
||||||
|
|
||||||
### Design direction (now Terminal — base16-eighties)
|
### Design direction (Terminal — base16-eighties)
|
||||||
|
|
||||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
||||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
||||||
16 px edge margins, 8 px card radius.
|
16 px edge margins, 8 px card radius.
|
||||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` / `#2a2a2a`
|
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
|
||||||
/ `#353535`), cyan primary CTA (`#6fc2ef`), lime success
|
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242` —
|
||||||
(`#acc267`), gold warning (`#ddb26f`), pink error / suit-red
|
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
|
||||||
(`#fb9fb1`), lavender celebration (`#e1a3ee`), teal info
|
success (`#acc267`), gold warning (`#ddb26f`), pink error /
|
||||||
(`#12cfc0`).
|
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
|
||||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`. Outlined
|
info (`#12cfc0`).
|
||||||
glyphs for diamonds & clubs are *always on*; the Settings
|
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
|
||||||
"color-blind mode" toggle only swaps red → cyan.
|
Outlined glyphs for diamonds & clubs are *always on*; the
|
||||||
|
Settings "color-blind mode" toggle swaps red → lime `#acc267`
|
||||||
(Was: Midnight Purple base + Balatro yellow primary + warm magenta.
|
(was red → cyan pre-v0.21.0; lime is the next-best non-red
|
||||||
Replaced this cycle.)
|
base16-eighties accent now that the primary itself is red).
|
||||||
|
- **Card glyphs render upright in both corners** — no 180°
|
||||||
|
inverted-corner-indicator rotation. Single-orientation
|
||||||
|
digital play doesn't benefit from the traditional flip-
|
||||||
|
readback convention. `design-system.md` § Game Cards
|
||||||
|
documents this deliberate deviation.
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||||
Branch: master. v0.20.0 just cut on 2026-05-07; CHANGELOG's new
|
Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
|
||||||
[Unreleased] section is empty pending the next cycle's threads.
|
replay-overlay polish). Seven post-cut commits are on master (see
|
||||||
|
"Since the v0.21.8 cut" above); push of the last four pending.
|
||||||
|
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
|
||||||
|
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
|
||||||
|
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
|
||||||
|
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
|
||||||
|
pending. See CHANGELOG.md § [0.21.9] for full detail.
|
||||||
|
|
||||||
State: HEAD on the v0.20.0 docs commit. Tag not pushed yet — last
|
State: HEAD locally — see `git rev-parse HEAD`. Workspace
|
||||||
pushed tag is v0.19.0. Working tree clean apart from the
|
tests: 1292 passing / 0 failing. Clippy clean.
|
||||||
intentionally-untracked `artwork/`.
|
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — this file
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CHANGELOG.md — [0.20.0] section is the most recent cut
|
2. CHANGELOG.md — [0.21.9] section has the pending-cut items
|
||||||
3. CLAUDE.md — unified-3.0 rule set
|
3. CLAUDE.md — unified-3.0 rule set
|
||||||
4. CLAUDE_SPEC.md — formal architecture spec
|
4. CLAUDE_SPEC.md — formal architecture spec
|
||||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
6. docs/ui-mockups/ — design system + 24-mockup library
|
6. docs/ui-mockups/ — design system + 24-mockup library +
|
||||||
(Terminal aesthetic — landed in fa7f98a)
|
desktop-adaptation.md (the rules-based
|
||||||
|
companion to the mockups; read this
|
||||||
|
before any plugin port)
|
||||||
7. docs/android/* — Android setup + build runbook
|
7. docs/android/* — Android setup + build runbook
|
||||||
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
— saved feedback / project context
|
— saved feedback / project context
|
||||||
@@ -248,26 +296,17 @@ READ FIRST (in order, before doing anything):
|
|||||||
fresh machine)
|
fresh machine)
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
A. Push v0.20.0 tag — `git tag v0.20.0 && git push --tags`. If
|
A. Android follow-ups — JNI ClipboardManager bridge (arboard
|
||||||
the player wants the cut formalised before any new work.
|
has no Android backend), Android Keystore (blocked on Phase 8).
|
||||||
B. APK launch verification — `adb install` + `adb logcat` on
|
Launch verification + double-tap are closed.
|
||||||
bevy_test AVD or an x86_64 device. Now that persistence is
|
B. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||||
wired (4b51e50), shake out remaining runtime bugs.
|
Axum server, `SolitaireServerClient` impl. The biggest open
|
||||||
C. Card-face artwork regeneration — generate Terminal-aesthetic
|
arc by scope; rolls up Android dependencies (Keystore,
|
||||||
card PNGs (dark face, light suit pips), then migrate
|
ClipboardManager).
|
||||||
CARD_FACE_COLOUR / RED_SUIT_COLOUR / BLACK_SUIT_COLOUR /
|
C. Play-by-Seed polish — the dialog is functional but has no
|
||||||
CARD_FACE_COLOUR_RED_CBM in lockstep. Largest visible
|
visual preview of the solver verdict in the UI yet; the
|
||||||
payoff remaining in the visual-identity arc.
|
HomeMode card is wired but the dialog spawn site and verdict
|
||||||
D. Splash boot-loader richness — port the scanline overlay,
|
display could use a second pass.
|
||||||
✓ check log, pulsing cursor, ROOT@SOLITAIRE prompt, and
|
|
||||||
loading bar from docs/ui-mockups/splash-mobile.html. Pure
|
|
||||||
polish; no behavioural change.
|
|
||||||
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. JNI ClipboardManager / Keystore bridge — replaces the
|
|
||||||
Android stubs for Stats clipboard share + sync auth.
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Use the system git config (already correct).
|
- Use the system git config (already correct).
|
||||||
@@ -277,6 +316,23 @@ WORKFLOW NOTES:
|
|||||||
- Every commit must pass build / clippy / test before pushing.
|
- Every commit must pass build / clippy / test before pushing.
|
||||||
- Push to GitHub (origin) — gh auth setup-git wired on
|
- Push to GitHub (origin) — gh auth setup-git wired on
|
||||||
primary dev box; verify on laptop before first push.
|
primary dev box; verify on laptop before first push.
|
||||||
|
- Token-port pattern: when migrating tokens, walk every
|
||||||
|
concrete artifact downstream of the token (PNG textures,
|
||||||
|
embedded SVGs, hardcoded literals, comment color names),
|
||||||
|
not just the token name. v0.21.0 surfaced three "the
|
||||||
|
migration walked past this" follow-ups that all matched
|
||||||
|
this shape — codified here so future similar work can
|
||||||
|
pattern-match instead of rediscovering.
|
||||||
|
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
|
||||||
|
visibility fix (`4d48cad`) implemented an invariant that
|
||||||
|
had been declared in a module doc comment but was never
|
||||||
|
enforced in code. When future work touches a module with
|
||||||
|
a "this does X" doc comment, verify the code actually does
|
||||||
|
X and add a test if not. Two layers, two checks.
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–F. Don't pick unilaterally.
|
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
||||||
|
Note: every remaining option is multi-session by nature (A is
|
||||||
|
gated on Android tooling; B and C are explicitly multi-session
|
||||||
|
arcs). A fresh session is a better fit for any of them than the
|
||||||
|
tail of a long working stretch.
|
||||||
```
|
```
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 196 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,251 @@
|
|||||||
|
# Card-face artwork migration plan
|
||||||
|
|
||||||
|
**Status:** planning artifact (no code changed by this document).
|
||||||
|
**Tracks:** the "Card-face / suit / card-back artwork regeneration"
|
||||||
|
item in `SESSION_HANDOFF.md` → "Visual-identity follow-ups"
|
||||||
|
(SESSION_HANDOFF Resume prompt option D).
|
||||||
|
**Companion to:** `docs/ui-mockups/design-system.md` (Game Cards
|
||||||
|
spec, lines 214–233) and `docs/ui-mockups/desktop-adaptation.md`
|
||||||
|
(rules-based companion to the mockups).
|
||||||
|
|
||||||
|
## Why this is a multi-session arc
|
||||||
|
|
||||||
|
Every post-v0.20.0 visual-identity port to date (modal scaffold,
|
||||||
|
toasts, table chrome, splash boot screen, replay overlay) was a
|
||||||
|
**single rendering path** — change tokens, change comments, ship.
|
||||||
|
Cards have **two** rendering paths that are visually identical
|
||||||
|
today and would visually disagree the moment one moves:
|
||||||
|
|
||||||
|
1. **PNG path (production).** `assets/cards/faces/<rank><suit>.png`
|
||||||
|
loaded into `CardImageSet.faces[suit][rank]` at startup; card
|
||||||
|
sprites blit the texture. 52 face PNGs + 5 back PNGs already
|
||||||
|
in `assets/`, all the legacy white-card aesthetic from the
|
||||||
|
pre-Terminal design system.
|
||||||
|
2. **Constant fallback (tests + asset-missing edge).** When
|
||||||
|
`CardImageSet` isn't a registered resource (the case under
|
||||||
|
`MinimalPlugins` test fixtures, and the bare-bones path the
|
||||||
|
first-frame of production hits before assets resolve), the
|
||||||
|
renderer falls back to solid-colour sprites driven by the
|
||||||
|
`card_plugin` constants:
|
||||||
|
- `CARD_FACE_COLOUR` — `(0.98, 0.98, 0.95)` cream-ish white.
|
||||||
|
- `RED_SUIT_COLOUR` — `(0.78, 0.12, 0.15)` warm red.
|
||||||
|
- `BLACK_SUIT_COLOUR` — `(0.08, 0.08, 0.08)` near-black.
|
||||||
|
- `CARD_FACE_COLOUR_RED_CBM` — `(0.85, 0.92, 1.0, 1.0)` light
|
||||||
|
blue (the legacy color-blind tint).
|
||||||
|
- `card_back_colour(idx)` — five legacy back themes.
|
||||||
|
|
||||||
|
A single-path migration leaves a known-broken state where tests
|
||||||
|
pass against Terminal constants while a human sees legacy artwork
|
||||||
|
on screen — the exact bisection-hostile drift the handoff's
|
||||||
|
"in lockstep" warning preempts.
|
||||||
|
|
||||||
|
## Target state — Terminal aesthetic
|
||||||
|
|
||||||
|
Per `design-system.md` § Game Cards (lines 214–233):
|
||||||
|
|
||||||
|
### Card face
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#1a1a1a` |
|
||||||
|
| Border | 1 px solid in **suit colour** (pink for ♥/♦, foreground gray for ♠/♣) |
|
||||||
|
| Corner radius | 8 px |
|
||||||
|
| Top-left | rank in JetBrains Mono **Bold 18 px** + small suit glyph (10 px) |
|
||||||
|
| Bottom-right | large suit glyph (32 px), rotated 180° |
|
||||||
|
| Glyph fill rule | ♥ ♠ filled; ♦ ♣ outlined (1.5 px stroke). Always on, not a toggle. |
|
||||||
|
|
||||||
|
### Suit colours (always-on glyph differentiation is the *primary*
|
||||||
|
distinguishing mechanism; colour is supplementary):
|
||||||
|
|
||||||
|
| Suit | Default | Color-blind mode |
|
||||||
|
|---|---|---|
|
||||||
|
| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) |
|
||||||
|
| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) |
|
||||||
|
| Spades | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) |
|
||||||
|
| Clubs | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) |
|
||||||
|
|
||||||
|
### Card back ("Terminal" theme)
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#151515` |
|
||||||
|
| Pattern | horizontal scanlines at 2 px pitch in `#1a1a1a` (1 px line, 1 px gap), full bleed |
|
||||||
|
| Border | 1 px solid `#353535` |
|
||||||
|
| Top-left badge | 12×16 px solid `#6fc2ef` block, 6 px from corner |
|
||||||
|
| Bottom-right monogram | `▌RS` in JetBrains Mono 12 px `#505050`, 6 px from corner |
|
||||||
|
| Corner radius | 8 px |
|
||||||
|
| Theme name / author | `"Terminal"` / `"Rusty Solitaire"` |
|
||||||
|
|
||||||
|
## Generation pipeline — programmatic SVG via the existing
|
||||||
|
`resvg` stack
|
||||||
|
|
||||||
|
### Why this path (vs. external tooling or direct `tiny_skia`)
|
||||||
|
|
||||||
|
The codebase already ships an SVG-to-PNG rasteriser at
|
||||||
|
`solitaire_engine/src/assets/svg_loader.rs`:
|
||||||
|
|
||||||
|
- Public `rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, _>`
|
||||||
|
- Backed by `usvg` (parser) + `resvg` (renderer) + `tiny_skia`
|
||||||
|
(CPU pixmap)
|
||||||
|
- Bundled font db includes JetBrains-style mono (FiraMono — same
|
||||||
|
face the splash uses; close enough to JetBrains Mono for
|
||||||
|
rasterisation purposes, and identical to what the Bevy UI
|
||||||
|
consumes in the rest of the app)
|
||||||
|
- `RenderAssetUsages::default()` is the call-site convention here
|
||||||
|
|
||||||
|
This means: **generating new card PNGs is one new file
|
||||||
|
(`solitaire_engine/examples/card_face_generator.rs`) calling an
|
||||||
|
existing public function.** No new dependencies, no asset-pipeline
|
||||||
|
changes, no build-script machinery. Anyone who runs the example
|
||||||
|
gets bit-identical artwork.
|
||||||
|
|
||||||
|
The two alternatives are weaker:
|
||||||
|
|
||||||
|
- **External tool (Inkscape / Figma / hand-design)** — produces
|
||||||
|
one-off PNGs that can't be re-generated reproducibly without
|
||||||
|
re-opening the source files in a specific tool. Iteration cost
|
||||||
|
is high; design tweaks (e.g. "make the suit glyph 2 px larger")
|
||||||
|
require a designer-in-the-loop.
|
||||||
|
- **Direct `tiny_skia` painting calls** — bypasses SVG entirely,
|
||||||
|
but loses the readability of "open the SVG to see exactly what
|
||||||
|
the card looks like." Also reinvents primitives (rounded
|
||||||
|
rectangles, text layout) that `usvg` already handles.
|
||||||
|
|
||||||
|
### Output format
|
||||||
|
|
||||||
|
PNG, RGBA8 sRGB, **dimensions 256 × 384** (2:3 aspect, half the
|
||||||
|
default `SvgLoaderSettings` of 512 × 768).
|
||||||
|
|
||||||
|
Rationale: cards never exceed ~250 px wide on desktop windows
|
||||||
|
today, and 256 × 384 PNGs are ~6 KB each at this content density
|
||||||
|
(13.4 KB total for a full deck of 52 + 5 backs). The default 512 ×
|
||||||
|
768 is 2× what's needed and quadruples the on-disk asset weight.
|
||||||
|
The existing legacy PNGs are 512 × 768 — reducing the new ones
|
||||||
|
halves the runtime asset size.
|
||||||
|
|
||||||
|
## Lockstep migration — recommended order
|
||||||
|
|
||||||
|
Each step is a separate commit; the constraint is that **steps 4
|
||||||
|
and 5 must land in the same commit** (or at most adjacent commits
|
||||||
|
on the same branch) so the rendered output never diverges between
|
||||||
|
the two paths.
|
||||||
|
|
||||||
|
1. **(Done — this commit)** Land the migration plan doc.
|
||||||
|
2. **Land the SVG generator example.** New
|
||||||
|
`solitaire_engine/examples/card_face_generator.rs`. Output
|
||||||
|
goes to `assets/cards/faces/` and `assets/cards/backs/`. Run
|
||||||
|
once locally to seed the new artwork. The example file stays
|
||||||
|
in-tree as a regenerator for future tweaks.
|
||||||
|
3. **(Optional — can land separately)** Add a one-shot regression
|
||||||
|
test that re-runs the generator into a `tempdir` and compares
|
||||||
|
the resulting bytes against the on-disk artwork; pinning the
|
||||||
|
generator output prevents silent drift if `usvg`/`resvg` ever
|
||||||
|
tweak rendering. Skip if the test runtime cost is unacceptable.
|
||||||
|
4. **Land the new artwork** (PNG bytes from step 2 committed to
|
||||||
|
`assets/cards/`) **and** the constant migration in the *same
|
||||||
|
commit*:
|
||||||
|
- `CARD_FACE_COLOUR` → `Color::srgb(0.102, 0.102, 0.102)` (`#1a1a1a`)
|
||||||
|
- `RED_SUIT_COLOUR` → `Color::srgb(0.984, 0.624, 0.694)` (`#fb9fb1`)
|
||||||
|
- `BLACK_SUIT_COLOUR` → `Color::srgb(0.816, 0.816, 0.816)` (`#d0d0d0`)
|
||||||
|
- `CARD_FACE_COLOUR_RED_CBM` → `Color::srgb(0.435, 0.761, 0.937)` (`#6fc2ef`) — note this is now the colour-blind *suit* colour, not a face tint; semantics shift slightly.
|
||||||
|
- `card_back_colour(idx)` — re-author for the Terminal palette;
|
||||||
|
index 0 stays the canonical "Terminal" back from `design-system.md`.
|
||||||
|
5. **Test updates land in step 4's commit.** The pinning tests at
|
||||||
|
`card_plugin.rs` lines 1749, 1750, 1767, 1768, 2057, 2063,
|
||||||
|
2071, 2081 all assert against the old constants. New
|
||||||
|
assertions update in lockstep with the constant changes.
|
||||||
|
|
||||||
|
## CBM (color-blind mode) semantics shift — flag
|
||||||
|
|
||||||
|
The **legacy** `CARD_FACE_COLOUR_RED_CBM` was a *face tint* — red
|
||||||
|
suits got a light-blue background wash. The **Terminal** spec
|
||||||
|
moves CBM into the *suit colour* itself (red glyphs swap to cyan).
|
||||||
|
Step 4 will rename / repurpose this constant; it's not a 1:1
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
- **Rename + repurpose:** `CARD_FACE_COLOUR_RED_CBM` →
|
||||||
|
`RED_SUIT_COLOUR_CBM`. Communicates the semantic shift in the
|
||||||
|
symbol name. Requires touching every callsite.
|
||||||
|
- **Keep the name, change the meaning:** less code churn but
|
||||||
|
worse for greppability — a future reader hitting the legacy
|
||||||
|
name will assume face-tint behaviour.
|
||||||
|
|
||||||
|
Recommendation: **rename**. The CBM swap is a one-frame operation
|
||||||
|
even if it touches every existing callsite (currently lines 642,
|
||||||
|
2071, 2081 per `grep -n CARD_FACE_COLOUR_RED_CBM`).
|
||||||
|
|
||||||
|
## Theme system — out of scope here
|
||||||
|
|
||||||
|
The card-theme system (`docs/CARD_PLAN.md`, `theme/plugin.rs`)
|
||||||
|
already supports user-supplied themes via `assets/themes/<theme>/`
|
||||||
|
SVG files rasterised by `svg_loader.rs`. The new Terminal artwork
|
||||||
|
is the **default theme**, not a new entry in the theme picker —
|
||||||
|
the theme system continues to overlay user themes on top of the
|
||||||
|
default at runtime.
|
||||||
|
|
||||||
|
If the next session wants to also ship Terminal as a *named theme
|
||||||
|
slot* (so a user can switch back to the legacy artwork via the
|
||||||
|
theme picker), that's an additive change after step 4 and lives
|
||||||
|
in `theme::plugin::apply_theme_to_card_image_set`.
|
||||||
|
|
||||||
|
## Test impact summary
|
||||||
|
|
||||||
|
`grep -n CARD_FACE_COLOUR\\b\|RED_SUIT_COLOUR\\b\|BLACK_SUIT_COLOUR\\b` in
|
||||||
|
`card_plugin.rs`:
|
||||||
|
|
||||||
|
- Line 1749–1750: red-suit text colour assertions (♥ + ♦).
|
||||||
|
- Line 1767–1768: black-suit text colour assertions (♠ + ♣).
|
||||||
|
- Line 2057, 2063: face-colour assertion in default mode.
|
||||||
|
- Line 2071, 2081: face-colour assertion in CBM.
|
||||||
|
|
||||||
|
The four suit-colour and two face-colour tests are **invariant
|
||||||
|
guards** — they exist precisely so a constant tweak surfaces here
|
||||||
|
rather than in a visual review. Step 4 updates each in lockstep
|
||||||
|
with the constant value change. No new test infrastructure
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## Open questions to resolve before step 4
|
||||||
|
|
||||||
|
1. **Border colour conflict.** The spec (line 218) says "Border:
|
||||||
|
1 px solid in suit colour." The fallback path doesn't draw a
|
||||||
|
border today — it draws solid-colour sprites. Step 4 either:
|
||||||
|
(a) leaves the fallback as solid-colour squares (the test
|
||||||
|
environment doesn't visually validate borders anyway), or
|
||||||
|
(b) extends the fallback renderer to paint a 1 px outline.
|
||||||
|
Recommend (a) — fallback fidelity isn't load-bearing.
|
||||||
|
2. **Glyph rendering in the constant fallback.** The fallback
|
||||||
|
today doesn't render suit glyphs at all — it's a coloured
|
||||||
|
square. The spec's filled-vs-outlined glyph differentiation
|
||||||
|
only matters in the PNG path. No change to the constant
|
||||||
|
fallback for glyphs.
|
||||||
|
3. **High-contrast mode.** `design-system.md` line 274 mentions
|
||||||
|
a high-contrast accessibility mode (boosts foreground from
|
||||||
|
`#d0d0d0` to `#f5f5f5`, suit-red from `#fb9fb1` to `#ff8aa0`).
|
||||||
|
Not currently implemented anywhere; out of scope for this
|
||||||
|
migration but worth flagging for a future accessibility pass.
|
||||||
|
|
||||||
|
## Post-migration — what's still open
|
||||||
|
|
||||||
|
- **High-contrast mode** (above).
|
||||||
|
- **Reduced-motion mode** for card lift / drop transitions
|
||||||
|
(also a `design-system.md` accessibility item, separate from
|
||||||
|
artwork).
|
||||||
|
- **The 9 missing-plugin screens** (splash, challenge,
|
||||||
|
time-attack, weekly-goals, leaderboard, sync, level-up,
|
||||||
|
replay, radial-menu) per `project_ui_overhaul` memory still
|
||||||
|
need their plugin ports — separate from the cards arc.
|
||||||
|
|
||||||
|
## Sign-off criteria for "D closed"
|
||||||
|
|
||||||
|
D from the SESSION_HANDOFF Resume prompt is closed when **all of
|
||||||
|
the following hold simultaneously**:
|
||||||
|
|
||||||
|
- The 52 face PNGs + 5 back PNGs in `assets/cards/` are the
|
||||||
|
Terminal-aesthetic artwork (regeneratable via the example).
|
||||||
|
- The five `card_plugin` constants reflect the Terminal palette.
|
||||||
|
- All pinning tests pass against the new values.
|
||||||
|
- A human boots the game and sees Terminal cards (not white
|
||||||
|
cards). This sign-off needs a real `cargo run`, not just
|
||||||
|
`cargo test`.
|
||||||
@@ -15,12 +15,12 @@ colors:
|
|||||||
inverse-on-surface: '#151515'
|
inverse-on-surface: '#151515'
|
||||||
outline: '#505050'
|
outline: '#505050'
|
||||||
outline-variant: '#353535'
|
outline-variant: '#353535'
|
||||||
surface-tint: '#6fc2ef'
|
surface-tint: '#a54242'
|
||||||
primary: '#6fc2ef'
|
primary: '#a54242'
|
||||||
on-primary: '#151515'
|
on-primary: '#151515'
|
||||||
primary-container: '#1f3a4a'
|
primary-container: '#3a1f1f'
|
||||||
on-primary-container: '#a8dcf5'
|
on-primary-container: '#d5a8a8'
|
||||||
inverse-primary: '#0e6e99'
|
inverse-primary: '#993e3e'
|
||||||
secondary: '#acc267'
|
secondary: '#acc267'
|
||||||
on-secondary: '#151515'
|
on-secondary: '#151515'
|
||||||
secondary-container: '#2a3320'
|
secondary-container: '#2a3320'
|
||||||
@@ -38,7 +38,7 @@ colors:
|
|||||||
surface-variant: '#353535'
|
surface-variant: '#353535'
|
||||||
suit-red: '#fb9fb1'
|
suit-red: '#fb9fb1'
|
||||||
suit-black: '#d0d0d0'
|
suit-black: '#d0d0d0'
|
||||||
suit-red-cb: '#6fc2ef'
|
suit-red-cb: '#acc267'
|
||||||
highlight-valid: '#acc267'
|
highlight-valid: '#acc267'
|
||||||
highlight-celebration: '#e1a3ee'
|
highlight-celebration: '#e1a3ee'
|
||||||
highlight-warning: '#ddb26f'
|
highlight-warning: '#ddb26f'
|
||||||
@@ -119,14 +119,16 @@ The palette is base16-eighties — a 16-slot terminal palette where indices 00
|
|||||||
| base09 | `#ddb26f` | orange — used for warning chips |
|
| base09 | `#ddb26f` | orange — used for warning chips |
|
||||||
| base0A | `#acc267` | yellow/lime — used for `highlight-valid` (drag targets, valid moves) |
|
| base0A | `#acc267` | yellow/lime — used for `highlight-valid` (drag targets, valid moves) |
|
||||||
| base0B | `#12cfc0` | green/teal — used for `highlight-info` (toasts, neutral status) |
|
| base0B | `#12cfc0` | green/teal — used for `highlight-info` (toasts, neutral status) |
|
||||||
| base0C | `#6fc2ef` | cyan/sky — primary CTA, focus ring, `selection`, `suit-red-cb` (color-blind tinted red) |
|
| base0C | `#6fc2ef` | cyan/sky — historically the primary CTA; now reserved for ad-hoc accents only |
|
||||||
| base0D | `#6fc2ef` | (alias) |
|
| base0D | `#6fc2ef` | (alias) |
|
||||||
|
| base08 (project) | `#a54242` | brick red — primary CTA, focus ring, `selection` (project-specific extension; the base16-eighties `base08` slot is `#fb9fb1` pink which we keep as `error`/`suit-red`) |
|
||||||
|
| `suit-red-cb` slot | `#acc267` | lime — color-blind-mode swap for red suits (was `#6fc2ef` cyan before the 2026-05-08 primary-accent swap; lime is the next-best non-red base16-eighties accent) |
|
||||||
| base0E | `#e1a3ee` | violet — used for celebration (level-up, achievement unlock) |
|
| base0E | `#e1a3ee` | violet — used for celebration (level-up, achievement unlock) |
|
||||||
| base0F | `#fb9fb1` | (alias) |
|
| base0F | `#fb9fb1` | (alias) |
|
||||||
|
|
||||||
### Semantic assignments
|
### Semantic assignments
|
||||||
|
|
||||||
- **CTA / Primary action**: cyan `#6fc2ef`. Reserved for "Play," "New Game," "Save," "Resume," and the focus ring on selected cards. Never used decoratively.
|
- **CTA / Primary action**: brick red `#a54242`. Reserved for "Play," "New Game," "Save," "Resume," and the focus ring on selected cards. Never used decoratively. (Was cyan `#6fc2ef` before the 2026-05-08 swap.)
|
||||||
- **Valid-move / drag-target highlight**: lime `#acc267`. Reserved for in-game feedback only. Never appears in chrome.
|
- **Valid-move / drag-target highlight**: lime `#acc267`. Reserved for in-game feedback only. Never appears in chrome.
|
||||||
- **Celebration**: lavender `#e1a3ee`. Used for level-up flashes, achievement unlock cards, and the daily-streak chip when the streak is active. Quiet otherwise.
|
- **Celebration**: lavender `#e1a3ee`. Used for level-up flashes, achievement unlock cards, and the daily-streak chip when the streak is active. Quiet otherwise.
|
||||||
- **Warning / soft alert**: gold `#ddb26f`. Used for "challenge expires in N minutes" chips, sync-pending status, and the daily-seed countdown.
|
- **Warning / soft alert**: gold `#ddb26f`. Used for "challenge expires in N minutes" chips, sync-pending status, and the daily-seed countdown.
|
||||||
@@ -135,18 +137,23 @@ The palette is base16-eighties — a 16-slot terminal palette where indices 00
|
|||||||
|
|
||||||
## Suit Colors
|
## 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 |
|
| Suit | Default | Color-blind mode | Glyph differentiation |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | Solid filled glyph |
|
| Hearts | `#e35353` (saturated red) | `#acc267` (lime) | Solid filled glyph |
|
||||||
| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | **Outlined glyph (1.5px stroke)** |
|
| Diamonds | `#e35353` (saturated red) | `#acc267` (lime) | **Outlined glyph (1.5px stroke)** |
|
||||||
| Spades | `#d0d0d0` (foreground) | `#d0d0d0` | Solid filled glyph |
|
| Spades | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | Solid filled glyph |
|
||||||
| Clubs | `#d0d0d0` (foreground) | `#d0d0d0` | **Outlined glyph (1.5px stroke)** |
|
| 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 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→cyan; it does not turn the outlined glyphs on or off, because outlined glyphs are always on.
|
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
|
## Typography
|
||||||
|
|
||||||
@@ -177,7 +184,7 @@ Depth is created through **tonal layering and 1px outlines**, not blur shadows.
|
|||||||
- **Level 0 (Background)**: the `#151515` base canvas.
|
- **Level 0 (Background)**: the `#151515` base canvas.
|
||||||
- **Level 1 (Tableau slots, empty piles)**: 1px dashed outline in `#353535`. Empty foundations show a faint suit glyph at 12% opacity inside the outline.
|
- **Level 1 (Tableau slots, empty piles)**: 1px dashed outline in `#353535`. Empty foundations show a faint suit glyph at 12% opacity inside the outline.
|
||||||
- **Level 2 (Cards at rest)**: solid `#1a1a1a` fill, 1px solid border in the suit color (so the suit is detectable at a glance even if the card is partially obscured).
|
- **Level 2 (Cards at rest)**: solid `#1a1a1a` fill, 1px solid border in the suit color (so the suit is detectable at a glance even if the card is partially obscured).
|
||||||
- **Level 3 (Active / dragged card)**: same border, but glow effect: 0 0 12px of `#6fc2ef` at 40% opacity. **No scale transform** — flatness preserved. Z-index lifts above siblings.
|
- **Level 3 (Active / dragged card)**: same border, but glow effect: 0 0 12px of `#a54242` at 40% opacity. **No scale transform** — flatness preserved. Z-index lifts above siblings.
|
||||||
- **Modals**: full-screen with backdrop `#151515` at 95% opacity (just enough to dim the table without blurring it). Modal panel is `#202020` with a 1px `#505050` border — like a terminal pane.
|
- **Modals**: full-screen with backdrop `#151515` at 95% opacity (just enough to dim the table without blurring it). Modal panel is `#202020` with a 1px `#505050` border — like a terminal pane.
|
||||||
- **Toasts**: bottom of screen, `#202020` fill, 1px border in the toast's accent color (info=teal, warning=gold, error=pink, celebration=lavender). 16px monospaced caption.
|
- **Toasts**: bottom of screen, `#202020` fill, 1px border in the toast's accent color (info=teal, warning=gold, error=pink, celebration=lavender). 16px monospaced caption.
|
||||||
|
|
||||||
@@ -193,7 +200,7 @@ The shape language is **soft-rounded but tight**:
|
|||||||
- **Avatars / circular indicators**: `rounded-full`.
|
- **Avatars / circular indicators**: `rounded-full`.
|
||||||
- **Card-back pattern corners**: matches the card's `rounded-md`.
|
- **Card-back pattern corners**: matches the card's `rounded-md`.
|
||||||
|
|
||||||
Selection highlights use a **2px inset stroke** in `#6fc2ef` following the host shape's corner radius. Never an outer stroke — the outer stroke is reserved for the suit-color hairline.
|
Selection highlights use a **2px inset stroke** in `#a54242` following the host shape's corner radius. Never an outer stroke — the outer stroke is reserved for the suit-color hairline.
|
||||||
|
|
||||||
## Motion
|
## Motion
|
||||||
|
|
||||||
@@ -215,9 +222,9 @@ Selection highlights use a **2px inset stroke** in `#6fc2ef` following the host
|
|||||||
|
|
||||||
Flat face design.
|
Flat face design.
|
||||||
- Background: `#1a1a1a`
|
- 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)
|
- Top-left: rank in JetBrains Mono Bold 18px + small suit glyph (10px)
|
||||||
- Bottom-right: large suit glyph (32px), rotated 180°
|
- 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
|
- Corner radius: 8px
|
||||||
- Suit differentiation: hearts and spades have **filled** glyphs; diamonds and clubs have **outlined** glyphs (1.5px stroke)
|
- Suit differentiation: hearts and spades have **filled** glyphs; diamonds and clubs have **outlined** glyphs (1.5px stroke)
|
||||||
|
|
||||||
@@ -228,17 +235,17 @@ Flat face design.
|
|||||||
- Background: `#151515`
|
- Background: `#151515`
|
||||||
- Pattern: horizontal scanlines at 2px pitch in `#1a1a1a` (1px line, 1px gap), full bleed
|
- Pattern: horizontal scanlines at 2px pitch in `#1a1a1a` (1px line, 1px gap), full bleed
|
||||||
- Border: 1px solid `#353535`
|
- Border: 1px solid `#353535`
|
||||||
- Top-left badge: a 12×16px solid `#6fc2ef` block (the "terminal cursor"), 6px from the corner
|
- Top-left badge: a 12×16px solid `#a54242` block (the "terminal cursor"), 6px from the corner
|
||||||
- Bottom-right monogram: the characters `▌RS` in JetBrains Mono 12px, color `#505050`, 6px from the corner
|
- Bottom-right monogram: the characters `▌RS` in JetBrains Mono 12px, color `#505050`, 6px from the corner
|
||||||
- Corner radius: 8px (matches face)
|
- Corner radius: 8px (matches face)
|
||||||
|
|
||||||
### Primary Buttons
|
### Primary Buttons
|
||||||
|
|
||||||
Solid `#6fc2ef` fill, `#151515` text, JetBrains Mono Medium 14px uppercase with `+0.08em` tracking. 4px corner radius. Pressed state: darken to `#5aa9d4`. Disabled: `#353535` fill, `#505050` text.
|
Solid `#a54242` fill, `#151515` text, JetBrains Mono Medium 14px uppercase with `+0.08em` tracking. 4px corner radius. Pressed state: darken to `#7a3030`. Disabled: `#353535` fill, `#505050` text.
|
||||||
|
|
||||||
### Secondary Buttons
|
### Secondary Buttons
|
||||||
|
|
||||||
Transparent fill, 1px `#505050` border, `#d0d0d0` text. Hover/press: border becomes `#6fc2ef`, text becomes `#6fc2ef`.
|
Transparent fill, 1px `#505050` border, `#d0d0d0` text. Hover/press: border becomes `#a54242`, text becomes `#a54242`.
|
||||||
|
|
||||||
### HUD Chips
|
### HUD Chips
|
||||||
|
|
||||||
@@ -258,7 +265,7 @@ Full-screen backdrop at 95% opacity. Centered panel: `#202020` fill, 1px `#50505
|
|||||||
|
|
||||||
### Navigation Bar
|
### Navigation Bar
|
||||||
|
|
||||||
Fixed at the bottom of in-game screens. Height: 64px. `#202020` fill, 1px top border in `#353535`. Four icon buttons: Undo / Hint / New / Auto-complete. Icons: 24px, 1.5px stroke weight, color `#d0d0d0`. Active/pressed: icon color `#6fc2ef`.
|
Fixed at the bottom of in-game screens. Height: 64px. `#202020` fill, 1px top border in `#353535`. Four icon buttons: Undo / Hint / New / Auto-complete. Icons: 24px, 1.5px stroke weight, color `#d0d0d0`. Active/pressed: icon color `#a54242`.
|
||||||
|
|
||||||
### Status / Sync Indicator
|
### Status / Sync Indicator
|
||||||
|
|
||||||
@@ -270,7 +277,7 @@ Top-right corner of the HUD: a 6px circular dot.
|
|||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
1. **Color-blind mode** (Settings → Gameplay): swaps red suits' default `#fb9fb1` for `#6fc2ef`. 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`.
|
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.
|
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.
|
4. **Tabular figures** are mandatory for any number that updates live (timer, score, moves) so they don't reflow.
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# Terminal — Desktop Adaptation Spec
|
||||||
|
|
||||||
|
> **Why this exists.** The 24 mockups in this directory are mobile
|
||||||
|
> (390 × 844 logical, iPhone 14 Pro frame) with one exception
|
||||||
|
> (`home-menu-desktop.html`). The Stitch project that produced them
|
||||||
|
> is named "Solitaire Quest *Mobile* Redesign" — the mobile-first
|
||||||
|
> framing was deliberate when the new Android target opened, but
|
||||||
|
> desktop is still the primary delivery surface. Porting the mobile
|
||||||
|
> mockups 1:1 would land a 390-px-wide column floating in the middle
|
||||||
|
> of an 1800 × 1100 window. This file is the rules-based desktop
|
||||||
|
> companion — apply these adaptations whenever you port a Bevy
|
||||||
|
> plugin against a mobile mockup in this directory.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
* **Token system.** All tokens (palette, type scale, spacing,
|
||||||
|
radii, motion) in `design-system.md` are layout-agnostic and
|
||||||
|
apply unchanged on both targets. Do **not** introduce desktop-
|
||||||
|
specific token variants — adapt geometry, not tokens.
|
||||||
|
* **Already adapted in code.** v0.20.0's port is layout-agnostic
|
||||||
|
(modal scaffold, toasts, table chrome, card chrome, gameplay-
|
||||||
|
feedback, splash cursor). Those surfaces already adapt
|
||||||
|
correctly because their Bevy UI nodes use flex / percent /
|
||||||
|
stretch sizing rather than fixed pixel widths from the
|
||||||
|
mockups.
|
||||||
|
* **Not yet adapted in code.** Any future plugin port that
|
||||||
|
copies layout from a mobile mockup must apply the rules below.
|
||||||
|
|
||||||
|
## Viewport assumptions
|
||||||
|
|
||||||
|
| Range | Width × height | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Mobile target | 390 × 844 | iPhone 14 Pro logical, Stitch mockup canvas |
|
||||||
|
| Desktop minimum | 1024 × 600 | Smaller windows degrade to mobile rules |
|
||||||
|
| Desktop default | ~70 % of monitor | `apply_smart_default_window_size` (since v0.19.0) |
|
||||||
|
| Desktop typical | 1600 × 900 to 2560 × 1440 | The range we tune for |
|
||||||
|
| Desktop max | 3840 × 2160 | 4K, with HiDPI scaling already applied |
|
||||||
|
|
||||||
|
The "smart default" sizer means a 1080p monitor opens a ~1344 × 756
|
||||||
|
window, a 1440p monitor opens ~1792 × 1008, a 4K monitor opens
|
||||||
|
~2688 × 1512. Tune for the 1600–2400 width band as the centre of
|
||||||
|
the distribution; below 1024 width, fall back to the mobile rules
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
## Universal adaptation rules
|
||||||
|
|
||||||
|
Apply these to every screen unless the per-screen section
|
||||||
|
overrides them.
|
||||||
|
|
||||||
|
### 1. Edge margins
|
||||||
|
|
||||||
|
| Mobile | Desktop |
|
||||||
|
|---|---|
|
||||||
|
| `margin-edge: 16px` (`SPACE_4`) | `SPACE_5` (24 px) for windows < 1440 wide; `SPACE_6` (32 px) for 1440–2400; `SPACE_7` (48 px) for ≥ 2400 |
|
||||||
|
|
||||||
|
Engine: drive from `LayoutResource` based on `Window` size, not a
|
||||||
|
constant.
|
||||||
|
|
||||||
|
### 2. Modal max-width
|
||||||
|
|
||||||
|
| Mobile | Desktop |
|
||||||
|
|---|---|
|
||||||
|
| `100% - 2 × edge-margin` | `min(720 px, 50 % of viewport)` |
|
||||||
|
|
||||||
|
The 720 px cap is already in `ui_modal::spawn_modal`. No code
|
||||||
|
change needed; this rule documents *why* it's there.
|
||||||
|
|
||||||
|
### 3. Vertical content stacks
|
||||||
|
|
||||||
|
A mobile screen often stacks `Header → Body → Footer` vertically
|
||||||
|
to fit a tall narrow column. On desktop, prefer horizontal
|
||||||
|
distribution where the content allows:
|
||||||
|
|
||||||
|
* **Header rows that stack vertically on mobile** (title above
|
||||||
|
count above timer) → keep them in one horizontal row on
|
||||||
|
desktop.
|
||||||
|
* **Two-column flex layouts** (e.g. Settings rows: label left,
|
||||||
|
control right) — already work on both targets; no change.
|
||||||
|
* **Cards stacking with `mt-48`-style fixed gaps** — replace with
|
||||||
|
flex / percent gaps so the layout breathes.
|
||||||
|
|
||||||
|
### 4. Touch-target minimums
|
||||||
|
|
||||||
|
Mobile spec mandates 48 dp minimum touch targets. Desktop has no
|
||||||
|
such floor (mouse precision is finer), but **don't shrink below
|
||||||
|
mobile's 48 px** for primary actions — keyboard / gamepad focus
|
||||||
|
rings still need a visible target.
|
||||||
|
|
||||||
|
Secondary controls (chip-style toggles, hotkey hints, etc.) can
|
||||||
|
shrink to `TYPE_BODY` (14 px) text + `SPACE_3` (12 px) padding on
|
||||||
|
desktop where they were larger on mobile.
|
||||||
|
|
||||||
|
### 5. Bottom-anchored elements
|
||||||
|
|
||||||
|
Mobile mockups often anchor key controls (action bar, primary CTA,
|
||||||
|
toast position) to the bottom of the viewport for thumb reach.
|
||||||
|
Desktop has no thumb-reach concern:
|
||||||
|
|
||||||
|
* **Toasts** — keep bottom-anchored (already done in `a137607`),
|
||||||
|
the design language is consistent across targets and the
|
||||||
|
bottom is still the least-disruptive overlay zone.
|
||||||
|
* **Action bars** — top of viewport on desktop unless the
|
||||||
|
per-screen section says otherwise. The HUD already sits on
|
||||||
|
top.
|
||||||
|
* **Single primary CTA** — modals already right-align in the
|
||||||
|
actions row; no change.
|
||||||
|
|
||||||
|
### 6. Typography rungs unchanged
|
||||||
|
|
||||||
|
Do **not** shift `TYPE_*` tokens up a rung for desktop. The
|
||||||
|
spec's 14 / 18 / 26 / 40 progression is already calibrated for
|
||||||
|
the desktop reading distance (60–90 cm). Mobile uses the same
|
||||||
|
rungs at a closer reading distance (30–40 cm); same physical
|
||||||
|
angular size on the eye.
|
||||||
|
|
||||||
|
### 7. Hotkey hints become full strings
|
||||||
|
|
||||||
|
Mobile cells like `▌Esc` — the cursor block plus key letter — can
|
||||||
|
expand to `[Esc] cancel` style on desktop where horizontal
|
||||||
|
real-estate is cheap. Drives discoverability of keyboard-only
|
||||||
|
flows. Optional; only apply where horizontal space exists.
|
||||||
|
|
||||||
|
## Per-screen adaptation rules
|
||||||
|
|
||||||
|
### Game Table
|
||||||
|
|
||||||
|
Mockup: `game-table-mobile.html` (390 × 844).
|
||||||
|
|
||||||
|
| Element | Mobile | Desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| HUD band | full width, 56 px tall | full width, 48 px tall |
|
||||||
|
| Foundation row | 4 piles centred, fan-tight | 4 piles centred, **gutter doubled** so the row fills ~50 % of viewport width |
|
||||||
|
| Stock + waste | left of foundations, stacked | left of foundations, **horizontal pair**: stock on the left, waste to its immediate right (the mobile vertical pair feels cramped on a wide canvas) |
|
||||||
|
| Tableau row | 7 columns, 4 % gutter | 7 columns, **6 % gutter**, total tableau block ≤ 70 % viewport width |
|
||||||
|
| Card aspect | 2 : 3 (already in `Layout::card_size`) | unchanged — card aspect is domain |
|
||||||
|
| Tableau fan | `TABLEAU_FAN_FRAC = 0.25` | unchanged — fan is in card-height units, not viewport units |
|
||||||
|
| Drag-shadow offset | small | unchanged — pinned to 0 alpha under Terminal anyway |
|
||||||
|
|
||||||
|
**Engine impact:** `solitaire_engine/src/layout.rs::compute_layout`
|
||||||
|
already drives most of this from `Window::size()`. The mobile vs.
|
||||||
|
desktop difference is the gutter percentages — bake desktop
|
||||||
|
gutters when window width ≥ 1024.
|
||||||
|
|
||||||
|
### Win Summary
|
||||||
|
|
||||||
|
Mockup: `win-summary-mobile.html` (390 × 858).
|
||||||
|
|
||||||
|
| Element | Mobile | Desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| Modal width | 100 % − 2 × edge | **`min(720 px, 50 % viewport)`** (already done by `ui_modal`) |
|
||||||
|
| Score row | stacked vertically (line per metric) | **3-column grid**: Score / Time / Moves in one row, breakdown rows below in single-line per row |
|
||||||
|
| Action buttons | full-width stacked (Play Again, Continue, Stats) | **right-aligned action row** — the existing `spawn_modal_actions` already does this on both targets |
|
||||||
|
|
||||||
|
**Engine impact:** `solitaire_engine/src/win_summary_plugin.rs`. The
|
||||||
|
score-breakdown-stagger animation (`MOTION_SCORE_BREAKDOWN_*`) is
|
||||||
|
unchanged across targets.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
Mockup: `settings-mobile.html` (390 × 4330 — long scroll).
|
||||||
|
|
||||||
|
| Element | Mobile | Desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| Modal width | 100 % − 2 × edge | `min(720 px, 50 % viewport)` |
|
||||||
|
| Sections | full-width labels above stacked controls | **section labels left, control widget right** — already the engine's pattern; no change |
|
||||||
|
| Long page | scroll the whole modal | **two-column layout**: nav (sections list) on left ~30 %, current section on right ~70 %. Reduces scroll distance on desktop |
|
||||||
|
| Sliders | full-width on mobile | cap at 320 px on desktop |
|
||||||
|
|
||||||
|
**Engine impact:** if a desktop port wants the two-column nav, it's
|
||||||
|
a `settings_plugin` rewrite. Keep the existing single-column
|
||||||
|
stacked-modal layout for now — it works on both targets and the
|
||||||
|
two-column variant is a polish item, not a blocker.
|
||||||
|
|
||||||
|
### Help & Controls
|
||||||
|
|
||||||
|
Mockup: `help-mobile.html` (390 × 2544).
|
||||||
|
|
||||||
|
| Element | Mobile | Desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| Modal width | 100 % − 2 × edge | `min(720 px, 50 % viewport)` |
|
||||||
|
| Section list | one column of `Heading → 2-col rows` | **two columns of section blocks** for windows ≥ 1280 wide; halves vertical scroll distance |
|
||||||
|
| Hotkey rows | `key | description` 2-col flex | unchanged; 2-col already adapts |
|
||||||
|
|
||||||
|
**Engine impact:** `help_plugin`. Single-column on mobile, 2-col
|
||||||
|
on desktop windows ≥ 1280 wide is a flex-wrap option.
|
||||||
|
|
||||||
|
### Pause Menu
|
||||||
|
|
||||||
|
Mockup: `pause-menu-mobile.html` (390 × 1768).
|
||||||
|
|
||||||
|
Already a small modal; no significant geometry change. Modal
|
||||||
|
already uses `ui_modal::spawn_modal` which caps width and centres.
|
||||||
|
No desktop-specific rule.
|
||||||
|
|
||||||
|
### Home Menu
|
||||||
|
|
||||||
|
Mockup: `home-menu-mobile.html` and `home-menu-desktop.html`
|
||||||
|
(both already in this directory — desktop variant is the
|
||||||
|
authoritative reference).
|
||||||
|
|
||||||
|
The desktop mockup already specifies the layout. Cross-check it
|
||||||
|
against the mobile version when porting; differences are
|
||||||
|
deliberate (more horizontal real-estate, larger primary CTA, the
|
||||||
|
secondary actions row).
|
||||||
|
|
||||||
|
### Splash
|
||||||
|
|
||||||
|
Mockup: `splash-mobile.html` (390 × 844).
|
||||||
|
|
||||||
|
| Element | Mobile | Desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| Full-screen overlay | `inset-0` | unchanged — splash always covers the viewport |
|
||||||
|
| Cursor block (`▌`) | 96 px JetBrains Mono | unchanged — already done in `cdcadda`. The 96 px size scales fine on desktop because the splash is a brand beat, not a layout-driven element |
|
||||||
|
| Title `RUSTY SOLITAIRE` | 32 px | scale to 40 px (`TYPE_DISPLAY`) on desktop |
|
||||||
|
| Subtitle `TERMINAL EDITION` | 12 px | unchanged |
|
||||||
|
| Boot log lines | 70 % width column | cap at 480 px so the column doesn't stretch on a wide window |
|
||||||
|
| Progress bar | 100 % − 2 × edge | cap at 720 px |
|
||||||
|
| Palette swatch row + version footer | bottom-anchored | unchanged; bottom-anchor still reads correctly on desktop |
|
||||||
|
|
||||||
|
**Engine impact:** `splash_plugin` already has the cursor block
|
||||||
|
(`cdcadda`). The boot log / progress bar / palette swatch rows
|
||||||
|
are the next polish increment when option D is picked up.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
Mockup: `stats-mobile.html` (390 × 2624).
|
||||||
|
|
||||||
|
| Element | Mobile | Desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| Modal width | 100 % − 2 × edge | `min(720 px, 50 % viewport)` |
|
||||||
|
| Big-number cards | 2 × 2 grid | **4 × 1 row** for windows ≥ 1024 wide (the four headline metrics fit in a single horizontal row at desktop scale) |
|
||||||
|
| Latest-win caption | full-width line | unchanged |
|
||||||
|
| Replay clip / share row | full-width row | unchanged |
|
||||||
|
|
||||||
|
### Profile / Achievements / Theme Picker / Daily Challenge
|
||||||
|
|
||||||
|
These follow the **standard modal pattern** (`spawn_modal` with
|
||||||
|
header / body / actions). They already work on desktop because
|
||||||
|
`ui_modal` handles modal-width capping. Per-screen tweaks are
|
||||||
|
small and listed below; no structural changes:
|
||||||
|
|
||||||
|
* **Profile** — avatar + level / streak chips can flow into a
|
||||||
|
single horizontal row on desktop instead of stacking.
|
||||||
|
* **Achievements** — 3 × N grid on mobile becomes 4 × N or 5 × N
|
||||||
|
on desktop where windows ≥ 1280 wide.
|
||||||
|
* **Theme Picker** — 2-col grid of theme cards on mobile becomes
|
||||||
|
3- or 4-col on desktop.
|
||||||
|
* **Daily Challenge** — single-column scroll on both; no change.
|
||||||
|
|
||||||
|
## Mockup parity gap
|
||||||
|
|
||||||
|
The 9 missing-plugin screens (`splash`, `challenge`, `time-attack`,
|
||||||
|
`weekly-goals`, `leaderboard`, `sync`, `level-up`, `replay-overlay`,
|
||||||
|
`radial-menu`) have only mobile mockups. When porting any of these
|
||||||
|
plugins:
|
||||||
|
|
||||||
|
1. Read the mobile mockup for content + visual hierarchy.
|
||||||
|
2. Apply the universal adaptation rules above.
|
||||||
|
3. Apply the closest matching per-screen rule (e.g. an info modal
|
||||||
|
uses the same shape as Win Summary or Stats).
|
||||||
|
4. **No new layout pattern without explicit user approval.**
|
||||||
|
Adapting an existing pattern is in scope; inventing a desktop-
|
||||||
|
specific component is design work and should be flagged as such.
|
||||||
|
|
||||||
|
## Process notes
|
||||||
|
|
||||||
|
* **Smart-default sizer is the layout's source of truth.** Before
|
||||||
|
reading the mockup, always re-read `Window::size()` —
|
||||||
|
`apply_smart_default_window_size` runs at startup and the
|
||||||
|
player can resize freely. Hardcoded breakpoints in plugin code
|
||||||
|
should reference the *current* `Window` width via a
|
||||||
|
`LayoutResource` lookup, not the launch size.
|
||||||
|
* **`WindowResized` already drives layout recomputes** (CLAUDE.md
|
||||||
|
§3.4). Any per-window-width adaptation in this file should hook
|
||||||
|
into the existing recompute path, not a new system.
|
||||||
|
* **Mobile rules win at narrow desktop windows.** A user dragging
|
||||||
|
their desktop window down to 600 px width is closer to the
|
||||||
|
mobile use-case than the desktop one. Below 1024 px width,
|
||||||
|
apply the mobile rules verbatim.
|
||||||
|
* **Run on a 4K monitor before declaring a port done.** HiDPI
|
||||||
|
scaling routes through Bevy's logical sizing, but visual
|
||||||
|
polish (border thickness, motion budgets at high refresh rate)
|
||||||
|
is worth eyeballing.
|
||||||
@@ -22,16 +22,25 @@ bevy = { workspace = true }
|
|||||||
solitaire_engine = { workspace = true }
|
solitaire_engine = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
|
|
||||||
# `keyring`'s default-store init only matters on platforms with a
|
# Desktop-only deps. `keyring`'s default-store init only matters on
|
||||||
# real keychain backend (Linux Secret Service, macOS Keychain,
|
# platforms with a real keychain backend (Linux Secret Service,
|
||||||
# Windows Credential Store). The crate also pulls `rpassword`
|
# macOS Keychain, Windows Credential Store), and its transitive
|
||||||
# transitively, which uses `libc::__errno_location` — a symbol
|
# `rpassword` uses `libc::__errno_location` — a symbol Android's
|
||||||
# Android's bionic doesn't expose. Target-gating keeps
|
# bionic doesn't expose. `winit` is promoted from a transitive
|
||||||
# `cargo apk build` viable; the call site in `lib.rs` has its own
|
# Bevy 0.18 → bevy_winit 0.18 → winit 0.30 dep to a direct dep so
|
||||||
# `cfg(not(target_os = "android"))` guard so the desktop init path
|
# the `Window::icon` wiring in `set_window_icon` can construct
|
||||||
# is unchanged.
|
# `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]
|
[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`) -------------------
|
# --- Android packaging metadata (read by `cargo-apk`) -------------------
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -18,19 +18,22 @@ use std::io::Write;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{
|
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, 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_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
|
||||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// App entry point — builds and runs the Bevy app.
|
/// App entry point — builds and runs the Bevy app.
|
||||||
@@ -74,6 +77,7 @@ pub fn run() {
|
|||||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||||
// sessions don't end up with a comparatively tiny window.
|
// 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 had_saved_geometry = settings.window_geometry.is_some();
|
||||||
let (window_resolution, window_position) = match settings.window_geometry {
|
let (window_resolution, window_position) = match settings.window_geometry {
|
||||||
Some(geom) => (
|
Some(geom) => (
|
||||||
@@ -114,6 +118,9 @@ pub fn run() {
|
|||||||
// small enough that a few stray dropped frames from
|
// small enough that a few stray dropped frames from
|
||||||
// disabling vsync are imperceptible.
|
// disabling vsync are imperceptible.
|
||||||
present_mode: PresentMode::AutoNoVsync,
|
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 {
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||||
min_width: 800.0,
|
min_width: 800.0,
|
||||||
min_height: 600.0,
|
min_height: 600.0,
|
||||||
@@ -140,6 +147,13 @@ pub fn run() {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.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(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
.add_plugins(RadialMenuPlugin)
|
.add_plugins(RadialMenuPlugin)
|
||||||
@@ -156,6 +170,8 @@ pub fn run() {
|
|||||||
.add_plugins(DailyChallengePlugin)
|
.add_plugins(DailyChallengePlugin)
|
||||||
.add_plugins(WeeklyGoalsPlugin)
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
.add_plugins(ChallengePlugin)
|
.add_plugins(ChallengePlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin)
|
||||||
|
.add_plugins(DifficultyPlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
@@ -174,6 +190,14 @@ pub fn run() {
|
|||||||
.add_plugins(SplashPlugin)
|
.add_plugins(SplashPlugin)
|
||||||
.add_plugins(DiagnosticsHudPlugin);
|
.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,
|
// Smart default window sizing: when no saved geometry was loaded,
|
||||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||||
// monitor's logical size on the first frame. Without this, a 4K
|
// monitor's logical size on the first frame. Without this, a 4K
|
||||||
@@ -185,6 +209,8 @@ pub fn run() {
|
|||||||
// every fresh launch can flip `disable_smart_default_size` in
|
// every fresh launch can flip `disable_smart_default_size` in
|
||||||
// Settings to opt out. The flag is checked once at startup; a
|
// Settings to opt out. The flag is checked once at startup; a
|
||||||
// mid-session change applies on the next launch.
|
// 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 {
|
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||||
app.add_systems(Update, apply_smart_default_window_size);
|
app.add_systems(Update, apply_smart_default_window_size);
|
||||||
}
|
}
|
||||||
@@ -205,6 +231,7 @@ pub fn run() {
|
|||||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||||
/// populates the `Monitor` entities asynchronously after winit's
|
/// populates the `Monitor` entities asynchronously after winit's
|
||||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
fn apply_smart_default_window_size(
|
fn apply_smart_default_window_size(
|
||||||
mut applied: Local<bool>,
|
mut applied: Local<bool>,
|
||||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||||
@@ -251,6 +278,94 @@ fn apply_smart_default_window_size(
|
|||||||
*applied = true;
|
*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
|
/// 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
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
/// still runs afterwards, so stderr output and debugger integration are
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ publish = false
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
png = "0.17"
|
png = "0.17"
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
solitaire_core = { path = "../solitaire_core" }
|
||||||
|
solitaire_data = { path = "../solitaire_data" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_sfx"
|
name = "gen_sfx"
|
||||||
@@ -20,3 +22,11 @@ path = "src/bin/gen_sfx.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_art"
|
name = "gen_art"
|
||||||
path = "src/bin/gen_art.rs"
|
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,
|
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.
|
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||||||
///
|
///
|
||||||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||||||
@@ -59,6 +88,8 @@ pub enum DrawMode {
|
|||||||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||||||
/// countdown around the session and auto-deals a fresh game on every win
|
/// countdown around the session and auto-deals a fresh game on every win
|
||||||
/// (see `solitaire_engine::TimeAttackPlugin`).
|
/// (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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum GameMode {
|
pub enum GameMode {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -70,6 +101,8 @@ pub enum GameMode {
|
|||||||
Challenge,
|
Challenge,
|
||||||
/// Play as many games as possible within 10 minutes.
|
/// Play as many games as possible within 10 minutes.
|
||||||
TimeAttack,
|
TimeAttack,
|
||||||
|
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
|
||||||
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of game state used for undo.
|
/// Snapshot of game state used for undo.
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ tokio = { workspace = true }
|
|||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
keyring-core = { workspace = true }
|
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]
|
[dev-dependencies]
|
||||||
solitaire_server = { path = "../solitaire_server" }
|
solitaire_server = { path = "../solitaire_server" }
|
||||||
solitaire_sync = { workspace = true }
|
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.
|
// Android — delegate to the JNI Keystore bridge in android_keystore.
|
||||||
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
|
||||||
// effect is "session login required every launch", same as a Linux
|
|
||||||
// box without Secret Service.
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn store_tokens(
|
pub fn store_tokens(
|
||||||
_username: &str,
|
username: &str,
|
||||||
_access_token: &str,
|
access_token: &str,
|
||||||
_refresh_token: &str,
|
refresh_token: &str,
|
||||||
) -> Result<(), TokenError> {
|
) -> Result<(), TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::store_tokens(username, access_token, refresh_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::load_access_token(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::load_refresh_token(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
crate::android_keystore::delete_tokens(username)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,82 @@ pub const CHALLENGE_SEEDS: &[u64] = &[
|
|||||||
0xDDDD_EEEE_FFFF_0000,
|
0xDDDD_EEEE_FFFF_0000,
|
||||||
0x0101_0101_0101_0101,
|
0x0101_0101_0101_0101,
|
||||||
0xA1B2_C3D4_E5F6_0718,
|
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
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ pub use weekly::{
|
|||||||
pub mod challenge;
|
pub mod challenge;
|
||||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
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 mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
@@ -147,6 +150,9 @@ pub use settings::{
|
|||||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_keystore;
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
pub use auth_tokens::{
|
pub use auth_tokens::{
|
||||||
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||||
|
|||||||
@@ -147,12 +147,38 @@ pub struct Replay {
|
|||||||
/// [`REPLAY_SCHEMA_VERSION`].
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub share_url: Option<String>,
|
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 {
|
impl Replay {
|
||||||
/// Construct a fresh replay with the current schema version. The
|
/// Construct a fresh replay with the current schema version. The
|
||||||
/// caller fills in the recorded fields; this is the canonical
|
/// caller fills in the recorded fields; this is the canonical
|
||||||
/// constructor used by the engine on win.
|
/// 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(
|
pub fn new(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawMode,
|
||||||
@@ -172,8 +198,24 @@ impl Replay {
|
|||||||
recorded_at,
|
recorded_at,
|
||||||
moves,
|
moves,
|
||||||
share_url: None,
|
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.
|
/// Rolling history of the player's most recent winning replays.
|
||||||
@@ -737,4 +779,71 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
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 std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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 APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
@@ -117,6 +117,24 @@ pub struct Settings {
|
|||||||
/// solely on colour.
|
/// solely on colour.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_blind_mode: bool,
|
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`
|
/// Window size and screen position to restore on next launch. `None`
|
||||||
/// means "use platform defaults" — set on first run, then populated
|
/// means "use platform defaults" — set on first run, then populated
|
||||||
/// as the player resizes / moves the window. Older `settings.json`
|
/// 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")]`.
|
||||||
#[serde(default = "default_replay_move_interval_secs")]
|
#[serde(default = "default_replay_move_interval_secs")]
|
||||||
pub replay_move_interval_secs: f32,
|
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 {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -314,6 +339,8 @@ impl Default for Settings {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
high_contrast_mode: false,
|
||||||
|
reduce_motion_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
shown_achievement_onboarding: false,
|
shown_achievement_onboarding: false,
|
||||||
@@ -322,6 +349,7 @@ impl Default for Settings {
|
|||||||
winnable_deals_only: false,
|
winnable_deals_only: false,
|
||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
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
|
// Time Attack uses its own session-level scoring; a per-game best
|
||||||
// wouldn't compose with the other modes' single-game numbers.
|
// wouldn't compose with the other modes' single-game numbers.
|
||||||
GameMode::TimeAttack => {}
|
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();
|
self.last_modified = Utc::now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ zip = { workspace = true }
|
|||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
arboard = { workspace = true }
|
arboard = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,40 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
<!--
|
|
||||||
Default theme card back — Solitaire Quest's midnight-purple palette.
|
|
||||||
Original work, MIT-licensed alongside the rest of this project.
|
|
||||||
Aspect 2:3 to match the face SVGs from hayeah/playing-cards-assets.
|
|
||||||
-->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="diamonds" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
<pattern id="scanlines" x="0" y="0" width="2" height="4" patternUnits="userSpaceOnUse">
|
||||||
<rect x="0" y="0" width="20" height="20" fill="#1A0F2E"/>
|
<rect x="0" y="0" width="2" height="2" fill="#1a1a1a"/>
|
||||||
<path d="M 10 0 L 20 10 L 10 20 L 0 10 Z"
|
|
||||||
fill="none" stroke="#3A2580" stroke-width="1"/>
|
|
||||||
<circle cx="10" cy="10" r="1" fill="#FFD23F"/>
|
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- Outer card surface with a midnight-purple base + diamond lattice -->
|
<!-- Background fill, then scanlines on top (the scanlines stay
|
||||||
<rect x="0" y="0" width="200" height="300" rx="12" ry="12" fill="#1A0F2E"/>
|
darker than BACK_BG so the "off" rows show through). -->
|
||||||
<rect x="6" y="6" width="188" height="288" rx="9" ry="9" fill="url(#diamonds)"/>
|
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
|
||||||
|
fill="#151515" stroke="#353535" stroke-width="2"/>
|
||||||
|
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
|
||||||
|
fill="url(#scanlines)"/>
|
||||||
|
|
||||||
<!-- Bordered inset so the lattice has a clear edge -->
|
<!-- Top-left accent badge (the only theme-varying element). -->
|
||||||
<rect x="14" y="14" width="172" height="272" rx="6" ry="6"
|
<rect x="12" y="12" width="24" height="32" fill="#a54242"/>
|
||||||
fill="none" stroke="#FFD23F" stroke-width="1.5" opacity="0.85"/>
|
|
||||||
|
|
||||||
<!-- Centred diamond medallion -->
|
<!-- Bottom-right "▌RS" monogram in JetBrains-Mono-styled FiraMono. -->
|
||||||
<g transform="translate(100 150)">
|
<text x="244" y="368" font-family="Fira Mono" font-size="24"
|
||||||
<path d="M 0 -42 L 42 0 L 0 42 L -42 0 Z" fill="#2D1B69" stroke="#FFD23F" stroke-width="2"/>
|
fill="#505050" text-anchor="end">▌RS</text>
|
||||||
<path d="M 0 -22 L 22 0 L 0 22 L -22 0 Z" fill="#3A2580" stroke="#FFD23F" stroke-width="1"/>
|
</svg>
|
||||||
<circle cx="0" cy="0" r="4" fill="#FFD23F"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Corner pips picking up the magenta secondary accent so the back
|
|
||||||
still reads as part of the design system at a glance -->
|
|
||||||
<g fill="#FF6B9D">
|
|
||||||
<circle cx="22" cy="22" r="2.5"/>
|
|
||||||
<circle cx="178" cy="22" r="2.5"/>
|
|
||||||
<circle cx="22" cy="278" r="2.5"/>
|
|
||||||
<circle cx="178" cy="278" r="2.5"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 956 B |
@@ -1,281 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="0" y="0" width="256" height="384" rx="16" ry="16"
|
||||||
|
fill="#1a1a1a"/>
|
||||||
|
|
||||||
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
<!-- Top-left rank in JetBrains-Mono-styled FiraMono (rank digits
|
||||||
|
and letters render correctly in FiraMono; only the suit glyphs
|
||||||
|
needed to escape to paths). -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#e8e8e8">10</text>
|
||||||
|
|
||||||
<svg
|
<!-- Top-left small suit glyph at (14, 50), 20 × 20.
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
`suit_path_d` is authored in a 32-unit box, so scale 0.625
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
lands the visible glyph at 20 px. -->
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="none" stroke="#e8e8e8" stroke-width="3"/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</g>
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="167.0869141pt"
|
|
||||||
height="242.6669922pt"
|
|
||||||
viewBox="0 0 167.0869141 242.6669922"
|
|
||||||
xml:space="preserve"
|
|
||||||
id="svg2"
|
|
||||||
version="1.1"
|
|
||||||
inkscape:version="0.48.0 r9654"
|
|
||||||
sodipodi:docname="10_of_clubs.svg"
|
|
||||||
inkscape:export-filename="/home/byron/art/cards/final/PNGs/10_of_clubs.png"
|
|
||||||
inkscape:export-xdpi="215.44792"
|
|
||||||
inkscape:export-ydpi="215.44792"><metadata
|
|
||||||
id="metadata43"><rdf:RDF><cc:Work
|
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
|
||||||
id="defs41"><radialGradient
|
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient2984"
|
|
||||||
id="radialGradient3760"
|
|
||||||
cx="48.231091"
|
|
||||||
cy="18.137882"
|
|
||||||
fx="48.231091"
|
|
||||||
fy="18.137882"
|
|
||||||
r="9.5"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
|
||||||
id="linearGradient2984"><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:1;"
|
|
||||||
offset="0"
|
|
||||||
id="stop2986" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0.65648854;"
|
|
||||||
offset="1"
|
|
||||||
id="stop2988" /></linearGradient><radialGradient
|
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient3784"
|
|
||||||
id="radialGradient3792"
|
|
||||||
cx="171.48665"
|
|
||||||
cy="511.22299"
|
|
||||||
fx="171.48665"
|
|
||||||
fy="511.22299"
|
|
||||||
r="81.902771"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
|
||||||
id="linearGradient3784"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788" /></linearGradient><radialGradient
|
|
||||||
r="81.902771"
|
|
||||||
fy="511.22299"
|
|
||||||
fx="171.48665"
|
|
||||||
cy="511.22299"
|
|
||||||
cx="171.48665"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
id="radialGradient3855"
|
|
||||||
xlink:href="#linearGradient3784-4"
|
|
||||||
inkscape:collect="always" /><linearGradient
|
|
||||||
id="linearGradient3784-4"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786-8" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788-6" /></linearGradient><radialGradient
|
|
||||||
r="81.902771"
|
|
||||||
fy="461.84113"
|
|
||||||
fx="181.69392"
|
|
||||||
cy="461.84113"
|
|
||||||
cx="181.69392"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
id="radialGradient3916"
|
|
||||||
xlink:href="#linearGradient3784-3"
|
|
||||||
inkscape:collect="always" /><linearGradient
|
|
||||||
id="linearGradient3784-3"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786-86" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1"
|
|
||||||
objecttolerance="10"
|
|
||||||
gridtolerance="10"
|
|
||||||
guidetolerance="10"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:window-width="1680"
|
|
||||||
inkscape:window-height="977"
|
|
||||||
id="namedview39"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="2.4336873"
|
|
||||||
inkscape:cx="117.62976"
|
|
||||||
inkscape:cy="148.16686"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="25"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg2" />
|
|
||||||
<g
|
|
||||||
id="Layer_x0020_1"
|
|
||||||
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
|
||||||
<path
|
|
||||||
style="fill:#FFFFFF;stroke-width:0.5;"
|
|
||||||
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
|
||||||
id="path5" />
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g7">
|
|
||||||
<g
|
|
||||||
id="g9">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g15">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g19">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g23">
|
|
||||||
<g
|
|
||||||
id="g25">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g31">
|
|
||||||
<g
|
|
||||||
id="g33">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
|
<!-- Bottom-right large suit glyph at (178, 286), 64 × 64.
|
||||||
|
Visible bottom-right at (242, 350), visible top-left at
|
||||||
|
(178, 286). Same upright orientation as the top-left small
|
||||||
|
glyph — no 180° rotation applied. -->
|
||||||
|
<g transform="translate(178 286) scale(2)">
|
||||||
|
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="none" stroke="#e8e8e8" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<g
|
|
||||||
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
|
||||||
id="layer1-1-4"><path
|
|
||||||
id="cl-9"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<g
|
|
||||||
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
|
||||||
id="layer1-1-4-1"><path
|
|
||||||
id="cl-9-7"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 57.572834,25.099947 c 0,0 5.967372,-4.773898 5.967372,-11.392027 0,-3.8743954 -3.43972,-10.3065945 -11.392028,-10.3065945 -7.952308,0 -11.392028,6.4347116 -11.392028,10.3065945 0,6.618129 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163348 -18.444833,-1.638201 -18.444833,8.680956 0,5.16586 4.22113,10.849311 10.849311,10.849311 7.952308,0 11.392027,-8.680956 11.392027,-8.680956 0,0 1.010056,9.894531 -4.881939,15.191045 h 13.020178 c -5.891994,-5.294001 -4.881938,-15.191045 -4.881938,-15.191045 0,0 3.439718,8.680956 11.392027,8.680956 6.630693,0 10.849311,-5.685963 10.849311,-10.849311 0,-10.319157 -11.816654,-13.844304 -18.444833,-8.680956 z"
|
|
||||||
id="cl-9-8" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 57.110434,93.200747 c 0,0 5.967372,-4.773898 5.967372,-11.392027 0,-3.874396 -3.43972,-10.306594 -11.392028,-10.306594 -7.952308,0 -11.392028,6.434711 -11.392028,10.306594 0,6.618129 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163348 -18.444833,-1.638201 -18.444833,8.680953 0,5.16587 4.22113,10.84932 10.849311,10.84932 7.952308,0 11.392027,-8.68096 11.392027,-8.68096 0,0 1.010056,9.89453 -4.881939,15.19104 h 13.020178 c -5.891994,-5.294 -4.881938,-15.19104 -4.881938,-15.19104 0,0 3.439718,8.68096 11.392027,8.68096 6.630693,0 10.849311,-5.68597 10.849311,-10.84932 0,-10.319154 -11.816654,-13.844301 -18.444833,-8.680953 z"
|
|
||||||
id="cl-9-8-0" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 121.55789,24.926219 c 0,0 5.96737,-4.773898 5.96737,-11.392027 0,-3.8743954 -3.43971,-10.3065945 -11.39203,-10.3065945 -7.95231,0 -11.39202,6.4347116 -11.39202,10.3065945 0,6.618129 5.96737,11.392027 5.96737,11.392027 -6.62818,-5.163348 -18.444834,-1.638201 -18.444834,8.680956 0,5.16586 4.22113,10.849311 10.849304,10.849311 7.95231,0 11.39203,-8.680956 11.39203,-8.680956 0,0 1.01006,9.894531 -4.88193,15.191045 h 13.02017 c -5.89199,-5.294001 -4.88193,-15.191045 -4.88193,-15.191045 0,0 3.43971,8.680956 11.39202,8.680956 6.63069,0 10.84931,-5.685963 10.84931,-10.849311 0,-10.319157 -11.81665,-13.844304 -18.44483,-8.680956 z"
|
|
||||||
id="cl-9-8-9" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 121.55789,93.027019 c 0,0 5.96737,-4.773898 5.96737,-11.392028 0,-3.874395 -3.43971,-10.306593 -11.39203,-10.306593 -7.95231,0 -11.39202,6.434711 -11.39202,10.306593 0,6.61813 5.96737,11.392028 5.96737,11.392028 -6.62818,-5.163348 -18.444834,-1.638201 -18.444834,8.680951 0,5.16587 4.22113,10.84932 10.849304,10.84932 7.95231,0 11.39203,-8.68096 11.39203,-8.68096 0,0 1.01006,9.89453 -4.88193,15.19104 h 13.02017 c -5.89199,-5.294 -4.88193,-15.19104 -4.88193,-15.19104 0,0 3.43971,8.68096 11.39202,8.68096 6.63069,0 10.84931,-5.68597 10.84931,-10.84932 0,-10.319152 -11.81665,-13.844299 -18.44483,-8.680951 z"
|
|
||||||
id="cl-9-8-0-4" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 89.576798,59.281103 c 0,0 5.967372,-4.773897 5.967372,-11.392027 0,-3.874395 -3.43972,-10.306594 -11.392028,-10.306594 -7.952308,0 -11.392028,6.434712 -11.392028,10.306594 0,6.61813 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163347 -18.444833,-1.638201 -18.444833,8.680957 0,5.165859 4.22113,10.84931 10.849311,10.84931 7.952308,0 11.392027,-8.680956 11.392027,-8.680956 0,0 1.010056,9.894531 -4.881939,15.191045 h 13.020178 c -5.891994,-5.294001 -4.881938,-15.191045 -4.881938,-15.191045 0,0 3.439718,8.680956 11.392027,8.680956 6.63069,0 10.84931,-5.685963 10.84931,-10.84931 0,-10.319158 -11.816653,-13.844304 -18.444832,-8.680957 z"
|
|
||||||
id="cl-9-8-8" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 110.06258,217.80216 c 0,0 -5.96737,4.77391 -5.96737,11.39203 0,3.8744 3.43971,10.3066 11.39202,10.3066 7.95232,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61812 -5.96737,-11.39203 -5.96737,-11.39203 6.62818,5.16335 18.44483,1.6382 18.44483,-8.68095 0,-5.16586 -4.22112,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39202,8.68095 -11.39202,8.68095 0,0 -1.01006,-9.89453 4.88193,-15.19104 h -13.02017 c 5.89199,5.294 4.88193,15.19104 4.88193,15.19104 0,0 -3.43972,-8.68095 -11.39203,-8.68095 -6.630687,0 -10.849305,5.68596 -10.849305,10.84931 0,10.31915 11.816655,13.8443 18.444835,8.68095 z"
|
|
||||||
id="cl-9-8-4" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 110.70832,149.70136 c 0,0 -5.96737,4.77391 -5.96737,11.39203 0,3.8744 3.43971,10.3066 11.39202,10.3066 7.95232,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61812 -5.96737,-11.39203 -5.96737,-11.39203 6.62818,5.16335 18.44483,1.6382 18.44483,-8.68095 0,-5.16586 -4.22112,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39202,8.68095 -11.39202,8.68095 0,0 -1.01006,-9.89453 4.88193,-15.19104 h -13.02017 c 5.89199,5.294 4.88193,15.19104 4.88193,15.19104 0,0 -3.43972,-8.68095 -11.39203,-8.68095 -6.630687,0 -10.849305,5.68596 -10.849305,10.84931 0,10.31915 11.816655,13.8443 18.444835,8.68095 z"
|
|
||||||
id="cl-9-8-0-2" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 46.077633,217.97556 c 0,0 -5.967372,4.77391 -5.967372,11.39203 0,3.8744 3.43972,10.3066 11.392028,10.3066 7.952308,0 11.392028,-6.43471 11.392028,-10.3066 0,-6.61812 -5.967373,-11.39203 -5.967373,-11.39203 6.62818,5.16335 18.444833,1.6382 18.444833,-8.68095 0,-5.16586 -4.22113,-10.84931 -10.849311,-10.84931 -7.952308,0 -11.392027,8.68095 -11.392027,8.68095 0,0 -1.010056,-9.89453 4.881939,-15.19104 H 44.9922 c 5.891994,5.294 4.881938,15.19104 4.881938,15.19104 0,0 -3.439718,-8.68095 -11.392027,-8.68095 -6.630693,0 -10.849311,5.68596 -10.849311,10.84931 0,10.31915 11.816654,13.8443 18.444833,8.68095 z"
|
|
||||||
id="cl-9-8-9-6" /><path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 46.261118,149.87509 c 0,0 -5.967372,4.77391 -5.967372,11.39203 0,3.8744 3.43972,10.3066 11.392028,10.3066 7.952308,0 11.392028,-6.43471 11.392028,-10.3066 0,-6.61812 -5.967373,-11.39203 -5.967373,-11.39203 6.62818,5.16335 18.444833,1.6382 18.444833,-8.68095 0,-5.16586 -4.22113,-10.84931 -10.849311,-10.84931 -7.952308,0 -11.392027,8.68095 -11.392027,8.68095 0,0 -1.010056,-9.89453 4.881939,-15.19104 H 45.175685 c 5.891994,5.294 4.881938,15.19104 4.881938,15.19104 0,0 -3.439718,-8.68095 -11.392027,-8.68095 -6.630693,0 -10.849311,5.68596 -10.849311,10.84931 0,10.31915 11.816654,13.8443 18.444833,8.68095 z"
|
|
||||||
id="cl-9-8-0-4-9" /><text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="-1.1621548"
|
|
||||||
y="27.170401"
|
|
||||||
id="text3788"
|
|
||||||
sodipodi:linespacing="125%"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3790"
|
|
||||||
x="-1.1621548"
|
|
||||||
y="27.170401"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="11.000458"
|
|
||||||
y="27.499109"
|
|
||||||
id="text3038"
|
|
||||||
sodipodi:linespacing="125%"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3040"
|
|
||||||
x="11.000458"
|
|
||||||
y="27.499109">0</tspan></text>
|
|
||||||
<path
|
|
||||||
style="fill:#000000"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
d="m 78.0698,183.9376 c 0,0 -5.96738,4.77389 -5.96738,11.39202 0,3.8744 3.43972,10.3066 11.39203,10.3066 7.95231,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61813 -5.96737,-11.39202 -5.96737,-11.39202 6.62818,5.16334 18.44483,1.6382 18.44483,-8.68096 0,-5.16586 -4.22113,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39203,8.68096 -11.39203,8.68096 0,0 -1.01005,-9.89454 4.88194,-15.19105 H 76.98436 c 5.892,5.294 4.88194,15.19105 4.88194,15.19105 0,0 -3.43972,-8.68096 -11.39203,-8.68096 -6.630688,0 -10.849308,5.68596 -10.849308,10.84931 0,10.31916 11.816658,13.8443 18.444838,8.68096 z"
|
|
||||||
id="cl-9-8-8-8" /><text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="-168.80901"
|
|
||||||
y="-216.22618"
|
|
||||||
id="text3788-0"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
transform="scale(-1,-1)"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3790-6"
|
|
||||||
x="-168.80901"
|
|
||||||
y="-216.22618"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="-156.64639"
|
|
||||||
y="-215.89748"
|
|
||||||
id="text3038-8"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
transform="scale(-1,-1)"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3040-9"
|
|
||||||
x="-156.64639"
|
|
||||||
y="-215.89748">0</tspan></text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,216 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="0" y="0" width="256" height="384" rx="16" ry="16"
|
||||||
|
fill="#1a1a1a"/>
|
||||||
|
|
||||||
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
<!-- Top-left rank in JetBrains-Mono-styled FiraMono (rank digits
|
||||||
|
and letters render correctly in FiraMono; only the suit glyphs
|
||||||
|
needed to escape to paths). -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#e8e8e8">2</text>
|
||||||
|
|
||||||
<svg
|
<!-- Top-left small suit glyph at (14, 50), 20 × 20.
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
`suit_path_d` is authored in a 32-unit box, so scale 0.625
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
lands the visible glyph at 20 px. -->
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="none" stroke="#e8e8e8" stroke-width="3"/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</g>
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="167.0869141pt"
|
|
||||||
height="242.6669922pt"
|
|
||||||
viewBox="0 0 167.0869141 242.6669922"
|
|
||||||
xml:space="preserve"
|
|
||||||
id="svg2"
|
|
||||||
version="1.1"
|
|
||||||
inkscape:version="0.48.0 r9654"
|
|
||||||
sodipodi:docname="2_of_clubs.svg"
|
|
||||||
inkscape:export-filename="/home/byron/art/cards/final/PNGs/2_of_clubs.png"
|
|
||||||
inkscape:export-xdpi="215.44792"
|
|
||||||
inkscape:export-ydpi="215.44792"><metadata
|
|
||||||
id="metadata43"><rdf:RDF><cc:Work
|
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
|
||||||
id="defs41"><radialGradient
|
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient2984"
|
|
||||||
id="radialGradient3760"
|
|
||||||
cx="48.231091"
|
|
||||||
cy="18.137882"
|
|
||||||
fx="48.231091"
|
|
||||||
fy="18.137882"
|
|
||||||
r="9.5"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
|
||||||
id="linearGradient2984"><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:1;"
|
|
||||||
offset="0"
|
|
||||||
id="stop2986" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0.65648854;"
|
|
||||||
offset="1"
|
|
||||||
id="stop2988" /></linearGradient><radialGradient
|
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient3784"
|
|
||||||
id="radialGradient3792"
|
|
||||||
cx="171.48665"
|
|
||||||
cy="511.22299"
|
|
||||||
fx="171.48665"
|
|
||||||
fy="511.22299"
|
|
||||||
r="81.902771"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
|
||||||
id="linearGradient3784"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788" /></linearGradient><radialGradient
|
|
||||||
r="81.902771"
|
|
||||||
fy="511.22299"
|
|
||||||
fx="171.48665"
|
|
||||||
cy="511.22299"
|
|
||||||
cx="171.48665"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
id="radialGradient3855"
|
|
||||||
xlink:href="#linearGradient3784-4"
|
|
||||||
inkscape:collect="always" /><linearGradient
|
|
||||||
id="linearGradient3784-4"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786-8" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788-6" /></linearGradient><radialGradient
|
|
||||||
r="81.902771"
|
|
||||||
fy="461.84113"
|
|
||||||
fx="181.69392"
|
|
||||||
cy="461.84113"
|
|
||||||
cx="181.69392"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
id="radialGradient3916"
|
|
||||||
xlink:href="#linearGradient3784-3"
|
|
||||||
inkscape:collect="always" /><linearGradient
|
|
||||||
id="linearGradient3784-3"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786-86" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1"
|
|
||||||
objecttolerance="10"
|
|
||||||
gridtolerance="10"
|
|
||||||
guidetolerance="10"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:window-width="1680"
|
|
||||||
inkscape:window-height="977"
|
|
||||||
id="namedview39"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="2.4336873"
|
|
||||||
inkscape:cx="117.62976"
|
|
||||||
inkscape:cy="148.16686"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="25"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg2" />
|
|
||||||
<g
|
|
||||||
id="Layer_x0020_1"
|
|
||||||
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
|
||||||
<path
|
|
||||||
style="fill:#FFFFFF;stroke-width:0.5;"
|
|
||||||
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
|
||||||
id="path5" />
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g7">
|
|
||||||
<g
|
|
||||||
id="g9">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g15">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g19">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g23">
|
|
||||||
<g
|
|
||||||
id="g25">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g31">
|
|
||||||
<g
|
|
||||||
id="g33">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="8.3105459"
|
|
||||||
y="27.548409"
|
|
||||||
id="text3788"
|
|
||||||
sodipodi:linespacing="125%"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3790"
|
|
||||||
x="8.3105459"
|
|
||||||
y="27.548409"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
|
||||||
|
|
||||||
|
<!-- Bottom-right large suit glyph at (178, 286), 64 × 64.
|
||||||
|
Visible bottom-right at (242, 350), visible top-left at
|
||||||
|
(178, 286). Same upright orientation as the top-left small
|
||||||
|
glyph — no 180° rotation applied. -->
|
||||||
<g
|
<g transform="translate(178 286) scale(2)">
|
||||||
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="none" stroke="#e8e8e8" stroke-width="3"/>
|
||||||
id="layer1-1-4"><path
|
</g>
|
||||||
id="cl-9"
|
</svg>
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="-158.86395"
|
|
||||||
y="-214.4666"
|
|
||||||
id="text3788-8"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
transform="scale(-1,-1)"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3790-7"
|
|
||||||
x="-158.86395"
|
|
||||||
y="-214.4666"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<g
|
|
||||||
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
|
||||||
id="layer1-1-4-1"><path
|
|
||||||
id="cl-9-7"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><g
|
|
||||||
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,-1.5311156)"
|
|
||||||
id="layer1-1-4-8"><path
|
|
||||||
id="cl-9-8"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><g
|
|
||||||
transform="matrix(-2.5125778,0,0,-2.5125778,205.12954,245.27515)"
|
|
||||||
id="layer1-1-4-8-0"><path
|
|
||||||
id="cl-9-8-6"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,224 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="0" y="0" width="256" height="384" rx="16" ry="16"
|
||||||
|
fill="#1a1a1a"/>
|
||||||
|
|
||||||
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
<!-- Top-left rank in JetBrains-Mono-styled FiraMono (rank digits
|
||||||
|
and letters render correctly in FiraMono; only the suit glyphs
|
||||||
|
needed to escape to paths). -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#e8e8e8">3</text>
|
||||||
|
|
||||||
<svg
|
<!-- Top-left small suit glyph at (14, 50), 20 × 20.
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
`suit_path_d` is authored in a 32-unit box, so scale 0.625
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
lands the visible glyph at 20 px. -->
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="none" stroke="#e8e8e8" stroke-width="3"/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</g>
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="167.0869141pt"
|
|
||||||
height="242.6669922pt"
|
|
||||||
viewBox="0 0 167.0869141 242.6669922"
|
|
||||||
xml:space="preserve"
|
|
||||||
id="svg2"
|
|
||||||
version="1.1"
|
|
||||||
inkscape:version="0.48.0 r9654"
|
|
||||||
sodipodi:docname="3_of_clubs.svg"
|
|
||||||
inkscape:export-filename="/home/byron/art/cards/final/PNGs/3_of_clubs.png"
|
|
||||||
inkscape:export-xdpi="215.44792"
|
|
||||||
inkscape:export-ydpi="215.44792"><metadata
|
|
||||||
id="metadata43"><rdf:RDF><cc:Work
|
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
|
||||||
id="defs41"><radialGradient
|
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient2984"
|
|
||||||
id="radialGradient3760"
|
|
||||||
cx="48.231091"
|
|
||||||
cy="18.137882"
|
|
||||||
fx="48.231091"
|
|
||||||
fy="18.137882"
|
|
||||||
r="9.5"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
|
||||||
id="linearGradient2984"><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:1;"
|
|
||||||
offset="0"
|
|
||||||
id="stop2986" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0.65648854;"
|
|
||||||
offset="1"
|
|
||||||
id="stop2988" /></linearGradient><radialGradient
|
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient3784"
|
|
||||||
id="radialGradient3792"
|
|
||||||
cx="171.48665"
|
|
||||||
cy="511.22299"
|
|
||||||
fx="171.48665"
|
|
||||||
fy="511.22299"
|
|
||||||
r="81.902771"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
|
||||||
id="linearGradient3784"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788" /></linearGradient><radialGradient
|
|
||||||
r="81.902771"
|
|
||||||
fy="511.22299"
|
|
||||||
fx="171.48665"
|
|
||||||
cy="511.22299"
|
|
||||||
cx="171.48665"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
id="radialGradient3855"
|
|
||||||
xlink:href="#linearGradient3784-4"
|
|
||||||
inkscape:collect="always" /><linearGradient
|
|
||||||
id="linearGradient3784-4"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786-8" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788-6" /></linearGradient><radialGradient
|
|
||||||
r="81.902771"
|
|
||||||
fy="461.84113"
|
|
||||||
fx="181.69392"
|
|
||||||
cy="461.84113"
|
|
||||||
cx="181.69392"
|
|
||||||
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
id="radialGradient3916"
|
|
||||||
xlink:href="#linearGradient3784-3"
|
|
||||||
inkscape:collect="always" /><linearGradient
|
|
||||||
id="linearGradient3784-3"><stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
|
||||||
offset="0"
|
|
||||||
id="stop3786-86" /><stop
|
|
||||||
style="stop-color:#000000;stop-opacity:0;"
|
|
||||||
offset="1"
|
|
||||||
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1"
|
|
||||||
objecttolerance="10"
|
|
||||||
gridtolerance="10"
|
|
||||||
guidetolerance="10"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:window-width="1680"
|
|
||||||
inkscape:window-height="977"
|
|
||||||
id="namedview39"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="2.4336873"
|
|
||||||
inkscape:cx="117.62976"
|
|
||||||
inkscape:cy="148.16686"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="25"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg2" />
|
|
||||||
<g
|
|
||||||
id="Layer_x0020_1"
|
|
||||||
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
|
||||||
<path
|
|
||||||
style="fill:#FFFFFF;stroke-width:0.5;"
|
|
||||||
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
|
||||||
id="path5" />
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g7">
|
|
||||||
<g
|
|
||||||
id="g9">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g15">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="g19">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g23">
|
|
||||||
<g
|
|
||||||
id="g25">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
style="stroke:none;"
|
|
||||||
id="g31">
|
|
||||||
<g
|
|
||||||
id="g33">
|
|
||||||
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="8.3105459"
|
|
||||||
y="27.548409"
|
|
||||||
id="text3788"
|
|
||||||
sodipodi:linespacing="125%"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3790"
|
|
||||||
x="8.3105459"
|
|
||||||
y="27.548409"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
|
||||||
|
|
||||||
|
<!-- Bottom-right large suit glyph at (178, 286), 64 × 64.
|
||||||
|
Visible bottom-right at (242, 350), visible top-left at
|
||||||
|
(178, 286). Same upright orientation as the top-left small
|
||||||
|
glyph — no 180° rotation applied. -->
|
||||||
|
<g transform="translate(178 286) scale(2)">
|
||||||
<g
|
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="none" stroke="#e8e8e8" stroke-width="3"/>
|
||||||
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
</g>
|
||||||
id="layer1-1-4"><path
|
</svg>
|
||||||
id="cl-9"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
|
||||||
x="-158.86395"
|
|
||||||
y="-214.4666"
|
|
||||||
id="text3788-8"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
transform="scale(-1,-1)"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan3790-7"
|
|
||||||
x="-158.86395"
|
|
||||||
y="-214.4666"
|
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<g
|
|
||||||
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
|
||||||
id="layer1-1-4-1"><path
|
|
||||||
id="cl-9-7"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><g
|
|
||||||
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,-9.5311159)"
|
|
||||||
id="layer1-1-4-8"><path
|
|
||||||
id="cl-9-8"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><g
|
|
||||||
transform="matrix(-2.5125778,0,0,-2.5125778,205.12954,253.27515)"
|
|
||||||
id="layer1-1-4-8-0"><path
|
|
||||||
id="cl-9-8-6"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g><g
|
|
||||||
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,60.169684)"
|
|
||||||
id="layer1-1-4-8-2"><path
|
|
||||||
id="cl-9-8-0"
|
|
||||||
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#000000" /></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 1.5 KiB |