Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6907671be | |||
| a54fff7257 | |||
| 533bcec2d8 | |||
| ba786f5a09 | |||
| 7ee7cb6d93 | |||
| 14324b09ef | |||
| 124f1f5cf5 | |||
| a6a73b5f36 | |||
| b84fe79806 | |||
| 3248f00d66 | |||
| c680a043ae | |||
| d0ab7ed97b | |||
| 1144a96757 | |||
| ac6668cee7 | |||
| eba1f66b45 | |||
| 90959728b1 | |||
| 8b30f8778b | |||
| d6a7924f14 | |||
| 4db43fb3fb | |||
| 01d6b27e61 | |||
| 3cffbc2c51 | |||
| 2ef25934ac | |||
| bb670d6cc6 | |||
| 76911c57c9 | |||
| 8391235a1a | |||
| 2f3a6b9586 | |||
| 4d20b70809 | |||
| bfadcf0e0d | |||
| 356dbebe57 | |||
| c90c783177 | |||
| bbf4b2c14a | |||
| 62be72e918 | |||
| 1f46785b31 | |||
| 2e5d82f83c | |||
| 396ba6bc97 | |||
| 88298206bb | |||
| 0f65031114 | |||
| c91ce9436e | |||
| ace96b4a47 | |||
| ea079af9e1 | |||
| c66d81c73a | |||
| 20b7a617e0 | |||
| 7a0d57b2b1 | |||
| 93ec4a7478 | |||
| 72dfd741c4 | |||
| 3837a10b15 | |||
| 574115cb71 | |||
| 1707553790 | |||
| 6905f26b56 | |||
| 1b7c4d92aa | |||
| d685224ce6 | |||
| 539779d78b | |||
| f6506c57e5 | |||
| b88f3df119 | |||
| 0dcb783e94 | |||
| ea17f94b6c | |||
| d60dc18add | |||
| 38eefb22e8 | |||
| a579c25d5c | |||
| c40817d845 | |||
| c6c03b8bff | |||
| 5b3925a619 | |||
| 8485b3d1e0 | |||
| 8325bf6cf7 | |||
| ea58f5dd64 | |||
| c518255a2d | |||
| f5da9398f2 | |||
| b82573e7b1 | |||
| 40818f5bd2 | |||
| 228ebbad8a | |||
| 2b33feafc9 | |||
| f8c8c9158e | |||
| 9cc0837088 | |||
| b47462bd27 | |||
| 08d22c822a | |||
| feb581005c | |||
| 00f2d890f1 | |||
| 9533a7d420 | |||
| 5ec5ac1a19 | |||
| 86aea206b8 | |||
| 1bd1c0f927 | |||
| 7be7f4395c | |||
| 66c2907c25 | |||
| c2811fa661 | |||
| 933cc55ea9 | |||
| 58faae1911 | |||
| 96be1b85fb | |||
| bbf7709912 | |||
| 9983b873f9 | |||
| 079349dc0f | |||
| 8f82b9fcb5 | |||
| 0ebe87a411 | |||
| 1e6d153cd0 | |||
| af5ac68947 | |||
| 859b69b3c5 | |||
| 24ab25b0b7 | |||
| 918d83420b | |||
| a381a42f21 | |||
| 04f3dab563 | |||
| d204662415 | |||
| 4f0080dfbc | |||
| 46c3bf4bb2 | |||
| 6beb9f68ac | |||
| a0081a251c | |||
| 7411468e10 | |||
| 9af4046ac3 | |||
| d06af28aef | |||
| 27b58a5b71 | |||
| 3b6c8d2aab | |||
| 51fc8f65b1 | |||
| 65cb41461f | |||
| 24f5d140df | |||
| 03be4fcc67 | |||
| 9564f54fc0 | |||
| b4ada2a07e | |||
| d44cedbea0 | |||
| 75146847f6 | |||
| 566b112d9e | |||
| 198df75f94 | |||
| 40d07122ba | |||
| 08f74d1e25 | |||
| 6e6f3ef1ff | |||
| 549a817bb1 | |||
| 613bbf8799 | |||
| b129664344 | |||
| 7d7c83ab28 | |||
| bd388fef26 | |||
| 272d31f851 | |||
| 6ce55646d8 | |||
| 432061c3ec | |||
| 22303c62ff | |||
| b1731fe68a | |||
| 2b01f741b4 | |||
| 3110702c74 | |||
| 33fb9627a8 | |||
| 4398403418 | |||
| 002d96f2c8 | |||
| cc161cc37f | |||
| 8a3e30bd16 | |||
| 2a206b994c | |||
| ae7c6c97f1 | |||
| 016fb7214d | |||
| 948864e653 | |||
| 76a754d8e5 | |||
| 9fb59c7d47 | |||
| d714a11cfb | |||
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae | |||
| a5c3188686 | |||
| 6a289b7b50 | |||
| bee712c5ab | |||
| 0db5e9dac4 | |||
| 681a54d9bb | |||
| 7894559ca7 | |||
| ab803c07af | |||
| e43b329fc1 | |||
| 7c07f71f02 | |||
| c1329bbb21 | |||
| 4303ef3f5b | |||
| 4df962ee07 | |||
| f281425b45 | |||
| 2c822ba2d7 | |||
| 7ddf2733c9 | |||
| 585570559c | |||
| 45436d0eda | |||
| 2062bd06f3 | |||
| 0cb15872b1 | |||
| 395a322adc | |||
| 5199a5e499 | |||
| 16242e6d77 | |||
| 202a64db45 | |||
| c0415eb0ee | |||
| a449f60bc5 | |||
| ad5f613277 | |||
| c50eaf81f7 | |||
| b44d2777ec | |||
| 52407e7256 | |||
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 | |||
| a2432dfe7a | |||
| 511550232c | |||
| e5c4f51a6e | |||
| 23902cdc44 | |||
| 3cc8eacafa | |||
| 90e24d9711 | |||
| decbe0bbd9 | |||
| 1873b3f9be | |||
| d11d97e677 | |||
| d322abf67b | |||
| c9e4c0b4cd | |||
| fe68861e10 | |||
| c33b39cf11 |
@@ -0,0 +1,131 @@
|
|||||||
|
name: Android Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
# Rebuild whenever app/engine/asset code changes.
|
||||||
|
# Skip server-only, deploy, and doc changes.
|
||||||
|
paths-ignore:
|
||||||
|
- 'deploy/**'
|
||||||
|
- 'argocd/**'
|
||||||
|
- 'solitaire_server/**'
|
||||||
|
- '**.md'
|
||||||
|
|
||||||
|
env:
|
||||||
|
ANDROID_SDK: /opt/android-sdk
|
||||||
|
NDK_VERSION: "25.2.9519653"
|
||||||
|
BUILD_TOOLS_VERSION: "34.0.0"
|
||||||
|
PLATFORM: "android-34"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-apk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set short SHA
|
||||||
|
id: meta
|
||||||
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ── System dependencies ────────────────────────────────────────────
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y openjdk-17-jdk-headless unzip zip
|
||||||
|
|
||||||
|
# ── Android SDK (shared cache key with release workflow) ──────────
|
||||||
|
- name: Cache Android SDK
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: sdk-cache
|
||||||
|
with:
|
||||||
|
path: ${{ env.ANDROID_SDK }}
|
||||||
|
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
|
||||||
|
|
||||||
|
- name: Install Android SDK + NDK
|
||||||
|
if: steps.sdk-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
|
||||||
|
curl -sL \
|
||||||
|
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
|
||||||
|
-o /tmp/cmdtools.zip
|
||||||
|
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
|
||||||
|
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
|
||||||
|
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||||
|
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
|
||||||
|
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||||
|
--sdk_root=${{ env.ANDROID_SDK }} \
|
||||||
|
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
|
||||||
|
"platforms;${{ env.PLATFORM }}" \
|
||||||
|
"ndk;${{ env.NDK_VERSION }}"
|
||||||
|
|
||||||
|
# ── Rust toolchain ─────────────────────────────────────────────────
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --default-toolchain stable --no-modify-path
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Add Android cross-compilation targets
|
||||||
|
run: |
|
||||||
|
rustup target add \
|
||||||
|
aarch64-linux-android \
|
||||||
|
armv7-linux-androideabi \
|
||||||
|
x86_64-linux-android
|
||||||
|
|
||||||
|
# ── Cargo caches ───────────────────────────────────────────────────
|
||||||
|
- name: Cache Cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo-ndk binary
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: ndk-tool-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-ndk
|
||||||
|
key: cargo-ndk-${{ runner.os }}-stable
|
||||||
|
|
||||||
|
- name: Cache build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: android-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
|
||||||
|
restore-keys: android-target-${{ hashFiles('**/Cargo.lock') }}-
|
||||||
|
|
||||||
|
- name: Install cargo-ndk
|
||||||
|
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install cargo-ndk --locked
|
||||||
|
|
||||||
|
# ── Build APK ──────────────────────────────────────────────────────
|
||||||
|
# Debug CI only builds arm64-v8a — full three-ABI debug builds blow
|
||||||
|
# past the runner's disk budget (~25 GB of target/ + intermediate
|
||||||
|
# APKs caused apksigner to OOM-on-disk in the previous run). Release
|
||||||
|
# CI still ships all three ABIs from android-release.yml.
|
||||||
|
- name: Build debug APK
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: ${{ env.ANDROID_SDK }}
|
||||||
|
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
|
||||||
|
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
|
||||||
|
PLATFORM: ${{ env.PLATFORM }}
|
||||||
|
PROFILE: debug
|
||||||
|
ABIS: arm64-v8a
|
||||||
|
run: ./scripts/build_android_apk.sh
|
||||||
|
|
||||||
|
# ── Artifact ───────────────────────────────────────────────────────
|
||||||
|
# Pinned to v3 because Gitea Actions doesn't implement the github.com
|
||||||
|
# artifact service that upload-artifact@v4+ requires; v3 uses the
|
||||||
|
# older chunked HTTP API that Gitea's GHES-compatibility layer
|
||||||
|
# supports.
|
||||||
|
- name: Upload APK
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: solitaire-quest-debug-${{ steps.meta.outputs.sha }}
|
||||||
|
path: target/debug/apk/solitaire-quest.apk
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
ANDROID_SDK: /opt/android-sdk
|
||||||
|
NDK_VERSION: "25.2.9519653"
|
||||||
|
BUILD_TOOLS_VERSION: "34.0.0"
|
||||||
|
PLATFORM: "android-34"
|
||||||
|
GITEA_API: https://git.aleshym.co/api/v1
|
||||||
|
REPO: funman300/Rusty_Solitare
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-release-apk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ── System dependencies ────────────────────────────────────────────
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y openjdk-17-jdk-headless unzip zip jq
|
||||||
|
|
||||||
|
# ── Android SDK (shared cache key with debug workflow) ─────────────
|
||||||
|
- name: Cache Android SDK
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: sdk-cache
|
||||||
|
with:
|
||||||
|
path: ${{ env.ANDROID_SDK }}
|
||||||
|
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
|
||||||
|
|
||||||
|
- name: Install Android SDK + NDK
|
||||||
|
if: steps.sdk-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
|
||||||
|
curl -sL \
|
||||||
|
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
|
||||||
|
-o /tmp/cmdtools.zip
|
||||||
|
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
|
||||||
|
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
|
||||||
|
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||||
|
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
|
||||||
|
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||||
|
--sdk_root=${{ env.ANDROID_SDK }} \
|
||||||
|
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
|
||||||
|
"platforms;${{ env.PLATFORM }}" \
|
||||||
|
"ndk;${{ env.NDK_VERSION }}"
|
||||||
|
|
||||||
|
# ── Rust toolchain ─────────────────────────────────────────────────
|
||||||
|
- name: Install Rust stable
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --default-toolchain stable --no-modify-path
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Add Android cross-compilation targets
|
||||||
|
run: |
|
||||||
|
rustup target add \
|
||||||
|
aarch64-linux-android \
|
||||||
|
armv7-linux-androideabi \
|
||||||
|
x86_64-linux-android
|
||||||
|
|
||||||
|
# ── Cargo caches ───────────────────────────────────────────────────
|
||||||
|
- name: Cache Cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo-ndk binary
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: ndk-tool-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-ndk
|
||||||
|
key: cargo-ndk-${{ runner.os }}-stable
|
||||||
|
|
||||||
|
- name: Cache build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: android-release-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
|
||||||
|
restore-keys: android-release-target-${{ hashFiles('**/Cargo.lock') }}-
|
||||||
|
|
||||||
|
- name: Install cargo-ndk
|
||||||
|
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install cargo-ndk --locked
|
||||||
|
|
||||||
|
# ── Build & sign with release keystore ─────────────────────────────
|
||||||
|
- name: Decode keystore
|
||||||
|
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/solitaire-release.jks
|
||||||
|
|
||||||
|
- name: Build signed release APK
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: ${{ env.ANDROID_SDK }}
|
||||||
|
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
|
||||||
|
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
|
||||||
|
PLATFORM: ${{ env.PLATFORM }}
|
||||||
|
PROFILE: release
|
||||||
|
KEYSTORE: /tmp/solitaire-release.jks
|
||||||
|
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
KEY_PASS: ${{ secrets.KEY_PASS }}
|
||||||
|
APK_OUT: ferrous-solitaire-${{ steps.meta.outputs.tag }}.apk
|
||||||
|
run: ./scripts/build_android_apk.sh
|
||||||
|
|
||||||
|
# ── Publish to Gitea release ───────────────────────────────────────
|
||||||
|
- name: Create Gitea release
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
RESPONSE=$(curl -s -o /tmp/release.json -w "%{http_code}" \
|
||||||
|
-X POST "$GITEA_API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}")
|
||||||
|
if [ "$RESPONSE" = "409" ]; then
|
||||||
|
curl -sf "$GITEA_API/repos/$REPO/releases/tags/$TAG" \
|
||||||
|
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
||||||
|
> /tmp/release.json
|
||||||
|
elif [ "$RESPONSE" != "201" ]; then
|
||||||
|
echo "Release creation failed with HTTP $RESPONSE"
|
||||||
|
cat /tmp/release.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
|
||||||
|
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload signed APK
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
APK="ferrous-solitaire-${TAG}.apk"
|
||||||
|
curl -sf -X POST \
|
||||||
|
"$GITEA_API/repos/$REPO/releases/${{ steps.release.outputs.release_id }}/assets?name=$APK" \
|
||||||
|
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$APK"
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
# Only run when server code changes, not when CI itself updates deploy/.
|
||||||
|
paths-ignore:
|
||||||
|
- 'deploy/**'
|
||||||
|
- 'argocd/**'
|
||||||
|
- '**.md'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.aleshym.co
|
||||||
|
IMAGE: git.aleshym.co/funman300/solitaire-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Need full history so we can push the tag-update commit back.
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set image tag
|
||||||
|
id: meta
|
||||||
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: solitaire_server/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
|
||||||
|
${{ env.IMAGE }}:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Install kustomize
|
||||||
|
run: |
|
||||||
|
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||||
|
sudo mv kustomize /usr/local/bin/kustomize
|
||||||
|
|
||||||
|
- name: Pin image tag in deploy manifests
|
||||||
|
run: |
|
||||||
|
cd deploy
|
||||||
|
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
|
||||||
|
|
||||||
|
- name: Commit and push updated kustomization
|
||||||
|
run: |
|
||||||
|
git config user.email "ci@gitea.local"
|
||||||
|
git config user.name "Gitea CI"
|
||||||
|
git add deploy/kustomization.yaml
|
||||||
|
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
|
||||||
|
git pull --rebase origin master
|
||||||
|
git push
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: "-D warnings"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Test & Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: clippy
|
|
||||||
|
|
||||||
- name: Install Linux audio/display dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libasound2-dev \
|
|
||||||
libudev-dev \
|
|
||||||
libwayland-dev \
|
|
||||||
libxkbcommon-dev
|
|
||||||
|
|
||||||
- name: Cache cargo registry and build artifacts
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: Clippy (all crates, zero warnings)
|
|
||||||
run: cargo clippy --workspace -- -D warnings
|
|
||||||
|
|
||||||
- name: Test (headless crates only — no display required)
|
|
||||||
run: |
|
|
||||||
cargo test -p solitaire_core
|
|
||||||
cargo test -p solitaire_sync
|
|
||||||
cargo test -p solitaire_data
|
|
||||||
cargo test -p solitaire_server
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Release Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Install Linux audio/display dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libasound2-dev \
|
|
||||||
libudev-dev \
|
|
||||||
libwayland-dev \
|
|
||||||
libxkbcommon-dev
|
|
||||||
|
|
||||||
- name: Cache cargo registry and build artifacts
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-release-
|
|
||||||
|
|
||||||
- name: Build release binaries
|
|
||||||
run: cargo build --workspace --release
|
|
||||||
@@ -7,3 +7,17 @@
|
|||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# IDE project files
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Android signing keystores — never commit
|
||||||
|
*.jks
|
||||||
|
*.jks.bak
|
||||||
|
*.jks.bak*
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Kubernetes secrets — apply manually, never commit
|
||||||
|
deploy/matomo-secret.yaml
|
||||||
|
deploy/*-secret.yaml
|
||||||
|
deploy/*-auth-secret.yaml
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 7
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
|
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -34,5 +34,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
|
"hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "jti",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 7
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Solitaire Quest — Architecture Document
|
# Ferrous Solitaire — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.1
|
> **Version:** 1.3
|
||||||
> **Language:** Rust (Edition 2024)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-04-29
|
> **Last Updated:** 2026-05-12
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
## 1. Project Overview
|
## 1. Project Overview
|
||||||
|
|
||||||
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
||||||
|
|
||||||
### Sync Backend by Platform
|
### Sync Backend by Platform
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
|||||||
| macOS | Self-hosted server | Full feature set |
|
| macOS | Self-hosted server | Full feature set |
|
||||||
| Windows | Self-hosted server | Full feature set |
|
| Windows | Self-hosted server | Full feature set |
|
||||||
| Linux | Self-hosted server | Full feature set |
|
| Linux | Self-hosted server | Full feature set |
|
||||||
|
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
|
||||||
|
|
||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ solitaire_quest/
|
|||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||||
|
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
||||||
└── solitaire_app/ # Main binary entry point
|
└── solitaire_app/ # Main binary entry point
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,6 +162,20 @@ Owns:
|
|||||||
- Daily challenge seed generation
|
- Daily challenge seed generation
|
||||||
- Leaderboard management
|
- Leaderboard management
|
||||||
|
|
||||||
|
### `solitaire_wasm`
|
||||||
|
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||||
|
|
||||||
|
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||||
|
|
||||||
|
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
|
||||||
|
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
|
||||||
|
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
|
||||||
|
|
||||||
|
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||||
|
|
||||||
### `solitaire_app`
|
### `solitaire_app`
|
||||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
**Dependencies:** `bevy`, `solitaire_engine`.
|
||||||
|
|
||||||
@@ -261,6 +277,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
|
|||||||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||||||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||||||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||||||
|
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
|
||||||
|
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
|
||||||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||||||
| `HelpPlugin` | H | Help / controls overlay |
|
| `HelpPlugin` | H | Help / controls overlay |
|
||||||
| `PausePlugin` | Esc | Pause and resume |
|
| `PausePlugin` | Esc | Pause and resume |
|
||||||
@@ -305,6 +323,12 @@ struct FontResource(Handle<Font>);
|
|||||||
struct BackgroundImageSet {
|
struct BackgroundImageSet {
|
||||||
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OS-reserved edge insets (physical px); zero on desktop
|
||||||
|
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
|
||||||
|
|
||||||
|
// Whether the HUD band is visible (auto-hide chrome feature)
|
||||||
|
enum HudVisibility { Visible, Hidden }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Bevy Events
|
### Key Bevy Events
|
||||||
@@ -365,10 +389,22 @@ All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin`
|
|||||||
```rust
|
```rust
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SyncProvider: Send + Sync {
|
pub trait SyncProvider: Send + Sync {
|
||||||
|
// Required — must be implemented by every backend:
|
||||||
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
||||||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
||||||
fn backend_name(&self) -> &'static str;
|
fn backend_name(&self) -> &'static str;
|
||||||
fn is_authenticated(&self) -> bool;
|
fn is_authenticated(&self) -> bool;
|
||||||
|
|
||||||
|
// Optional — all have default no-op / empty implementations:
|
||||||
|
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
|
||||||
|
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
|
||||||
|
async fn delete_account(&self) -> Result<(), SyncError>;
|
||||||
|
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
|
||||||
|
// so LocalOnlyProvider silently no-ops the push-on-win path.
|
||||||
|
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -454,6 +490,24 @@ CREATE TABLE leaderboard (
|
|||||||
recorded_at TEXT NOT NULL,
|
recorded_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id)
|
PRIMARY KEY (user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- migrations/002_replays.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS replays (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
seed INTEGER NOT NULL,
|
||||||
|
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||||||
|
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||||||
|
time_seconds INTEGER NOT NULL,
|
||||||
|
final_score INTEGER NOT NULL,
|
||||||
|
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||||||
|
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||||||
|
replay_json TEXT NOT NULL -- full Replay serialisation
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Request Lifecycle
|
### Request Lifecycle
|
||||||
@@ -579,12 +633,25 @@ pub struct AchievementRecord {
|
|||||||
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
pub sfx_volume: f32, // 0.0–1.0
|
pub sfx_volume: f32, // 0.0–1.0
|
||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||||
|
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
|
||||||
|
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
|
||||||
pub first_run_complete: bool,
|
pub first_run_complete: bool,
|
||||||
|
pub color_blind_mode: bool, // blue tint on red suits
|
||||||
|
pub high_contrast_mode: bool, // boosted luminance for low-vision users
|
||||||
|
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
|
||||||
|
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WindowGeometry {
|
||||||
|
pub width: u32, // logical pixels
|
||||||
|
pub height: u32,
|
||||||
|
pub x: i32, // physical pixels, top-left corner
|
||||||
|
pub y: i32,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -600,7 +667,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||||
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||||
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token}` |
|
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
|
||||||
|
|
||||||
### Sync
|
### Sync
|
||||||
|
|
||||||
@@ -617,6 +684,21 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
||||||
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
||||||
|
|
||||||
|
### Replays
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
|
||||||
|
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
|
||||||
|
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
|
||||||
|
|
||||||
|
### Web Replay Player
|
||||||
|
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||||
|
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||||
|
|
||||||
### Account Management
|
### Account Management
|
||||||
|
|
||||||
| Method | Path | Auth | Body | Response |
|
| Method | Path | Auth | Body | Response |
|
||||||
@@ -825,7 +907,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
|
|||||||
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
||||||
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
||||||
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
||||||
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
|
||||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||||
|
|
||||||
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
||||||
@@ -945,6 +1027,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
|||||||
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
||||||
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
||||||
| Token expiry | Access: 24h, Refresh: 30d |
|
| Token expiry | Access: 24h, Refresh: 30d |
|
||||||
|
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
|
||||||
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
||||||
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
||||||
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
||||||
|
|||||||
@@ -1,13 +1,577 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Solitaire Quest are documented here. The format is
|
All notable changes to Ferrous Solitaire are documented here. The format is
|
||||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||||
project follows [Semantic Versioning](https://semver.org/).
|
project follows [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
No threads in flight. v0.21.4 cut on 2026-05-08; CHANGELOG accumulates
|
### Fixed
|
||||||
the next cycle here.
|
|
||||||
|
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
|
||||||
|
now checks `scrims.is_empty()` — a `Query<(), With<ModalScrim>>` guard —
|
||||||
|
before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats,
|
||||||
|
Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile
|
||||||
|
could be open simultaneously.
|
||||||
|
- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu
|
||||||
|
button description "Menu: Stats, Settings, Profile, Achievements" wrapped to
|
||||||
|
two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)"
|
||||||
|
which fits on one line. Verified on device.
|
||||||
|
- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons
|
||||||
|
were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono
|
||||||
|
font — rendered as missing-glyph rectangles on Android. Replaced with card
|
||||||
|
suits (U+2660–2666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
|
||||||
|
- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New
|
||||||
|
`apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom
|
||||||
|
by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over
|
||||||
|
the safe area, not the full physical screen. The Settings / Help / Stats Done
|
||||||
|
buttons are reachable on gesture-nav Android devices. Verified on device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.23.0] — 2026-05-12
|
||||||
|
|
||||||
|
Phase 8 sync UI: the self-hosted-server connection flow is now fully
|
||||||
|
playable end-to-end. Players can open a Connect modal from Settings,
|
||||||
|
enter a server URL + credentials, log in or register, and see the
|
||||||
|
sync-status section update live. Token expiry auto-reopens the modal.
|
||||||
|
Account deletion ships a two-click destroy flow. Server deployment
|
||||||
|
artifacts (Dockerfile + docker-compose) let self-hosters spin up in one
|
||||||
|
command.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sync setup modal — Connect / Disconnect flow** (`432061c`).
|
||||||
|
New `SyncSetupPlugin` (`solitaire_engine/src/sync_setup_plugin.rs`)
|
||||||
|
provides the full server-connection UI. Three tab-stopped text fields
|
||||||
|
(URL, Username, Password) handle keyboard input via `MessageReader<KeyboardInput>`
|
||||||
|
with focus cycling on Tab. "Log In" and "Register" buttons each spawn an
|
||||||
|
async `AsyncComputeTaskPool` task that calls the new
|
||||||
|
`SolitaireServerClient::login()` / `::register()` methods; `poll_auth_task`
|
||||||
|
harvests the result, stores tokens via `store_tokens()`, hot-swaps
|
||||||
|
`SyncProviderResource` to the new server backend, fires
|
||||||
|
`ManualSyncRequestEvent` to pull immediately, and closes the modal.
|
||||||
|
An inline `SyncAuthError` label displays credential errors without a
|
||||||
|
toast. The modal is idempotent (`existing.is_empty()` guard) — safe
|
||||||
|
to open programmatically.
|
||||||
|
- **`SyncConfigureRequestEvent`, `SyncLogoutRequestEvent`,
|
||||||
|
`DeleteAccountRequestEvent`** (`432061c`). Three new engine events
|
||||||
|
wire the Settings buttons → plugin handlers. `SyncConfigureRequestEvent`
|
||||||
|
opens the setup modal; `SyncLogoutRequestEvent` disconnects and resets
|
||||||
|
`SyncProviderResource` to `LocalOnlyProvider`; `DeleteAccountRequestEvent`
|
||||||
|
opens the deletion confirmation modal.
|
||||||
|
- **Settings sync section — dynamic backend UI** (`432061c`).
|
||||||
|
`sync_row()` in `SettingsPlugin` now takes `backend: &SyncBackend` and
|
||||||
|
renders conditionally: `Local` → "Connect" button; `SolitaireServer` →
|
||||||
|
username label + "Sync Now" + "Disconnect" + "Delete Account". Three new
|
||||||
|
`SettingsButton` discriminants (`ConnectSync` tab 91, `DisconnectSync`
|
||||||
|
tab 92, `DeleteAccount` tab 93) feed into a new `handle_sync_buttons`
|
||||||
|
system extracted from `handle_settings_buttons` to stay within Bevy's
|
||||||
|
16-parameter system limit.
|
||||||
|
- **`SolitaireServerClient::login()` and `::register()`** (`432061c`).
|
||||||
|
Both POST to `/api/auth/login` and `/api/auth/register` respectively.
|
||||||
|
Private helper `extract_auth_tokens` parses `{ access_token, refresh_token }`.
|
||||||
|
409 CONFLICT → "username already taken"; 401/403 → "invalid credentials";
|
||||||
|
400 → server message echoed to the player.
|
||||||
|
- **Re-auth prompt on token expiry** (`6ce5564`).
|
||||||
|
`poll_pull_result` in `SyncPlugin` now fires `InfoToastEvent("Session
|
||||||
|
expired — please reconnect")` + `SyncConfigureRequestEvent` when the
|
||||||
|
pull task resolves to `SyncError::Auth(_)`. Because the modal is
|
||||||
|
idempotent the re-open is safe to trigger from any system path.
|
||||||
|
- **Server deployment artifacts** (`6ce5564`).
|
||||||
|
`solitaire_server/Dockerfile`: multi-stage build (`rust:1.95-slim` →
|
||||||
|
`debian:bookworm-slim`); copies `.sqlx` offline cache so `SQLX_OFFLINE=true`
|
||||||
|
succeeds without a live database at build time; exposes port 8080.
|
||||||
|
`solitaire_server/docker-compose.yml`: single-service compose file;
|
||||||
|
`db-data` volume at `/app/data`; `DATABASE_URL` and `JWT_SECRET` from
|
||||||
|
environment; HTTP health-check via `wget`. `solitaire_server/.env.example`:
|
||||||
|
documents all required variables with generation hint (`openssl rand -hex 32`).
|
||||||
|
- **Account deletion flow** (`272d31f`).
|
||||||
|
"Delete Account" in Settings fires `DeleteAccountRequestEvent` →
|
||||||
|
`SyncSetupPlugin::open_delete_confirm_modal` spawns a danger-red
|
||||||
|
confirmation modal with "Cancel" and "Delete Forever" buttons.
|
||||||
|
"Delete Forever" submits an async `PendingDeleteTask` that calls
|
||||||
|
`SyncProvider::delete_account()`; `poll_delete_task` on Ok fires
|
||||||
|
`SyncLogoutRequestEvent` + a success toast; on Err shows an error toast
|
||||||
|
and leaves the modal open. Two-click destroy pattern — no accidental
|
||||||
|
account deletion possible.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **`SyncAuthResultEvent`** (`432061c`). Defined but never emitted or
|
||||||
|
consumed; removed as dead code.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: **1300+ passing** / 0 failing
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_data` (sync_client), `solitaire_engine`
|
||||||
|
(events, settings_plugin, sync_plugin, sync_setup_plugin [new], lib),
|
||||||
|
`solitaire_app` (lib.rs), `solitaire_server` (Dockerfile,
|
||||||
|
docker-compose.yml, .env.example [new])
|
||||||
|
|
||||||
|
## [0.22.0] — 2026-05-08
|
||||||
|
|
||||||
|
Adds difficulty-tier game selection, Android JNI bridges for keystore and
|
||||||
|
clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens.
|
||||||
|
Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Difficulty-tier game mode** (this release).
|
||||||
|
`DifficultyLevel` enum (`Easy / Medium / Hard / Expert / Grandmaster /
|
||||||
|
Random`) added to `solitaire_core::game_state` alongside a new
|
||||||
|
`GameMode::Difficulty(DifficultyLevel)` variant. Five pre-verified seed
|
||||||
|
catalogs (40 seeds each, 200 total) are generated by the new
|
||||||
|
`gen_difficulty_seeds` binary in `solitaire_assetgen`; each catalog
|
||||||
|
contains seeds proven winnable at progressively larger solver budgets
|
||||||
|
(1 K → 200 K moves). `DifficultyPlugin` resolves `StartDifficultyRequestEvent`
|
||||||
|
→ catalog seed → `NewGameRequestEvent`; the `Random` tier uses a
|
||||||
|
system-time seed and intentionally bypasses the winnable-only filter.
|
||||||
|
The home overlay gains an expandable `▶ Difficulty` section between the
|
||||||
|
Draw Mode row and the mode-card grid; the last-played tier is persisted
|
||||||
|
in `Settings::last_difficulty` and pre-expands/highlights on re-open.
|
||||||
|
Difficulty wins pool into Classic stats (no separate buckets).
|
||||||
|
- **Prev/Next replay selector in the Stats overlay** (`a449f60`).
|
||||||
|
`ReplayPrevButton`, `ReplayNextButton`, `ReplaySelectorCaption`, and
|
||||||
|
`ReplaySelectorDetail` nodes now spawn inside `spawn_stats_screen`
|
||||||
|
as a flex row of two bordered chips flanking a `"Replay N / M"`
|
||||||
|
caption, with a detail line below showing the selected replay's
|
||||||
|
duration + date and an optional `"· Shareable"` badge. Both chips
|
||||||
|
carry `ModalButton(Secondary)` so the existing `repaint_modal_buttons`
|
||||||
|
paint loop gives them hover/press feedback at zero extra cost.
|
||||||
|
`repaint_replay_selector_detail` is wired into the existing
|
||||||
|
`.chain()` alongside `handle_replay_selector_buttons` and
|
||||||
|
`repaint_replay_selector_caption`. The click handler and repaint
|
||||||
|
systems have been registered (and dormant) since v0.19.0; this
|
||||||
|
commit is purely the missing spawn site.
|
||||||
|
- **6 new selector unit tests** (`a449f60`). Covers: spawn-site
|
||||||
|
presence (Prev, Next, Caption, Detail all spawn with the screen),
|
||||||
|
caption initial text ("Replay 1 / 1"), detail initial text
|
||||||
|
("{dur} win on {date}"), Shareable badge when `share_url` is set,
|
||||||
|
empty-history "No replays" caption, and ordinal wrapping.
|
||||||
|
`make_test_replay(time_seconds, share_url)` helper encapsulates
|
||||||
|
`Replay::new(...)` + `chrono::NaiveDate`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`const { assert!() }` for dim-layer z-order test** (`a449f60`).
|
||||||
|
Converted `assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)` in
|
||||||
|
`replay_overlay` tests to `const { assert!(…) }` to satisfy
|
||||||
|
`clippy::assertions_on_constants` (constant-fold at compile time
|
||||||
|
rather than a runtime no-op).
|
||||||
|
|
||||||
|
### Added (post-cut, same pending release)
|
||||||
|
|
||||||
|
- **Double-tap auto-move on touch screens** (`395a322`).
|
||||||
|
`handle_double_tap` fires `MoveRequestEvent` (single card to
|
||||||
|
foundation/tableau, or a whole face-up stack via
|
||||||
|
`best_tableau_destination_for_stack`) when two `TouchPhase::Ended`
|
||||||
|
events on the same card arrive within `DOUBLE_TAP_WINDOW` (0.5 s,
|
||||||
|
slightly wider than the mouse `DOUBLE_CLICK_WINDOW` to account for
|
||||||
|
touch latency). If no legal destination exists, fires
|
||||||
|
`MoveRejectedEvent` (audio + visual rejection feedback). The system
|
||||||
|
is inserted into the touch drag chain immediately before
|
||||||
|
`touch_end_drag` so `DragState.active_touch_id` and `committed` are
|
||||||
|
still readable; the tap timestamp is tracked in a `Local<HashMap<u32,
|
||||||
|
f32>>` keyed by card ID.
|
||||||
|
- **Play-by-Seed dialog** (`0cb1587`).
|
||||||
|
`PlayBySeedPlugin` adds a numeric-input modal that accepts a decimal
|
||||||
|
seed, runs a solver preview in the background (debounced 500 ms via
|
||||||
|
`AsyncComputeTaskPool`), and shows a win/no-win verdict before
|
||||||
|
dealing. A new `HomeMode::PlayBySeed` card in the home overlay fires
|
||||||
|
`StartPlayBySeedRequestEvent`; the handler in `PlayBySeedPlugin`
|
||||||
|
spawns the dialog. Digit, Backspace, Enter (confirm), and Escape
|
||||||
|
(cancel) are handled via `ButtonInput<KeyCode>`. Five unit tests
|
||||||
|
cover spawn, digit append, buffer read, confirm, and cancel paths.
|
||||||
|
- **75 new challenge seeds** (`2062bd0`).
|
||||||
|
New `gen_seeds` binary in `solitaire_assetgen` brute-searches seeds
|
||||||
|
in the `0xCAFEBABE…` namespace and filters for hands solvable in
|
||||||
|
≤250 moves via the core solver. The 75 confirmed-win seeds are
|
||||||
|
appended to `CHALLENGE_SEEDS` in `solitaire_data::challenge`.
|
||||||
|
|
||||||
|
### Fixed (post-cut, same pending release)
|
||||||
|
|
||||||
|
- **Gate `handle_fullscreen` to non-Android** (`45436d0`).
|
||||||
|
F11 fullscreen toggle makes no sense on Android (the OS owns window
|
||||||
|
sizing); the fn and its `MonitorSelection`/`WindowMode` imports are
|
||||||
|
now `#[cfg(not(target_os = "android"))]`-gated. The `add_systems`
|
||||||
|
call is extracted as a separate statement so `#[cfg]` can annotate it
|
||||||
|
(attributes cannot appear mid-chain in Rust).
|
||||||
|
- **Android APK launch: export `android_main`** (`202a64d`).
|
||||||
|
`NativeActivity` dlopen-s `libsolitaire_app.so` and calls
|
||||||
|
`android_main` as its entry point. Without the symbol the app
|
||||||
|
crashed immediately with `UnsatisfiedLinkError`. The new function
|
||||||
|
sets `bevy::android::ANDROID_APP` (required by `WinitPlugin`) then
|
||||||
|
delegates to `run()` — equivalent to what `#[bevy_main]` would
|
||||||
|
generate, but usable on an arbitrary entry point name.
|
||||||
|
- **Android APK launch: gate `resize_constraints` to non-Android**
|
||||||
|
(`202a64d`). On Android `max_width/max_height` default to `0.0`;
|
||||||
|
Bevy's clamp panicked with `min=800 > max=0`.
|
||||||
|
- **Android APK launch: gate `apply_smart_default_window_size` to
|
||||||
|
non-Android** (`202a64d`). The system calls `.clamp(800.0,
|
||||||
|
logical_w)` which panics when the emulator reports zero window
|
||||||
|
dimensions during early Android lifecycle events. The OS controls
|
||||||
|
window size on Android; the system is irrelevant there.
|
||||||
|
- **Ignore `.idea/` IDE project files** (`16242e6`). Android Studio
|
||||||
|
created `.idea/` when the project was opened during APK
|
||||||
|
verification; added to `.gitignore` and removed the accidentally-
|
||||||
|
committed files.
|
||||||
|
|
||||||
|
### Android verification result
|
||||||
|
|
||||||
|
APK boots on `x86_64-linux-android` in a Pixel_7 AVD (Android 14 /
|
||||||
|
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
|
||||||
|
Bevy renderer initialises, splash screen loads. This is the first
|
||||||
|
confirmed end-to-end device run.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: **1300+ passing** / 0 failing
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_core` (game_state), `solitaire_data`
|
||||||
|
(settings, stats, difficulty_seeds, challenge), `solitaire_engine`
|
||||||
|
(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin,
|
||||||
|
input_plugin, play_by_seed_plugin, lib), `solitaire_app` (lib.rs),
|
||||||
|
`solitaire_assetgen` (gen_difficulty_seeds + gen_seeds binaries)
|
||||||
|
|
||||||
|
## [0.21.8] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for replay-overlay polish. Through-line:
|
||||||
|
**notch-label centering + WIN MOVE HC legibility + HC system extension**.
|
||||||
|
All three items were "optional polish" flagged in the v0.21.7 handoff;
|
||||||
|
all three ship in two commits.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`STATE_SUCCESS_HC` constant** (`c50eaf8`). Brighter lime
|
||||||
|
(`#c8e862`, L≈0.73) in `ui_theme` for use wherever the
|
||||||
|
standard `STATE_SUCCESS` (`#acc267`, L≈0.51) needs extra
|
||||||
|
luminance under HC mode. Sits above the bumped notch ticks
|
||||||
|
(`BORDER_SUBTLE_HC` gray, L≈0.60) so a WIN MOVE marker at
|
||||||
|
this colour is unambiguous.
|
||||||
|
- **`HighContrastBackground::with_hc(default, hc)` constructor**
|
||||||
|
(`c50eaf8`). Extends `HighContrastBackground` with an
|
||||||
|
`hc_color: Color` field (default = `BORDER_SUBTLE_HC` via
|
||||||
|
`with_default()`). `update_high_contrast_backgrounds` now
|
||||||
|
reads `marker.hc_color` instead of the hardcoded constant —
|
||||||
|
backwards-compatible; all existing `with_default()` usages
|
||||||
|
continue to bump to gray.
|
||||||
|
- **WIN MOVE scrub-bar marker HC bump** (`c50eaf8`). Marker
|
||||||
|
now carries `HighContrastBackground::with_hc(STATE_SUCCESS,
|
||||||
|
STATE_SUCCESS_HC)` so the lime stays lime under HC (brighter
|
||||||
|
lime rather than gray). Pin test locks both the default and
|
||||||
|
HC colour fields on the spawned entity.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Scrub-bar notch-label centering** (`b44d277`). Middle
|
||||||
|
three labels ("25%", "50%", "75%") previously had their
|
||||||
|
left edge at the notch; now their text centre coincides
|
||||||
|
with the notch tick. Implemented using the CSS
|
||||||
|
`translateX(-50%)` pattern for Bevy 0.18 UI: a fixed
|
||||||
|
`SCRUB_LABEL_CENTER_WIDTH = 36 px` container with
|
||||||
|
`margin.left = -18 px` is placed at `left: Percent(pct)`,
|
||||||
|
and `Justify::Center` centres the text within it. Endpoint
|
||||||
|
labels ("0%", "100%") keep their flush-left / flush-right
|
||||||
|
anchoring. `with_default()` remains one-argument.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: 1276 passing / 0 failing (engine: 831)
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_engine` (replay_overlay.rs,
|
||||||
|
ui_theme.rs, settings_plugin.rs)
|
||||||
|
|
||||||
|
## [0.21.7] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release closing the last major B-2 sub-piece. Through-line:
|
||||||
|
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
|
||||||
|
50 % opacity" is now implemented as a full-screen UI scrim that darkens
|
||||||
|
the card world during replay so the chrome (banner + move-log panel)
|
||||||
|
reads clearly against the scene.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
|
||||||
|
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
|
||||||
|
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY − 1 = 54` whenever
|
||||||
|
a replay starts; despawned alongside the banner and move-log
|
||||||
|
panel when the replay ends. Bevy's UI/world compositor means
|
||||||
|
no changes to `card_plugin` are needed — UI nodes always
|
||||||
|
render above world-space sprites regardless of `Transform.z`.
|
||||||
|
The dim layer carries no `Interaction` component (purely
|
||||||
|
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
|
||||||
|
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
|
||||||
|
lifecycle (spawn/despawn mirrors the floating-chip pattern)
|
||||||
|
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
|
||||||
|
pinned). 1275 tests pass / 0 failing.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: 1275 passing / 0 failing
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_engine` (replay_overlay.rs)
|
||||||
|
|
||||||
|
## [0.21.6] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.5 work. Through-line:
|
||||||
|
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
|
||||||
|
keyboard-accelerator surface (Space / Esc / ← / →) and the
|
||||||
|
keybind footer; v0.21.6 builds on that with two parallel
|
||||||
|
threads — accessibility + scrub-on-hold polish for the v0.21.5
|
||||||
|
surfaces, plus a brand-new Move Log panel anchored to the
|
||||||
|
viewport's bottom edge that gives players a 5-row recent-and-
|
||||||
|
upcoming move history alongside the existing top-edge banner.
|
||||||
|
|
||||||
|
The Move Log panel is the first replay-overlay surface that
|
||||||
|
*isn't* attached to the banner — it lives at a separate screen
|
||||||
|
anchor (bottom: 0) with its own spawn/despawn lifecycle.
|
||||||
|
Establishes the pattern for "multi-anchor replay UI" that the
|
||||||
|
remaining B-2 sub-piece (mini-tableau preview) will inherit.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **HC-mode coverage for the scrub track + quarter-mark notch
|
||||||
|
ticks** (`d3cb1a5`). Adds parallel primitive
|
||||||
|
`HighContrastBackground` to `ui_theme` and a paint system
|
||||||
|
`update_high_contrast_backgrounds` in `settings_plugin` that
|
||||||
|
mirrors the existing border-marker pattern but targets
|
||||||
|
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
|
||||||
|
scrub track Node and all five quarter-mark notch ticks so
|
||||||
|
they bump from `BORDER_SUBTLE` (`#505050`) →
|
||||||
|
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
|
||||||
|
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
|
||||||
|
don't get the marker — accent and state colours are already
|
||||||
|
saturated and don't need an HC luminance variant.
|
||||||
|
- **Continuous scrub on key-held arrow keys** (`2e25476`).
|
||||||
|
Holding ← or → triggers continuous step at 100 ms cadence
|
||||||
|
(10 steps/sec) — matches the mockup's `[← →] scrub`
|
||||||
|
terminology while keeping single-press = single-step
|
||||||
|
semantics. Per-key accumulators in a new
|
||||||
|
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
|
||||||
|
the accumulator and fire immediately. Release resets to 0
|
||||||
|
so the next fresh press fires immediately rather than at
|
||||||
|
half-interval.
|
||||||
|
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
|
||||||
|
`4437a1a`). New bottom-edge UI panel showing a 5-row window
|
||||||
|
onto recent + upcoming moves: 2 prev rows above the active
|
||||||
|
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
|
||||||
|
rows below. Header reads `▌ MOVE LOG · N/M` (or
|
||||||
|
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
|
||||||
|
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
|
||||||
|
legible contrast against the brick-red highlight. Prev /
|
||||||
|
next rows render in `TEXT_SECONDARY` so the active row
|
||||||
|
stays the focal point.
|
||||||
|
- Sibling-of-banner pattern (separate root entity anchored
|
||||||
|
at viewport bottom, not a banner child) — same
|
||||||
|
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
|
||||||
|
different screen anchor.
|
||||||
|
- Five pure helpers handle the formatting:
|
||||||
|
`format_pile`, `format_move_body`,
|
||||||
|
`format_move_log_header`, `format_kth_recent_row` (active
|
||||||
|
+ prev), `format_kth_next_row` (next). 1-indexed display
|
||||||
|
numbers throughout (`Foundation(2)` reads as "foundation
|
||||||
|
3" rather than the enum's 0-index).
|
||||||
|
- Panel grows from 56 → 84 → 112 px across the four
|
||||||
|
move-log commits. `MOVE_LOG_PREV_ROWS` and
|
||||||
|
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
|
||||||
|
the row count; `format_kth_recent_row` and
|
||||||
|
`format_kth_next_row` return empty for out-of-range k so
|
||||||
|
panels gracefully under-fill at the start (cursor=1) and
|
||||||
|
end (cursor=N-1) of a replay.
|
||||||
|
- HC marker on the panel's top border so the 1 px edge
|
||||||
|
bumps under HC mode (same pattern as the keybind footer).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`react_to_state_change` despawns the Move Log panel** on
|
||||||
|
`Playing → Inactive` alongside the banner root and floating
|
||||||
|
progress chip. Third query in the same defer-and-despawn
|
||||||
|
cycle.
|
||||||
|
- **Move Log panel height grew 56 → 84 → 112 px** across the
|
||||||
|
prev-rows and next-rows commits. The panel is sized to fit
|
||||||
|
the chosen row count + header + padding; tunable via the
|
||||||
|
`MOVE_LOG_PANEL_HEIGHT` const.
|
||||||
|
- **`format_active_move_row` now prefixes the `▶` focus
|
||||||
|
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
|
||||||
|
and prepends the prefix when the body is non-empty. Empty
|
||||||
|
case still returns empty — cursor=0 doesn't paint a stray
|
||||||
|
`▶` on an otherwise-empty row.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
|
||||||
|
recording the HC paint + continuous-scrub polish, then
|
||||||
|
again as the Move Log arc shipped commit-by-commit. The
|
||||||
|
Resume menu's B option now traces the full arc:
|
||||||
|
notches → labels → footer → ESC → HC → arrow keys →
|
||||||
|
HC paint → continuous scrub → move log.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1273 passing tests / 0 failing** across the workspace
|
||||||
|
(net +23 from v0.21.5's 1250 baseline):
|
||||||
|
- 2 from `d3cb1a5` (HC marker on track + notches).
|
||||||
|
- 2 from `2e25476` (continuous-scrub repeat-while-held +
|
||||||
|
release-resets-accumulator).
|
||||||
|
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
|
||||||
|
spawn / lifecycle scenarios).
|
||||||
|
- 4 from `140251b` (prev rows: helper k coverage + spawn
|
||||||
|
cardinality + spawn texts + repaint on cursor advance).
|
||||||
|
- 3 from `e7345ae` (active row highlight: wrapper bg +
|
||||||
|
text colour + focus prefix + cursor=0 stays empty).
|
||||||
|
- 4 from `4437a1a` (next rows: helper k coverage + spawn
|
||||||
|
cardinality + spawn texts + under-fill at replay end).
|
||||||
|
- Clippy clean across the workspace.
|
||||||
|
|
||||||
|
## [0.21.5] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.4 work. One through-line:
|
||||||
|
**replay-overlay scrubbing affordances + accessibility**. v0.21.4
|
||||||
|
shipped pause / resume / step + the WIN MOVE marker as the first
|
||||||
|
*scrubbing-shaped* additions to the replay overlay; v0.21.5
|
||||||
|
fills out the rest of the scrubbing UX so the player has both
|
||||||
|
visual anchor points (notches + labels) and a complete keyboard
|
||||||
|
control surface (Space / Esc / ← / →) for navigating a paused
|
||||||
|
replay.
|
||||||
|
|
||||||
|
Two of the six commits in this cycle are layout-changing — they
|
||||||
|
grow the banner height from 60 px → 76 px → 92 px to make room
|
||||||
|
for the notch labels and keybind footer. Banner geometry was
|
||||||
|
fixed for every prior B-2 commit; this release establishes the
|
||||||
|
"grow the container, add a flex-column child" pattern that the
|
||||||
|
remaining B-2 sub-pieces (move-log scroller, mini-tableau
|
||||||
|
preview) will inherit when they land.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Quarter-mark scrub-bar notches** (`fe68861`). Five 1 px
|
||||||
|
vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player
|
||||||
|
visual anchor points without needing to mentally bisect the
|
||||||
|
bar. Pure helper `scrub_notch_positions()` returns the fixed
|
||||||
|
array; spawn loop sits next to the WIN MOVE marker spawn so
|
||||||
|
the lifecycles match. Notches paint in `BORDER_SUBTLE` (same
|
||||||
|
as the unfilled track) and rely on extending past the 1 px
|
||||||
|
track (5 px tall, anchored 2 px above the track top) for
|
||||||
|
visibility — same trick the WIN MOVE marker uses. Spawned
|
||||||
|
*after* the WIN MOVE marker so a notch and the marker
|
||||||
|
landing on the same percentage paint the marker on top.
|
||||||
|
- **Percentage labels under each notch** (`d322abf`). Five
|
||||||
|
`0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
|
||||||
|
row beneath the 1 px scrub track give the player explicit
|
||||||
|
quarter-mark readouts. Banner grew from 60 → 76 px to
|
||||||
|
accommodate the row — first **layout-changing** commit in
|
||||||
|
the B-2 arc. Pure helper `scrub_notch_labels()` returns the
|
||||||
|
fixed array, paired index-for-index with
|
||||||
|
`scrub_notch_positions()`. Spawn loop applies an "endpoints
|
||||||
|
flush, middle three percent-anchored" positioning pattern:
|
||||||
|
leftmost label gets `left: 0`, rightmost gets `right: 0`,
|
||||||
|
middle three anchor at `left: Val::Percent(p)` since Bevy
|
||||||
|
0.18 UI lacks a clean CSS-style `translate-x: -50%`
|
||||||
|
centering primitive. Label colour is `TEXT_SECONDARY`
|
||||||
|
rather than the mockup's `BORDER_SUBTLE` (the latter would
|
||||||
|
match the notches but is too low-contrast against
|
||||||
|
`BG_ELEVATED_HI` to read at 12 px).
|
||||||
|
- **Keybind-hint footer** (`1873b3f`). Vim-style mode line on
|
||||||
|
the left (`▌ NORMAL │ replay`) plus a keybind hint on the
|
||||||
|
right at the bottom edge of the banner. Banner grew from
|
||||||
|
76 → 92 px to fit the 16 px footer row. Surfaces every
|
||||||
|
wired keyboard accelerator visually so CLAUDE.md §3.3's
|
||||||
|
UI-first contract holds for keyboard accelerators too. The
|
||||||
|
footer lists *only* keybinds that are actually wired —
|
||||||
|
the only-wired-keybinds discipline means each release
|
||||||
|
cycle's hint string is a precise honest contract with the
|
||||||
|
player. Two pure helpers (`keybind_footer_mode_text`,
|
||||||
|
`keybind_footer_hint_text`) keep the static text testable.
|
||||||
|
1 px top border in `BORDER_SUBTLE` separates the footer
|
||||||
|
from the labels row.
|
||||||
|
- **ESC keyboard accelerator for replay-stop** (`90e24d9`).
|
||||||
|
New `handle_stop_keyboard` system parallels
|
||||||
|
`handle_pause_keyboard` in shape — fires only when state
|
||||||
|
is `Playing`, calls `stop_replay_playback`. Cross-plugin
|
||||||
|
coordination via `pause_plugin::toggle_pause`: added a
|
||||||
|
fourth defer-if check
|
||||||
|
(`replay_state.is_some_and(|s| s.is_playing())`) right
|
||||||
|
after the existing `other_modal_scrims` check so ESC
|
||||||
|
during active replay belongs to the replay overlay, not
|
||||||
|
the pause modal.
|
||||||
|
- **HC-mode coverage for the keybind-footer top border**
|
||||||
|
(`23902cd`).
|
||||||
|
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
|
||||||
|
on the footer's border-carrying Node so the existing
|
||||||
|
`apply_high_contrast_borders` system bumps the 1 px top
|
||||||
|
border from `#505050` → `#a0a0a0` when
|
||||||
|
`Settings::high_contrast_mode` is on. Without the marker
|
||||||
|
the footer reads as floating loose under HC because the
|
||||||
|
border that anchors it to the labels row is
|
||||||
|
near-invisible.
|
||||||
|
- **← / → keyboard accelerators for paused stepping**
|
||||||
|
(`e5c4f51`). New `step_backwards_replay_playback` in
|
||||||
|
`replay_playback.rs` decrements the cursor and dispatches
|
||||||
|
`UndoRequestEvent`; the game's `handle_undo` reads it
|
||||||
|
next frame to reverse its most-recent move. Hooks the
|
||||||
|
existing undo system rather than replaying-forward-from-
|
||||||
|
zero — every replay-applied move pushes to the undo stack
|
||||||
|
the same way a player move would, so undo is the right
|
||||||
|
reversal primitive. Both arrow keys are paused-only via
|
||||||
|
the same destructure-gate pattern the forward step uses.
|
||||||
|
The mockup labels these `[← →] scrub`; single-move step
|
||||||
|
is the closest behaviour shippable today, so the footer
|
||||||
|
hint reads `[← →] step` — only-wired-keybinds discipline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Banner height grew 60 → 76 → 92 px** across two
|
||||||
|
layout-changing commits (`d322abf` then `1873b3f`). Top
|
||||||
|
row's `flex_grow: 1.0` still consumes 59 px so the
|
||||||
|
existing content (label / progress chip / buttons) has
|
||||||
|
the same vertical space; the new rows (16 px labels +
|
||||||
|
16 px footer) extend the banner downward into the
|
||||||
|
gameplay area. Banner geometry is now mutable — every
|
||||||
|
prior B-2 commit fit inside fixed 60 px space.
|
||||||
|
- **Keybind-footer hint text grew alongside the wirings**:
|
||||||
|
`[SPACE] pause/resume` →
|
||||||
|
`[SPACE] pause/resume · [ESC] stop` →
|
||||||
|
`[SPACE] pause/resume · [ESC] stop · [← →] step`.
|
||||||
|
- **`pause_plugin::toggle_pause` now defers when a replay
|
||||||
|
is active** (`90e24d9`). Adds a fourth defer-if check to
|
||||||
|
the existing modal-stack pattern.
|
||||||
|
- **`ReplayOverlayPlugin` registers
|
||||||
|
`add_message::<UndoRequestEvent>()`** (`e5c4f51`).
|
||||||
|
Defensive registration so the plugin runs cleanly under
|
||||||
|
`MinimalPlugins` without `GamePlugin` attached.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed five times this cycle.
|
||||||
|
The B option in the Resume menu now traces the full arc:
|
||||||
|
notches → labels → footer → ESC → HC → arrow keys.
|
||||||
|
- The pre-existing `daily_challenge` warning test that
|
||||||
|
fails when wall-clock UTC is within 30 minutes of
|
||||||
|
midnight is documented in this cycle's handoff. Same
|
||||||
|
shape as the earlier `winnable_seed_search` flake —
|
||||||
|
time-dependent, deterministically passes outside the
|
||||||
|
trigger window.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1250 total tests / 1249 passing / 1 pre-existing
|
||||||
|
time-dependent flake** across the workspace (net +22 from
|
||||||
|
v0.21.4's 1228 baseline):
|
||||||
|
- 4 from `fe68861` (scrub-notch coverage)
|
||||||
|
- 4 from `d322abf` (notch-label coverage)
|
||||||
|
- 4 from `1873b3f` (keybind-footer coverage)
|
||||||
|
- 3 from `90e24d9` (ESC-accelerator coverage)
|
||||||
|
- 1 from `23902cd` (HC-marker coverage)
|
||||||
|
- 6 from `e5c4f51` (arrow-keyboard coverage)
|
||||||
|
- **Pre-existing flake**:
|
||||||
|
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
|
||||||
|
fails when wall-clock UTC is within 30 minutes of
|
||||||
|
midnight. Verified pre-existing by stash-and-retest
|
||||||
|
before each commit. Will pass deterministically outside
|
||||||
|
the trigger window. Not introduced by this release.
|
||||||
|
- Clippy clean across the workspace.
|
||||||
|
|
||||||
## [0.21.4] — 2026-05-08
|
## [0.21.4] — 2026-05-08
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
version: unified-3.0
|
version: unified-4.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,8 +29,9 @@ solitaire_sync/ # Shared API + merge logic
|
|||||||
solitaire_data/ # Persistence + sync client
|
solitaire_data/ # Persistence + sync client
|
||||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
solitaire_server/ # Axum backend (optional sync layer)
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
|
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||||
solitaire_app/ # Entry binary
|
solitaire_app/ # Entry binary
|
||||||
assets/ # Runtime assets (except audio)
|
assets/ # Runtime assets (except audio + default theme)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -72,12 +73,16 @@ These override all other instructions.
|
|||||||
|
|
||||||
* NO `unwrap()`
|
* NO `unwrap()`
|
||||||
* NO `panic!()` in runtime/game logic
|
* NO `panic!()` in runtime/game logic
|
||||||
* All state transitions:
|
* Core game state mutations MUST return:
|
||||||
|
|
||||||
```rust id="err_model"
|
```rust id="err_model"
|
||||||
Result<T, MoveError>
|
Result<T, MoveError>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* Engine / UI state changes follow ECS patterns (Resources, Events) —
|
||||||
|
they do not return `MoveError`
|
||||||
|
* Use `thiserror`-derived types for any new error enums outside `solitaire_core`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2.4 Threading Rules
|
## 2.4 Threading Rules
|
||||||
@@ -126,10 +131,15 @@ trait SyncProvider
|
|||||||
## 3.1 ECS Design
|
## 3.1 ECS Design
|
||||||
|
|
||||||
* systems = single responsibility
|
* systems = single responsibility
|
||||||
* communication = Events only
|
* cross-system communication = Events (fire-and-forget triggers)
|
||||||
* shared state = Resources only
|
* persistent shared state = Resources (polled every frame or on change)
|
||||||
* per-entity state = Components only
|
* per-entity state = Components only
|
||||||
|
|
||||||
|
Events and Resources are both valid communication paths — use Events when
|
||||||
|
the receiver needs to react once; use Resources when the receiver polls
|
||||||
|
or when multiple systems read the same value (e.g. `SafeAreaInsets`,
|
||||||
|
`HudVisibility`, `LayoutResource`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.2 Game State Authority
|
## 3.2 Game State Authority
|
||||||
@@ -149,11 +159,22 @@ Every player action MUST:
|
|||||||
Keyboard shortcuts are:
|
Keyboard shortcuts are:
|
||||||
→ optional accelerators only
|
→ optional accelerators only
|
||||||
|
|
||||||
|
**Exception — UI chrome gestures:**
|
||||||
|
Tap-to-toggle visibility of UI chrome (e.g. auto-hiding HUD band) is
|
||||||
|
permitted without a visible button. The gesture MUST:
|
||||||
|
* affect only chrome visibility, never game state
|
||||||
|
* restore chrome automatically when any modal opens
|
||||||
|
* be purely additive (game remains fully playable with chrome always visible)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.4 Layout System
|
## 3.4 Layout System
|
||||||
|
|
||||||
* recompute on `WindowResized`
|
* recompute on `WindowResized`
|
||||||
|
* recompute on `SafeAreaInsets` changed
|
||||||
|
* recompute on `HudVisibility` changed
|
||||||
|
* `compute_layout` MUST accept `hud_visible: bool`; pass `HUD_BAND_HEIGHT`
|
||||||
|
when `true`, `0.0` when `false`
|
||||||
* no fixed resolution assumptions
|
* no fixed resolution assumptions
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -178,11 +199,18 @@ Includes:
|
|||||||
|
|
||||||
## 4.2 Embedded Assets
|
## 4.2 Embedded Assets
|
||||||
|
|
||||||
Only audio:
|
Embed via `include_bytes!()` only when ALL of the following are true:
|
||||||
|
|
||||||
```text id="audio_rule"
|
* the asset is small (< 500 KB uncompressed)
|
||||||
include_bytes!()
|
* it changes rarely (not user-customisable)
|
||||||
```
|
* a missing file would be a hard crash, not a graceful degradation
|
||||||
|
|
||||||
|
Currently embedded:
|
||||||
|
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
||||||
|
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
||||||
|
|
||||||
|
Do NOT embed card face PNGs, background images, or user fonts —
|
||||||
|
these are loaded via `AssetServer` so art can be swapped without recompile.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,7 +238,9 @@ Must degrade gracefully under `MinimalPlugins`.
|
|||||||
## 5.2 Public API Rules
|
## 5.2 Public API Rules
|
||||||
|
|
||||||
* prefer `Into<T>` over concrete types
|
* prefer `Into<T>` over concrete types
|
||||||
* all public items require doc comments
|
* publicly exported functions, traits, and non-trivial types require doc comments
|
||||||
|
* simple marker components, newtype wrappers, and internal `pub` items
|
||||||
|
used only within the same crate are exempt from doc comment requirements
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -276,11 +306,13 @@ NEVER commit otherwise
|
|||||||
|
|
||||||
Claude must request confirmation before:
|
Claude must request confirmation before:
|
||||||
|
|
||||||
* adding dependencies
|
* adding dependencies to `solitaire_core` or `solitaire_sync`
|
||||||
* modifying `solitaire_sync`
|
(engine/server crates may add deps without confirmation)
|
||||||
* changing DB schema
|
* modifying `solitaire_sync` types or the `SyncProvider` trait
|
||||||
|
* changing DB schema (migrations are append-only)
|
||||||
* introducing `unsafe`
|
* introducing `unsafe`
|
||||||
* changing merge strategy
|
* changing the merge strategy in `solitaire_sync::merge`
|
||||||
|
* changing the `SyncPayload` wire format (breaking change for existing servers)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -304,10 +336,29 @@ Core is always the source of truth.
|
|||||||
|
|
||||||
Must always be handled explicitly:
|
Must always be handled explicitly:
|
||||||
|
|
||||||
|
**All platforms**
|
||||||
* Bevy `Time` uses `f32`
|
* Bevy `Time` uses `f32`
|
||||||
* `sqlx::migrate!()` path is crate-relative
|
* `sqlx::migrate!()` path is crate-relative
|
||||||
* `dirs::data_dir()` may return `None`
|
* `dirs::data_dir()` may return `None`
|
||||||
* Linux may lack keyring backend
|
* Linux may lack keyring backend — handle `keyring::Error` gracefully
|
||||||
|
|
||||||
|
**Android (active target — not stretch)**
|
||||||
|
* Safe-area insets arrive in frames 1–3 via JNI polling, not at startup;
|
||||||
|
UI that depends on them must handle the zero-inset initial state
|
||||||
|
* Physical pixels ≠ logical pixels: `SafeAreaInsets` values are physical
|
||||||
|
(from `WindowInsets` API); divide by `window.scale_factor()` before
|
||||||
|
passing to Bevy `Val::Px`
|
||||||
|
* `adb shell input tap` uses physical pixel coordinates
|
||||||
|
* FiraMono (bundled font) covers: ASCII, card suits U+2660–2666,
|
||||||
|
Arrows U+2190–21FF. It does NOT cover Geometric Shapes (U+25xx) —
|
||||||
|
those render as missing-glyph rectangles on Android
|
||||||
|
* The gesture/navigation bar at the bottom (≈132px physical on common
|
||||||
|
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
||||||
|
avoid placing interactive elements in that zone
|
||||||
|
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
|
||||||
|
layout constants are `#[cfg(target_os = "android")]` gated
|
||||||
|
* JNI calls must use `attach_current_thread_permanently` — not
|
||||||
|
`attach_current_thread` — to avoid detach-on-drop panics
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -318,6 +369,12 @@ Must always be handled explicitly:
|
|||||||
* blocking async calls in ECS
|
* blocking async calls in ECS
|
||||||
* insecure credential storage
|
* insecure credential storage
|
||||||
* bypassing core logic layer
|
* bypassing core logic layer
|
||||||
|
* hardcoded pixel coordinates in layout — always derive from `compute_layout`
|
||||||
|
* Unicode Geometric Shapes block (U+25xx) in UI text — not in FiraMono
|
||||||
|
* spawning a second `ModalScrim` while one already exists without first
|
||||||
|
dismissing the existing one (use `scrims.is_empty()` guard)
|
||||||
|
* reading `SafeAreaInsets` physical values directly into `Val::Px` without
|
||||||
|
dividing by `window.scale_factor()`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,9 +402,74 @@ If unclear:
|
|||||||
| Both combined | full system understanding |
|
| Both combined | full system understanding |
|
||||||
|
|
||||||
---
|
---
|
||||||
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
# 14. Modal System Conventions
|
||||||
|
|
||||||
## 14.1 Purpose
|
All full-screen overlay panels MUST use the `spawn_modal` / `ModalScrim` pattern
|
||||||
|
from `solitaire_engine::ui_modal`.
|
||||||
|
|
||||||
|
## 14.1 Spawn pattern
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let scrim = spawn_modal(commands, MyScreenMarker, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, "Title", font_res);
|
||||||
|
// ... body nodes ...
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(actions, MyCloseButton, "Done", None,
|
||||||
|
ButtonVariant::Primary, font_res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Optional: allow clicking the scrim outside the card to dismiss
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14.2 Guard rule
|
||||||
|
|
||||||
|
Before spawning a new modal, check `scrims: Query<(), With<ModalScrim>>`
|
||||||
|
and return early if `!scrims.is_empty()` — unless the new modal is
|
||||||
|
explicitly replacing the current one (despawn first, then spawn).
|
||||||
|
|
||||||
|
## 14.3 Safe area
|
||||||
|
|
||||||
|
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
||||||
|
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
||||||
|
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
||||||
|
|
||||||
|
## 14.4 Z-ordering
|
||||||
|
|
||||||
|
Use `Z_MODAL_PANEL` from `ui_theme` for all modal scrims. Do not use
|
||||||
|
raw `z_index` values — they drift and cause ordering bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Android Build & Verification
|
||||||
|
|
||||||
|
## 15.1 Build command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo apk build --package solitaire_app --lib
|
||||||
|
adb install -r target/debug/apk/solitaire-quest.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15.2 Coordinate system reminder
|
||||||
|
|
||||||
|
Device physical: 1080×2400. Bevy logical: 900×2000. Scale factor: 1.20.
|
||||||
|
`adb shell input tap X Y` takes PHYSICAL coordinates.
|
||||||
|
To convert from what you see on screen (logical): multiply by 1.20.
|
||||||
|
|
||||||
|
## 15.3 Android-specific test checklist
|
||||||
|
|
||||||
|
Before shipping any Android build:
|
||||||
|
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
|
||||||
|
- [ ] All modal Done buttons are above the gesture bar
|
||||||
|
- [ ] No Geometric Shapes glyphs in UI text
|
||||||
|
- [ ] HUD band does not overlap the top status bar
|
||||||
|
- [ ] Touch drag-and-drop works on all pile types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||||
|
|
||||||
|
## 16.1 Purpose
|
||||||
|
|
||||||
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||||
|
|
||||||
@@ -360,7 +482,7 @@ This prevents:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.2 Input Classification Step (MANDATORY)
|
## 16.2 Input Classification Step (MANDATORY)
|
||||||
|
|
||||||
Every request MUST be classified into exactly one task type:
|
Every request MUST be classified into exactly one task type:
|
||||||
|
|
||||||
@@ -381,13 +503,13 @@ If uncertain → ask clarification.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.3 Context Selection Engine
|
## 16.3 Context Selection Engine
|
||||||
|
|
||||||
After classification, Claude MUST include ONLY the relevant sections below.
|
After classification, Claude MUST include ONLY the relevant sections below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.4 Context Map (CORE RULESET)
|
## 16.4 Context Map (CORE RULESET)
|
||||||
|
|
||||||
### feature
|
### feature
|
||||||
|
|
||||||
@@ -495,7 +617,7 @@ Include:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.5 Context Compression Rules
|
## 16.5 Context Compression Rules
|
||||||
|
|
||||||
Claude MUST obey:
|
Claude MUST obey:
|
||||||
|
|
||||||
@@ -506,7 +628,7 @@ Claude MUST obey:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.6 Context Priority Order
|
## 16.6 Context Priority Order
|
||||||
|
|
||||||
When space is limited:
|
When space is limited:
|
||||||
|
|
||||||
@@ -517,7 +639,7 @@ When space is limited:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.7 “No Context Pollution” Rule
|
## 16.7 “No Context Pollution” Rule
|
||||||
|
|
||||||
Claude must NOT include:
|
Claude must NOT include:
|
||||||
|
|
||||||
@@ -529,7 +651,7 @@ Claude must NOT include:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.8 Self-Check Before Execution
|
## 16.8 Self-Check Before Execution
|
||||||
|
|
||||||
Before writing code, Claude MUST verify:
|
Before writing code, Claude MUST verify:
|
||||||
|
|
||||||
@@ -542,7 +664,7 @@ If any fail → revise context selection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.9 Injection Output Format (Internal Model)
|
## 16.9 Injection Output Format (Internal Model)
|
||||||
|
|
||||||
Claude should behave as if it constructed:
|
Claude should behave as if it constructed:
|
||||||
|
|
||||||
@@ -560,7 +682,7 @@ Claude should behave as if it constructed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.10 Relationship to ARCHITECTURE.md
|
## 16.10 Relationship to ARCHITECTURE.md
|
||||||
|
|
||||||
* ARCHITECTURE.md = source of truth
|
* ARCHITECTURE.md = source of truth
|
||||||
* CLAUDE.md = execution constraints
|
* CLAUDE.md = execution constraints
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ You must follow CLAUDE_SPEC.md strictly.
|
|||||||
Rules:
|
Rules:
|
||||||
- Do not expand scope beyond what is defined
|
- Do not expand scope beyond what is defined
|
||||||
- Do not refactor unrelated code
|
- Do not refactor unrelated code
|
||||||
- Do not introduce new dependencies
|
- Do not introduce new dependencies to solitaire_core or solitaire_sync without confirmation
|
||||||
- Prefer minimal, surgical changes
|
- Prefer minimal, surgical changes
|
||||||
- Use existing patterns in the codebase
|
- Use existing patterns in the codebase
|
||||||
- Return minimal diffs or changed functions only
|
- Return minimal diffs or changed functions only
|
||||||
@@ -360,7 +360,7 @@ notes:
|
|||||||
target:
|
target:
|
||||||
"<what is slow>"
|
"<what is slow>"
|
||||||
|
|
||||||
constraints:CLAUDE_WORKFLOW.md
|
constraints:
|
||||||
- no behavior change
|
- no behavior change
|
||||||
- no architecture change
|
- no architecture change
|
||||||
- minimal code changes
|
- minimal code changes
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ solitaire_server:
|
|||||||
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||||
role: "backend"
|
role: "backend"
|
||||||
|
|
||||||
|
solitaire_wasm:
|
||||||
|
depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen]
|
||||||
|
role: "wasm_replay_player"
|
||||||
|
|
||||||
solitaire_app:
|
solitaire_app:
|
||||||
depends_on: [solitaire_engine]
|
depends_on: [solitaire_engine]
|
||||||
role: "entrypoint"
|
role: "entrypoint"
|
||||||
@@ -180,7 +184,7 @@ threading:
|
|||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
pattern: "feature_isolation"
|
pattern: "feature_isolation"
|
||||||
communication: "events"
|
communication: "events and resources"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Credits
|
# Credits
|
||||||
|
|
||||||
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
Ferrous Solitaire is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||||
the work of many open-source projects and a small handful of third-party
|
the work of many open-source projects and a small handful of third-party
|
||||||
assets. This file lists every component that ships in the binary or in the
|
assets. This file lists every component that ships in the binary or in the
|
||||||
`assets/` directory.
|
`assets/` directory.
|
||||||
@@ -43,7 +43,7 @@ copyleft code is statically linked into the game binary.
|
|||||||
| File(s) | Source | License |
|
| File(s) | Source | License |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
|
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
|
||||||
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
|
| `solitaire_engine/assets/themes/default/back.svg` | Original — Ferrous Solitaire | MIT (this project) |
|
||||||
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
|
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
|
||||||
| `assets/cards/backs/back_0.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
| `assets/cards/backs/back_0.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||||
|
|
||||||
@@ -107,6 +107,6 @@ Audio files are MIT-licensed alongside the rest of this project.
|
|||||||
backs, every audio file) are original work covered by this project's MIT
|
backs, every audio file) are original work covered by this project's MIT
|
||||||
license.
|
license.
|
||||||
|
|
||||||
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
If you redistribute Ferrous Solitaire, you must ship this `CREDITS.md` and the
|
||||||
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
||||||
and OFL (FiraMono) notices remain visible to end users.
|
and OFL (FiraMono) notices remain visible to end users.
|
||||||
|
|||||||
@@ -6967,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,8 +6986,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -7009,10 +7013,12 @@ dependencies = [
|
|||||||
"bevy",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"kira",
|
"kira",
|
||||||
"resvg",
|
"resvg",
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
# Solitaire Quest
|
# Ferrous Solitaire
|
||||||
|
|
||||||
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||||
system, full progression (XP / levels / achievements / daily challenges), and
|
system, full progression (XP / levels / achievements / daily challenges), and
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Solitaire Quest — Self-Hosting Guide
|
# Ferrous Solitaire — Self-Hosting Guide
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -42,3 +42,29 @@ git pull
|
|||||||
docker compose build
|
docker compose build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Admin — Password Reset
|
||||||
|
|
||||||
|
If a player loses access to their account, the server binary includes a
|
||||||
|
built-in password reset command. Run it on the host (or inside the container)
|
||||||
|
with `DATABASE_URL` pointing at your database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive (prompts for the new password):
|
||||||
|
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||||
|
./solitaire_server --reset-password <username>
|
||||||
|
|
||||||
|
# Non-interactive (piped from a script or password manager):
|
||||||
|
echo "new_password" | \
|
||||||
|
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||||
|
./solitaire_server --reset-password <username>
|
||||||
|
|
||||||
|
# Inside a running Docker container:
|
||||||
|
docker compose exec server sh -c \
|
||||||
|
'echo "new_password" | ./solitaire_server --reset-password alice'
|
||||||
|
```
|
||||||
|
|
||||||
|
On success the user's `password_hash` is updated and **all active refresh
|
||||||
|
tokens are deleted**, so every open session must log in again with the new
|
||||||
|
password. `JWT_SECRET` does not need to be set for this command.
|
||||||
|
|||||||
@@ -1,354 +1,177 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-08 — **v0.21.3 cut and tagged at
|
**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
|
||||||
`3d92a91`**, working tree clean, all post-tag work pushed to
|
|
||||||
origin.
|
|
||||||
|
|
||||||
v0.21.3 is a patch release with one through-line: **accessibility
|
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||||
arc closure**. v0.21.2 explicitly carved out "dynamic-paint sites"
|
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||||
(HUD action buttons, modal buttons, radial menu rim) on the
|
artifacts (Dockerfile + docker-compose), replay upload on win, web replay
|
||||||
assumption that their existing paint cycles would race the
|
player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out,
|
||||||
central `update_high_contrast_borders` system. v0.21.3 walks the
|
and full server integration tests.
|
||||||
actual code, finds the carve-out was over-cautious, and closes
|
|
||||||
it. Bonus: the first real consumer of `ToastVariant::Warning`
|
|
||||||
also lands here, making the `ToastVariant` enum fully load-bearing
|
|
||||||
(every variant has at least one driver).
|
|
||||||
|
|
||||||
Full v0.21.3 detail lives in `CHANGELOG.md` § [0.21.3]. This
|
---
|
||||||
file from here on focuses on what's *open* post-cut and how to
|
|
||||||
resume.
|
|
||||||
|
|
||||||
## Status at pause
|
## Current state
|
||||||
|
|
||||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
- **HEAD locally:** `03be4fc` (feat: leaderboard custom display name).
|
||||||
`3d92a91`; post-cut work on B-2 (`ab857bb` data field +
|
- **HEAD on origin:** `03be4fc` (fully pushed).
|
||||||
`52befa6` WIN MOVE marker UI + `fbe48ac` playback controls)
|
- **Working tree:** clean (only `solitaire-release.jks.bak2` untracked — intentional).
|
||||||
rides on top of that.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **HEAD on origin:** matches local. v0.21.3 is fully on origin.
|
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||||
- **Working tree:** clean. No WIP outstanding.
|
- **Tags on origin:** `v0.9.0` through `v0.22.0`.
|
||||||
- **`artwork/` directory:** still untracked. Intentional.
|
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
|
||||||
clean.
|
|
||||||
- **Tests:** **1228 passing / 0 failing** across the workspace
|
|
||||||
(1207 from v0.21.3's stats + 5 from `ab857bb`'s
|
|
||||||
`win_move_index` coverage + 8 from `52befa6`'s WIN MOVE marker
|
|
||||||
pure-helper truth-table + spawn lifecycle + 8 from `fbe48ac`'s
|
|
||||||
pause / step / keyboard accelerator coverage).
|
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.21.3`. v0.21.3 is on
|
|
||||||
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
|
|
||||||
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
|
|
||||||
`41a009a`.
|
|
||||||
|
|
||||||
## Since the v0.21.3 cut
|
---
|
||||||
|
|
||||||
- **`ab857bb` — `Replay::win_move_index` data field landed.**
|
## What shipped in Phase 8 (432061c – bd388fe)
|
||||||
First finite step toward the B-2 replay screen-takeover
|
|
||||||
redesign. Additive optional `Option<usize>` on `Replay` with
|
|
||||||
`#[serde(default)]` so older `latest_replay.json` /
|
|
||||||
`replays.json` files load unchanged (no schema bump). Populated
|
|
||||||
at the live recording site via a new `with_win_move_index`
|
|
||||||
builder; for fresh recordings the value is always
|
|
||||||
`Some(moves.len() - 1)` because recording freezes on win, but
|
|
||||||
storing it explicitly lets the playback UI read the WIN MOVE
|
|
||||||
position directly without re-deriving on every render. 5 new
|
|
||||||
tests (1207 → 1212): default, builder set / set-None, on-disk
|
|
||||||
round-trip, legacy-JSON-loads-with-None backward-compat.
|
|
||||||
- **`52befa6` — WIN MOVE marker on the scrub bar.** Second
|
|
||||||
commit on B-2 — the UI that consumes the data field. New
|
|
||||||
`ReplayOverlayWinMoveMarker` component spawned as a sibling
|
|
||||||
to `ReplayOverlayScrubFill` under the 1px scrub track,
|
|
||||||
absolute-positioned at `replay.win_move_index / total` along
|
|
||||||
the bar. Painted in `STATE_SUCCESS` (green) so the marker
|
|
||||||
reads as "this is where the win lives." Pure helper
|
|
||||||
`win_move_marker_pct` returns `None` for any state where the
|
|
||||||
marker shouldn't draw (Inactive, Completed, replay missing
|
|
||||||
the field, empty move list); percentage clamps to `[0, 100]`
|
|
||||||
defensively. Lifecycle is spawn-time only — the marker is
|
|
||||||
immutable during a single playback because the underlying
|
|
||||||
`Replay` doesn't change while `Playing`. Despawned with the
|
|
||||||
overlay tree on transition back to `Inactive`. 8 new tests
|
|
||||||
(1212 → 1220): pure-helper truth table + spawn-presence /
|
|
||||||
spawn-absence / despawn-lifecycle observables.
|
|
||||||
- **`fbe48ac` — playback controls (pause / resume / step).**
|
|
||||||
Third commit on B-2. New `paused: bool` field on
|
|
||||||
`ReplayPlaybackState::Playing`; `tick_replay_playback` skips
|
|
||||||
the `secs_to_next` decrement entirely while paused so cursor
|
|
||||||
and timer freeze together. New public API:
|
|
||||||
`toggle_pause_replay_playback` and `step_replay_playback`
|
|
||||||
(the latter hard-gated to `Playing { paused: true }` so
|
|
||||||
manual stepping can't race the tick loop). UI: Pause /
|
|
||||||
Resume button (label repaints reactively via
|
|
||||||
`update_pause_button_label` which walks `Children` from
|
|
||||||
marker to inner `Text`) + Step button + Space keyboard
|
|
||||||
accelerator. Existing 25 `Playing { ... }` construction
|
|
||||||
sites across tests gained `paused: false` mechanically.
|
|
||||||
8 new tests (1220 → 1228): label truth table, label repaint
|
|
||||||
on state change, click-toggles-paused, step advances exactly
|
|
||||||
one cursor with paused preserved, step-while-running no-op,
|
|
||||||
Space toggles paused.
|
|
||||||
|
|
||||||
## Open punch list
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `432061c` | Sync setup modal (login/register/connect/disconnect) |
|
||||||
|
| `6ce5564` | Re-auth on expired session + server deployment artifacts |
|
||||||
|
| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor |
|
||||||
|
| `bd388fe` | CHANGELOG v0.23.0 documentation |
|
||||||
|
|
||||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
||||||
|
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
|
||||||
|
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
|
||||||
|
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
|
||||||
|
- DB migration 002: `replays` table + two indexes
|
||||||
|
- Full server integration tests for replay endpoints
|
||||||
|
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
|
||||||
|
- Stats panel "Copy Share Link" button reads `share_url` from replay history
|
||||||
|
|
||||||
- **APK launch verification on AVD / device.** `adb install` then
|
---
|
||||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
|
||||||
The build works and persistence is wired, but no end-to-end
|
|
||||||
device run has been logged. Shakes out runtime bugs the build +
|
|
||||||
unit tests can't catch.
|
|
||||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
|
||||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
|
||||||
Android backend; small custom JNI call.
|
|
||||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
|
||||||
to a stub returning `KeychainUnavailable`; replace with Android
|
|
||||||
Keystore via JNI when sync auth ships on mobile.
|
|
||||||
- **Google Play Games (gpgs) integration.** Listed as a
|
|
||||||
Phase-Android target since Phase 1; now unblocked by the build
|
|
||||||
target.
|
|
||||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
|
||||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
|
||||||
Either upstream a cargo-apk fix or document `--lib` as
|
|
||||||
canonical in the runbook.
|
|
||||||
|
|
||||||
### Visual-identity follow-ups (post-v0.21.0)
|
## Open punch list (ordered by priority)
|
||||||
|
|
||||||
The visual-identity arc is effectively complete: token system,
|
### 1. Documentation debt (no code)
|
||||||
chrome migration, splash boot screen, replay-overlay banner,
|
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
|
||||||
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
|
||||||
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
- [x] SESSION_HANDOFF.md update — this file
|
||||||
|
|
||||||
- **Replay-overlay screen-takeover redesign.** The full mockup
|
### 2. Leaderboard wiring gaps
|
||||||
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
|
- [x] **Best-score auto-post.** Done (`303c78a`): `update_leaderboard_if_opted_in`
|
||||||
mini-tableau preview, playback controls, move-log scroll, and
|
called from both first-push and merge paths in `sync.rs`; uses SQLite `MIN`/`MAX`
|
||||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
in the UPDATE so scores never regress on stale data.
|
||||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
- [x] **Display name = username.** Done (`03be4fc`): `leaderboard_display_name:
|
||||||
`e080b49`); the floating MOVE chip above the focused card
|
Option<String>` added to `Settings`; editor modal in leaderboard panel; persists
|
||||||
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
|
to `settings.json`; `handle_opt_in_button` prefers custom name over username.
|
||||||
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
|
|
||||||
(UI). Playback controls (pause / resume / step + Space
|
|
||||||
accelerator) shipped post-v0.21.3 in `fbe48ac`. What still
|
|
||||||
needs to land: a move-log scroller and a mini-tableau
|
|
||||||
preview — both screen-takeover-only pieces that need a
|
|
||||||
larger layout reflow than the existing banner can carry.
|
|
||||||
Multi-session.
|
|
||||||
- *Floating `MOVE N/M` chip above the focused card during
|
|
||||||
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
|
||||||
`Text2d` entity sibling to the banner overlay; uses the same
|
|
||||||
`LayoutResource` pile coordinates so it survives window
|
|
||||||
resizes without UI/camera math.
|
|
||||||
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
|
|
||||||
Daily-challenge-expiry toast fires once per `daily.date` when
|
|
||||||
within 30 min of UTC midnight reset and today is incomplete.
|
|
||||||
`ToastVariant` is now fully load-bearing (every variant has at
|
|
||||||
least one real driver). Future Warning drivers can either reuse
|
|
||||||
the generic `WarningToastEvent(String)` carrier or add their
|
|
||||||
own domain message + `animation_plugin` handler.
|
|
||||||
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
|
|
||||||
`MoveRejectedEvent` now fires a 2-second pink-bordered
|
|
||||||
"Invalid move" toast as the third leg of the
|
|
||||||
audio + visual + text rejection-feedback stool.
|
|
||||||
- *High-contrast accessibility mode — closed 2026-05-08 by
|
|
||||||
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
|
|
||||||
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
|
|
||||||
dynamic-paint rollout (`c153363`).* Card text rendering plus
|
|
||||||
8 static-border chrome surfaces (modal scaffold, tooltip,
|
|
||||||
onboarding key chips, help panel key chips, stats panel
|
|
||||||
cells, home Level/XP/Score row, home mode buttons, home
|
|
||||||
mode-hotkey chips, 4 settings panel surfaces) all boost
|
|
||||||
borders to `BORDER_SUBTLE_HC` under HC via the
|
|
||||||
`HighContrastBorder` marker. The previously-carved-out
|
|
||||||
dynamic-paint sites are now also covered: HUD action buttons
|
|
||||||
and modal buttons take the same marker (their paint cycles
|
|
||||||
only mutate `BackgroundColor`, so no race); the radial menu
|
|
||||||
rim folds HC into its per-frame spawn via
|
|
||||||
`radial_rim_outline` so the focused rim boosts to
|
|
||||||
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
|
|
||||||
hierarchy that naive marker substitution would invert).
|
|
||||||
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
|
|
||||||
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
|
|
||||||
card animations; `pulse_splash_cursor` skips the per-frame
|
|
||||||
pulse multiplier; `spawn_splash` skips the scanline overlay
|
|
||||||
entirely. Future scope: gate any future card-lift z-bump
|
|
||||||
animation, warning-chip pulse (when one materialises).
|
|
||||||
|
|
||||||
### Carried forward from v0.19.0
|
### 3. Security hardening
|
||||||
|
- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
|
||||||
|
(migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
|
||||||
|
tests.
|
||||||
|
- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
|
||||||
|
decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
|
||||||
|
steady-state; integration test passes.
|
||||||
|
|
||||||
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
|
### 4. Android validation
|
||||||
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
|
- [x] **Android Keystore functional test.** Done (2026-05-11, Pixel 7 AVD,
|
||||||
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
|
Android 14): `load_access_token()` exercised via `start_pull`; logcat confirmed
|
||||||
hicolor + downstream `.icns`/`.ico` packaging needs. The
|
`NotFound` returned cleanly — no JNI panic. See `docs/android/PLAYABILITY_TODO.md` P4.
|
||||||
`.ico` and `.icns` bundle-format files themselves are *not*
|
- [x] **JNI clipboard functional test.** Done (2026-05-11): temporary `KEYCODE_C`
|
||||||
generated — both would need new crate deps (`ico` and
|
hook confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14.
|
||||||
`icns` respectively) and only matter at app-bundle time
|
Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`.
|
||||||
(cargo-bundle / packaging), not at `cargo run`. Open if the
|
Note: `adb shell input tap` doesn't deliver touch events on headless AVD (documented).
|
||||||
project later ships as a packaged macOS / Windows app.
|
- [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib`
|
||||||
|
is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible.
|
||||||
|
|
||||||
### Other small candidates
|
### 5. Feature completeness
|
||||||
|
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
|
||||||
|
Settings Appearance section. Shows import path label, scans user_theme_dir()
|
||||||
|
for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
|
||||||
|
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
||||||
|
default never overridden and never called; achievements already sync via
|
||||||
|
`SyncPayload` push. Deleted from trait and blanket impl.
|
||||||
|
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
|
||||||
|
documents `wasm-pack build --target web`, cleans up pkg metadata files,
|
||||||
|
includes dependency guard + install instructions.
|
||||||
|
- [x] **Server password reset.** Done (`7514684`): `--reset-password <username>`
|
||||||
|
subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
|
||||||
|
active sessions for the user.
|
||||||
|
|
||||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
### 5b. Android UX polish (2026-05-12)
|
||||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
|
||||||
site renders them today — the Shareable badge therefore lands
|
|
||||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
|
||||||
the badge will need to follow.
|
|
||||||
- **Toast queue / immediate unification.** The two toast paths
|
|
||||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
|
||||||
for fire-and-forget) now share visual treatment but remain
|
|
||||||
separate functions because they serve different temporal
|
|
||||||
needs (sequential vs. parallel). If overlap becomes a UX
|
|
||||||
issue, merge into one queue with priority lanes.
|
|
||||||
|
|
||||||
### Process notes
|
- [x] **UX-1 — Modal Done button in gesture zone.** `apply_safe_area_to_modal_scrims` system
|
||||||
|
added to `SafeAreaInsetsPlugin` (`safe_area.rs`). Pads every `ModalScrim` bottom by
|
||||||
|
`insets.bottom / scale`. Fires on resource change + `Added<ModalScrim>`. Verified on device.
|
||||||
|
- [x] **UX-5b — Home mode glyph corruption.** Geometric Shapes (U+25xx, absent from FiraMono)
|
||||||
|
replaced with card suits U+2660–2666 in `home_plugin.rs`. Affects Zen/Challenge/Daily mode
|
||||||
|
selector buttons at level 5+.
|
||||||
|
- [x] **UX-7 — Help text wrap.** Android HUD entry shortened to
|
||||||
|
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs` — fits one line.
|
||||||
|
- [x] **BUG-3 — Multi-modal stacking.** `handle_menu_button` now checks
|
||||||
|
`scrims: Query<(), With<ModalScrim>>` and guards `spawn_menu_popover` with `scrims.is_empty()`.
|
||||||
|
Verified on device: ≡ tap while Stats open does nothing.
|
||||||
|
|
||||||
- **The desktop-adaptation spec is the canonical reference for
|
**Note:** These 4 fixes are implemented and verified but not yet committed.
|
||||||
geometry decisions** when porting any future plugin. Read
|
|
||||||
`docs/ui-mockups/desktop-adaptation.md` first; apply the
|
|
||||||
universal rules to every surface; consult the per-screen
|
|
||||||
table for the priority surfaces. The 9 missing-plugin screens
|
|
||||||
(splash now ported; eight remaining) inherit the universal
|
|
||||||
rules without dedicated guidance.
|
|
||||||
- **Stitch `generate_variants` is unreliable for layout-only
|
|
||||||
adaptation prompts** as of 2026-05-07. The first call timed
|
|
||||||
out and no variant ever landed in `list_screens`. If a future
|
|
||||||
session wants visual desktop mockups, prefer
|
|
||||||
`generate_screen_from_text` with a fresh narrow prompt per
|
|
||||||
screen rather than `generate_variants` against existing
|
|
||||||
mobile screens.
|
|
||||||
- **Token-port pattern.** v0.20.0's chrome-migration commits
|
|
||||||
set a reusable shape for "centralised design system applied
|
|
||||||
across N plugins":
|
|
||||||
1. Constants module (`ui_theme.rs`) is the source of truth.
|
|
||||||
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
|
||||||
`const` on stable) use a literal RGB matching the token,
|
|
||||||
with a unit test pinning the RGB to the token (e.g.
|
|
||||||
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
|
|
||||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
|
|
||||||
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT` ↔
|
|
||||||
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
|
|
||||||
promoted const re-exported from one plugin and imported
|
|
||||||
by the other — replaces "kept in sync" doc comments with a
|
|
||||||
compile-time invariant.
|
|
||||||
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
|
||||||
as literals with a comment naming the rationale; only UI
|
|
||||||
chrome routes through tokens.
|
|
||||||
- **`SplashFadable` scaffolding pattern** (introduced in
|
|
||||||
`cacb19c`). Any future overlay that needs to fade `N >> 3`
|
|
||||||
elements together should follow the same shape: one tiny
|
|
||||||
marker carrying the full-alpha base colour, one global query
|
|
||||||
that lerps every marker's alpha each frame, no per-element
|
|
||||||
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
|
|
||||||
query exclusion pattern that the old splash was hitting at
|
|
||||||
three siblings.
|
|
||||||
|
|
||||||
### Canonical remote
|
### 6. Testing gaps
|
||||||
|
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
|
||||||
|
`jwt_refresh_on_401_succeeds` (pull) and
|
||||||
|
`push_retries_after_401_on_expired_access_token` (push) in
|
||||||
|
`solitaire_data/tests/sync_round_trip.rs`.
|
||||||
|
- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver
|
||||||
|
searches seeds 1–200 at test time; steps every move through `ReplayPlayer`;
|
||||||
|
asserts `is_won = true` on the final `StateSnapshot`.
|
||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
---
|
||||||
Always push there. As of v0.21.0 origin matches local; the next
|
|
||||||
push happens when post-cut work accumulates and is ready to roll
|
|
||||||
into a v0.21.1 / v0.22.0 cut.
|
|
||||||
|
|
||||||
### Design direction (Terminal — base16-eighties)
|
## ARCHITECTURE.md gaps (for the update pass)
|
||||||
|
|
||||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
Items missing from the doc:
|
||||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities)
|
||||||
16 px edge margins, 8 px card radius.
|
2. Replay API endpoints (§9 API Reference — 3 new routes)
|
||||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
|
3. Web replay player route (`/replays/:id` + `ServeDir /web`)
|
||||||
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242` —
|
4. `SyncProvider` trait: 6 added methods
|
||||||
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
|
5. Theme system in Bevy plugin table (§5)
|
||||||
success (`#acc267`), gold warning (`#ddb26f`), pink error /
|
6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`,
|
||||||
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
|
`reduce_motion_mode`, `window_geometry`, `selected_card_back`,
|
||||||
info (`#12cfc0`).
|
`selected_background`
|
||||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
|
7. DB migration 002 (§7)
|
||||||
Outlined glyphs for diamonds & clubs are *always on*; the
|
8. Update "Last Updated" date
|
||||||
Settings "color-blind mode" toggle swaps red → lime `#acc267`
|
|
||||||
(was red → cyan pre-v0.21.0; lime is the next-best non-red
|
---
|
||||||
base16-eighties accent now that the primary itself is red).
|
|
||||||
- **Card glyphs render upright in both corners** — no 180°
|
## Process notes
|
||||||
inverted-corner-indicator rotation. Single-orientation
|
|
||||||
digital play doesn't benefit from the traditional flip-
|
- **Commit attribution:** use `funman300` as git user. Co-author line:
|
||||||
readback convention. `design-system.md` § Game Cards
|
`Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
|
||||||
documents this deliberate deviation.
|
- **Commit format:** `type(scope): description` per CLAUDE.md §7.
|
||||||
|
- **Never commit without:** `cargo test --workspace` passing + clippy clean.
|
||||||
|
- **Sub-agents** stage/verify only; orchestrator commits.
|
||||||
|
- **`CARD_PLAN.md`** referenced in `theme/` module comments but not present in
|
||||||
|
repo. Clean up references or commit the file.
|
||||||
|
- **Token-port pattern** (v0.20.0): when migrating tokens, walk every concrete
|
||||||
|
artifact downstream — PNGs, SVGs, literals, comments. Three "walked past this"
|
||||||
|
follow-ups in v0.21.0 all had this shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Ferrous Solitaire.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
Working directory: <Rusty_Solitaire clone path>.
|
||||||
Branch: master. v0.21.3 is tagged at 3d92a91 (cut 2026-05-08, a
|
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
|
||||||
patch release rolling up the accessibility-arc closure: HC reaches
|
|
||||||
the previously-carved-out dynamic-paint sites, and the first real
|
|
||||||
consumer of `ToastVariant::Warning` lands as the daily-challenge
|
|
||||||
expiry toast). v0.21.2 stays at f23df3b, v0.21.1 at daa655a,
|
|
||||||
v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md §
|
|
||||||
[0.21.3] for full detail.
|
|
||||||
|
|
||||||
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
|
READ FIRST (in order):
|
||||||
pass (1207+; check with `cargo test --workspace`), clippy clean.
|
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
|
||||||
1. SESSION_HANDOFF.md — this file
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CHANGELOG.md — [0.21.3] section is the most recent cut
|
2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail
|
||||||
3. CLAUDE.md — unified-3.0 rule set
|
3. CLAUDE.md — unified-4.0 rule set
|
||||||
4. CLAUDE_SPEC.md — formal architecture spec
|
4. ARCHITECTURE.md — v1.3, fully up to date
|
||||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
5. docs/ui-mockups/ — design system + mockup library
|
||||||
6. docs/ui-mockups/ — design system + 24-mockup library +
|
6. docs/android/ — Android setup + build runbook
|
||||||
desktop-adaptation.md (the rules-based
|
7. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
companion to the mockups; read this
|
|
||||||
before any plugin port)
|
|
||||||
7. docs/android/* — Android setup + build runbook
|
|
||||||
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
|
||||||
— saved feedback / project context
|
|
||||||
(machine-local; may be missing on a
|
|
||||||
fresh machine)
|
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
OPEN WORK:
|
||||||
A. APK launch verification on AVD / device — `adb install` +
|
Phase 8 punch list is fully closed. All items verified complete.
|
||||||
`adb logcat` to shake out runtime bugs the build / unit
|
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking).
|
||||||
tests can't catch. Likely surfaces JNI ClipboardManager
|
|
||||||
and Android Keystore stubs that need real bridges. Larger
|
|
||||||
scope; needs an Android device or emulator running.
|
|
||||||
B. Replay-overlay screen-takeover redesign — multi-session
|
|
||||||
work. Three sub-pieces shipped post-v0.21.3: WIN MOVE
|
|
||||||
marker (`ab857bb` data field + `52befa6` UI), playback
|
|
||||||
controls (`fbe48ac` pause/resume/step + Space). What
|
|
||||||
still needs to land: a move-log scroller and a
|
|
||||||
mini-tableau preview — both layout-heavy pieces that need
|
|
||||||
more vertical real estate than the current banner-only
|
|
||||||
overlay carries, so the natural next finite step is the
|
|
||||||
screen-takeover layout itself (mockup at
|
|
||||||
`docs/ui-mockups/replay-overlay-mobile.html`). The
|
|
||||||
smaller floating-MOVE-chip piece shipped in v0.21.2
|
|
||||||
(`2fb2d63`).
|
|
||||||
C. Phase 8 (sync) — local storage scaffolding, self-hosted
|
|
||||||
Axum server, `SolitaireServerClient` impl, GPGS stub
|
|
||||||
wired into Settings. The biggest open arc by scope; rolls
|
|
||||||
up several Phase Android dependencies (Keystore,
|
|
||||||
ClipboardManager).
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
4 Android UX fixes are implemented and verified but NOT YET COMMITTED:
|
||||||
- Use the system git config (already correct).
|
- BUG-3 (hud_plugin.rs): multi-modal stacking guard
|
||||||
- When attributing playtester feedback in commits/docs, use
|
- UX-7 (help_plugin.rs): help text wrap on Android
|
||||||
"Quat" not "Rhys" (saved feedback memory).
|
- UX-5b (home_plugin.rs): FiraMono glyph corruption in mode selector
|
||||||
- Sub-agents stage + verify only; orchestrator commits.
|
- UX-1 (safe_area.rs): modal Done button in gesture zone
|
||||||
- Every commit must pass build / clippy / test before pushing.
|
|
||||||
- Push to GitHub (origin) — gh auth setup-git wired on
|
|
||||||
primary dev box; verify on laptop before first push.
|
|
||||||
- Token-port pattern: when migrating tokens, walk every
|
|
||||||
concrete artifact downstream of the token (PNG textures,
|
|
||||||
embedded SVGs, hardcoded literals, comment color names),
|
|
||||||
not just the token name. v0.21.0 surfaced three "the
|
|
||||||
migration walked past this" follow-ups that all matched
|
|
||||||
this shape — codified here so future similar work can
|
|
||||||
pattern-match instead of rediscovering.
|
|
||||||
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
|
|
||||||
visibility fix (`4d48cad`) implemented an invariant that
|
|
||||||
had been declared in a module doc comment but was never
|
|
||||||
enforced in code. When future work touches a module with
|
|
||||||
a "this does X" doc comment, verify the code actually does
|
|
||||||
X and add a test if not. Two layers, two checks.
|
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
Commit those first, then suggest Phase 9 planning.
|
||||||
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.
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: solitaire-server
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||||
|
targetRevision: master
|
||||||
|
path: deploy
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: solitaire
|
||||||
|
# Secrets are applied manually and must not be pruned by ArgoCD.
|
||||||
|
ignoreDifferences:
|
||||||
|
- group: ""
|
||||||
|
kind: Secret
|
||||||
|
name: matomo-secret
|
||||||
|
namespace: solitaire
|
||||||
|
jsonPointers:
|
||||||
|
- /data
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuild the solitaire_wasm crate and install the output into
|
||||||
|
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# cargo install wasm-pack
|
||||||
|
# rustup target add wasm32-unknown-unknown
|
||||||
|
#
|
||||||
|
# Run from the repo root:
|
||||||
|
# ./build_wasm.sh
|
||||||
|
#
|
||||||
|
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||||
|
# committed to git so self-hosters who don't touch the WASM crate can
|
||||||
|
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||||
|
# solitaire_core/.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||||
|
|
||||||
|
if ! command -v wasm-pack &> /dev/null; then
|
||||||
|
echo "error: wasm-pack not found." >&2
|
||||||
|
echo " Install with: cargo install wasm-pack" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building solitaire_wasm (target: web)..."
|
||||||
|
wasm-pack build \
|
||||||
|
--target web \
|
||||||
|
--out-dir "$OUT_DIR" \
|
||||||
|
--no-typescript \
|
||||||
|
"$REPO_ROOT/solitaire_wasm"
|
||||||
|
|
||||||
|
# wasm-pack writes a package.json and .gitignore into the output dir.
|
||||||
|
# Remove them — we manage the output directory ourselves.
|
||||||
|
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||||
|
|
||||||
|
echo "Done. Output:"
|
||||||
|
ls -lh "$OUT_DIR"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: solitaire-server
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: solitaire-server
|
||||||
|
# SQLite is single-writer; Recreate avoids two pods owning the PVC at once.
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: solitaire-server
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
|
containers:
|
||||||
|
- name: server
|
||||||
|
image: solitaire-server
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
value: sqlite:///data/sol.db
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: solitaire-secrets
|
||||||
|
key: jwt-secret
|
||||||
|
- name: SERVER_PORT
|
||||||
|
value: "8080"
|
||||||
|
volumeMounts:
|
||||||
|
- name: db-data
|
||||||
|
mountPath: /data
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
volumes:
|
||||||
|
- name: db-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: solitaire-db
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: solitaire-analytics
|
||||||
|
namespace: solitaire
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: analytics.aleshym.co
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: matomo
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- analytics.aleshym.co
|
||||||
|
secretName: analytics-tls
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: solitaire-server
|
||||||
|
namespace: solitaire
|
||||||
|
annotations:
|
||||||
|
# Remove the next two lines if you are not using cert-manager.
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: klondike.aleshym.co
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: solitaire-server
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
# Remove the tls block if you are not using cert-manager.
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- klondike.aleshym.co
|
||||||
|
secretName: solitaire-tls
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- pvc.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
- mariadb-pvc.yaml
|
||||||
|
- mariadb-deployment.yaml
|
||||||
|
- mariadb-service.yaml
|
||||||
|
- matomo-pvc.yaml
|
||||||
|
- matomo-deployment.yaml
|
||||||
|
- matomo-service.yaml
|
||||||
|
- ingress-analytics.yaml
|
||||||
|
|
||||||
|
# CI updates this block automatically via `kustomize edit set image`.
|
||||||
|
# The image name here matches the `image: solitaire-server` stub in deployment.yaml.
|
||||||
|
images:
|
||||||
|
- name: solitaire-server
|
||||||
|
newName: git.aleshym.co/funman300/solitaire-server
|
||||||
|
newTag: 533bcec2
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: mariadb
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: mariadb
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: mariadb
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: mariadb
|
||||||
|
image: mariadb:11
|
||||||
|
env:
|
||||||
|
- name: MYSQL_ROOT_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_ROOT_PASSWORD
|
||||||
|
- name: MYSQL_DATABASE
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_DATABASE
|
||||||
|
- name: MYSQL_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_USER
|
||||||
|
- name: MYSQL_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_PASSWORD
|
||||||
|
ports:
|
||||||
|
- containerPort: 3306
|
||||||
|
volumeMounts:
|
||||||
|
- name: mariadb-data
|
||||||
|
mountPath: /var/lib/mysql
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- healthcheck.sh
|
||||||
|
- --connect
|
||||||
|
- --innodb_initialized
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- healthcheck.sh
|
||||||
|
- --connect
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
volumes:
|
||||||
|
- name: mariadb-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mariadb-data
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mariadb-data
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mariadb
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: mariadb
|
||||||
|
ports:
|
||||||
|
- name: mysql
|
||||||
|
port: 3306
|
||||||
|
targetPort: 3306
|
||||||
|
clusterIP: None
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: matomo
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: matomo
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: matomo
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: matomo
|
||||||
|
image: matomo:5.10.0
|
||||||
|
env:
|
||||||
|
- name: MATOMO_DATABASE_HOST
|
||||||
|
value: mariadb
|
||||||
|
- name: MATOMO_DATABASE_PORT
|
||||||
|
value: "3306"
|
||||||
|
- name: MATOMO_DATABASE_ADAPTER
|
||||||
|
value: PDO\MYSQL
|
||||||
|
- name: MATOMO_DATABASE_DBNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_DATABASE
|
||||||
|
- name: MATOMO_DATABASE_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_USER
|
||||||
|
- name: MATOMO_DATABASE_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: matomo-secret
|
||||||
|
key: MYSQL_PASSWORD
|
||||||
|
# Traefik terminates SSL; tell Matomo to trust X-Forwarded-* headers
|
||||||
|
- name: MATOMO_GENERAL_ASSUME_SECURE_PROTOCOL
|
||||||
|
value: "1"
|
||||||
|
- name: MATOMO_GENERAL_PROXY_CLIENT_HEADERS
|
||||||
|
value: HTTP_X_FORWARDED_FOR
|
||||||
|
- name: MATOMO_GENERAL_PROXY_HOST_HEADERS
|
||||||
|
value: HTTP_X_FORWARDED_HOST
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
volumeMounts:
|
||||||
|
- name: matomo-data
|
||||||
|
mountPath: /var/www/html
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /matomo.php
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 5
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /matomo.php
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
volumes:
|
||||||
|
- name: matomo-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: matomo-data
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: matomo-data
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# DO NOT COMMIT THE REAL VERSION OF THIS FILE.
|
||||||
|
# deploy/matomo-secret.yaml is gitignored — apply it manually once:
|
||||||
|
#
|
||||||
|
# cp deploy/matomo-secret.yaml.example deploy/matomo-secret.yaml
|
||||||
|
# # edit the passwords below, then:
|
||||||
|
# kubectl apply -f deploy/matomo-secret.yaml
|
||||||
|
# kubectl annotate secret matomo-secret -n solitaire \
|
||||||
|
# argocd.argoproj.io/sync-options=Prune=false --overwrite
|
||||||
|
#
|
||||||
|
# Generate strong passwords with:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_urlsafe(18))"
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: matomo-secret
|
||||||
|
namespace: solitaire
|
||||||
|
stringData:
|
||||||
|
MYSQL_ROOT_PASSWORD: "CHANGE_ME"
|
||||||
|
MYSQL_DATABASE: matomo
|
||||||
|
MYSQL_USER: matomo
|
||||||
|
MYSQL_PASSWORD: "CHANGE_ME"
|
||||||
|
MATOMO_ADMIN_PASSWORD: "CHANGE_ME"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: matomo
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: matomo
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: solitaire
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: solitaire-db
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: solitaire-server
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: solitaire-server
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
@@ -143,16 +143,18 @@ After the APK is signed cargo-apk panics with:
|
|||||||
thread 'main' panicked: Bin is not compatible with Cdylib
|
thread 'main' panicked: Bin is not compatible with Cdylib
|
||||||
```
|
```
|
||||||
|
|
||||||
This happens AFTER the APK is on disk and signed. cargo-apk is
|
This happens AFTER the APK is on disk and signed. cargo-apk tries to
|
||||||
trying to also wrap the desktop `[[bin]]` target. The APK is still
|
also wrap the desktop `[[bin]]` target alongside the `[lib]`. The APK
|
||||||
valid. Work around with `--lib`:
|
is valid — the panic is cosmetic. **Always use `--lib`**, which is the
|
||||||
|
canonical build command (see `CLAUDE.md §15.1`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
cargo apk build -p solitaire_app --lib
|
||||||
```
|
```
|
||||||
|
|
||||||
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
|
Root cause: upstream cargo-apk bug — it does not skip `[[bin]]` targets
|
||||||
gate so cargo-apk skips the bin target on Android.)
|
when building for Android. No in-repo fix is possible; `--lib` is the
|
||||||
|
accepted workaround.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Ferrous Solitaire — Session Handoff (ARCHIVED)
|
||||||
|
|
||||||
|
> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical
|
||||||
|
> reference only. The authoritative session handoff is at the repo root:
|
||||||
|
> `SESSION_HANDOFF.md`.**
|
||||||
|
|
||||||
> Last updated: 2026-04-25
|
> Last updated: 2026-04-25
|
||||||
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
||||||
@@ -20,7 +24,7 @@ All seven Cargo crates created and compiling cleanly:
|
|||||||
| `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 |
|
| `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 |
|
||||||
| `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C |
|
| `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C |
|
||||||
| `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android |
|
| `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android |
|
||||||
| `solitaire_app` | Working | Opens blank Bevy window titled "Solitaire Quest" at 1280×800 |
|
| `solitaire_app` | Working | Opens blank Bevy window titled "Ferrous Solitaire" at 1280×800 |
|
||||||
|
|
||||||
Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place.
|
Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Android Playability TODO
|
||||||
|
|
||||||
|
**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK
|
||||||
|
running on a real device showed the desktop HUD projected onto a
|
||||||
|
360 dp portrait viewport with no mobile adaptation. This list
|
||||||
|
tracks the work needed to make the APK genuinely playable, not
|
||||||
|
just "boots without crashing."
|
||||||
|
|
||||||
|
**Context:** v0.22.3 (signed release APK) builds and launches.
|
||||||
|
JNI bridges (clipboard, keystore) compile but are untested on
|
||||||
|
hardware. The work below is UI/UX port work — no architectural
|
||||||
|
rewrites required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading from the v0.22.3 screenshot
|
||||||
|
|
||||||
|
| Region | Observation |
|
||||||
|
|--------|-------------|
|
||||||
|
| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset |
|
||||||
|
| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp |
|
||||||
|
| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch |
|
||||||
|
| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right |
|
||||||
|
| Card backs | Face-down cards render as solid red squares, not back-art texture |
|
||||||
|
| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout |
|
||||||
|
| Bottom edge | No accommodation for Android gesture / home-indicator area |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Blocking playability
|
||||||
|
|
||||||
|
- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by
|
||||||
|
`b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin`
|
||||||
|
query `WindowInsets.getInsets(systemBars())` via JNI on Android;
|
||||||
|
HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the
|
||||||
|
change-detection fix-up system re-applies `base_top + insets.top`
|
||||||
|
whenever the resource updates. Bottom inset is captured but not
|
||||||
|
yet consumed (waits for bottom-anchored UI).
|
||||||
|
- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD
|
||||||
|
column and the right action button row are now capped at
|
||||||
|
`max_width: 50 %` and the button row + tier-row child Nodes carry
|
||||||
|
`flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks
|
||||||
|
to multiple lines (right-justified) and the tier rows wrap
|
||||||
|
individually instead of overflowing into the action column. On
|
||||||
|
desktop (≥ 1280 px) the 50 % cap is wider than any natural row
|
||||||
|
width so the existing single-line layout is unchanged.
|
||||||
|
- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by
|
||||||
|
`fcc7337`.* `AssetPlugin::file_path = "../assets"` was set
|
||||||
|
unconditionally to fix the desktop `cargo run -p solitaire_app`
|
||||||
|
CWD relativity, but on Android cargo-apk packages the same
|
||||||
|
directory into the APK at `assets/` and Bevy's
|
||||||
|
AndroidAssetReader is already rooted there — prepending `../`
|
||||||
|
walked the reader out of the APK assets root and every load
|
||||||
|
failed silently. The face-down branch then fell through to the
|
||||||
|
`card_back_colour(0)` solid-red brick fallback. Gated the
|
||||||
|
override behind `#[cfg(not(target_os = "android"))]`.
|
||||||
|
- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
|
||||||
|
was clamping the input window up to `MIN_WINDOW = 800 × 600`,
|
||||||
|
so a 360 dp phone got laid out as if it were 800-wide and the
|
||||||
|
outer piles fell outside the actual viewport. Lowered the floor
|
||||||
|
to 320 × 400 (below the smallest reasonable phone) so real
|
||||||
|
Android resolutions flow through without clamping, while keeping
|
||||||
|
a sentinel to guard against degenerate / startup-zero windows.
|
||||||
|
New regression test `phone_portrait_layout_fits_horizontally`
|
||||||
|
asserts all 13 piles fit a 360 × 800 viewport.
|
||||||
|
|
||||||
|
## P1 — Touch UX
|
||||||
|
|
||||||
|
- [x] **Suppress keyboard-hint labels on Android.** *Closed
|
||||||
|
2026-05-10.* `spawn_action_button` now nulls the `hotkey`
|
||||||
|
argument on Android via a `#[cfg(target_os = "android")]` rebind,
|
||||||
|
so the U / Esc / F1 / N chips next to the action row labels
|
||||||
|
disappear on touch builds. Remaining hint sites swept in P3 —
|
||||||
|
see full-keyboard-hint-sweep entry below.
|
||||||
|
- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action
|
||||||
|
button Node carries `min_width: Val::Px(48.0), min_height:
|
||||||
|
Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is
|
||||||
|
a no-op for buttons whose content already exceeds 48 px in
|
||||||
|
either axis. Applied universally rather than cfg-gated since
|
||||||
|
Material's guideline applies to all input modes. Cards, pile
|
||||||
|
markers, modal close buttons not yet audited — track as P3 if
|
||||||
|
they fall below threshold on hardware.
|
||||||
|
- [x] **Portrait-first card spacing.** *Closed 2026-05-11.*
|
||||||
|
`compute_layout` now derives an adaptive `tableau_fan_frac` from the
|
||||||
|
available vertical space below the tableau row. On height-limited
|
||||||
|
(desktop) windows the formula returns ≈ 0.25 and the clamp keeps the
|
||||||
|
existing behaviour. On width-limited (portrait phone) windows — where
|
||||||
|
card size is constrained by the 9-column horizontal packing — the fan
|
||||||
|
fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp).
|
||||||
|
`tableau_facedown_fan_frac` scales proportionally. Both values live in
|
||||||
|
the `Layout` struct; `card_plugin::card_positions` and
|
||||||
|
`input_plugin::card_position` / `pile_drop_rect` read from the struct
|
||||||
|
so rendering and hit-testing stay in sync across viewport sizes.
|
||||||
|
- [x] **Double-tap auto-move visible feedback.** *Closed 2026-05-11.*
|
||||||
|
On a recognised double-tap (priority 1 single-card or priority 2
|
||||||
|
stack move), the moved card(s) receive a 0.35 s lime flash
|
||||||
|
(`STATE_SUCCESS` tint + `HintHighlight { remaining: 0.35 }`) before
|
||||||
|
the move request is written. The flash persists through the card
|
||||||
|
animation and is cleaned up by the existing `tick_hint_highlight`
|
||||||
|
system. Hardware trigger-verification remains a manual step — connect
|
||||||
|
AVD or device and confirm two rapid `TouchPhase::Ended` events within
|
||||||
|
0.5 s produce the lime flash.
|
||||||
|
|
||||||
|
## P2 — Polish
|
||||||
|
|
||||||
|
- [x] **Drag responsiveness on touch.** *Closed 2026-05-11.*
|
||||||
|
Two code-side improvements shipped; final feel confirmation still needs
|
||||||
|
hardware:
|
||||||
|
1. `start_drag` (mouse path) now bails out when a touch is just-pressed
|
||||||
|
(`Touches::iter_just_pressed()`), ensuring `touch_start_drag` always
|
||||||
|
owns the drag state on touch-screen devices — including Bevy/Winit
|
||||||
|
versions that simulate `MouseButton::Left` from the primary touch.
|
||||||
|
2. Mobile drag commit threshold lowered 10 px → 8 px, matching Android's
|
||||||
|
`ViewConfiguration.getScaledTouchSlop()` spec. Smaller threshold →
|
||||||
|
smaller snap-on-commit and faster perceived response.
|
||||||
|
**Remaining:** connect AVD or device and verify drag feels responsive
|
||||||
|
with no stutter; tune threshold further if needed.
|
||||||
|
- [x] **Long-press menu.** *Closed 2026-05-11.* New system
|
||||||
|
`radial_open_on_long_press` in `radial_menu.rs` counts up while a
|
||||||
|
touch is held (`drag.active_touch_id.is_some() && !drag.committed`)
|
||||||
|
and opens `RightClickRadialState::Active` after 0.5 s — the same
|
||||||
|
state the right-click path uses. Existing radial infrastructure
|
||||||
|
then handles everything:
|
||||||
|
- `radial_track_cursor` extended to fall back to the first active
|
||||||
|
touch when no cursor position is available, so sliding the held
|
||||||
|
finger moves the hover ring.
|
||||||
|
- `radial_handle_release_or_cancel` extended to confirm/cancel on
|
||||||
|
`Touches::iter_just_released()` in addition to right-mouse release.
|
||||||
|
- `handle_double_tap` skips when the radial is active (guards a
|
||||||
|
narrow edge case where the finger lifts at exactly the same frame
|
||||||
|
the 0.5 s threshold fires).
|
||||||
|
Hardware verification needed: confirm the 0.5 s hold feel, verify
|
||||||
|
sliding to a destination and lifting confirms the move.
|
||||||
|
- [x] **HUD typography.** *Closed 2026-05-11.* New system
|
||||||
|
`update_hud_typography` fires on `WindowResized` and adjusts Tier-1
|
||||||
|
font sizes based on viewport width. Below 480 logical px: Score
|
||||||
|
`TYPE_HEADLINE` (26) → `TYPE_BODY_LG` (18), Moves/Timer
|
||||||
|
`TYPE_BODY_LG` (18) → `TYPE_CAPTION` (11), so all three items fit
|
||||||
|
in the 180 dp HUD column on a 360 dp phone. At ≥ 480 px the
|
||||||
|
original sizes are restored — desktop/tablet layout unchanged.
|
||||||
|
`add_message::<WindowResized>()` added defensively to `HudPlugin`
|
||||||
|
so the system works under `MinimalPlugins` in tests.
|
||||||
|
- [x] **Orientation lock.** *Closed 2026-05-11.* Added
|
||||||
|
`[package.metadata.android.application.activity]` section to
|
||||||
|
`solitaire_app/Cargo.toml` with `orientation = "portrait"`.
|
||||||
|
cargo-apk/ndk-build maps this to `android:screenOrientation="portrait"`
|
||||||
|
in the generated `AndroidManifest.xml`. Remove (or add a landscape
|
||||||
|
layout) before enabling auto-rotate.
|
||||||
|
|
||||||
|
## P3 — Asset density
|
||||||
|
|
||||||
|
- [x] **Density-aware card scaling.** *Closed 2026-05-11 — no code change
|
||||||
|
required.* `WindowResized` fires with **logical** pixels; sprites are
|
||||||
|
sized in world units (1 world unit = 1 logical pixel); Bevy's renderer
|
||||||
|
maps logical → physical via `scale_factor` internally. On a 360 dp
|
||||||
|
3×-DPI phone, cards are 40 logical dp = 120 physical px. The 256 × 384 px
|
||||||
|
card textures are **downscaled** to fit (256 → 120 px) — quality is fine.
|
||||||
|
Upscaling only occurs if `card_width × scale_factor > 256`, i.e. a
|
||||||
|
tablet with a logical width > 765 dp at 3× DPI — no current target
|
||||||
|
device falls in that range. Revisit if the game ships on large-screen
|
||||||
|
high-DPI tablets.
|
||||||
|
- [x] **App-icon density buckets.** *Closed 2026-05-11.* Created
|
||||||
|
`solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png`
|
||||||
|
from the existing `assets/icon/` PNGs (48→mdpi, 64→hdpi, 128→xhdpi,
|
||||||
|
256→xxhdpi+xxxhdpi). Added `resources = "res"` to
|
||||||
|
`[package.metadata.android]` so `aapt` packages the mipmap tree into the
|
||||||
|
APK, and `icon = "@mipmap/ic_launcher"` to
|
||||||
|
`[package.metadata.android.application]` so the launcher references it.
|
||||||
|
- [x] **Full keyboard-hint sweep.** *Closed 2026-05-11.* Extended the
|
||||||
|
P1 suppression to cover all remaining hint sites:
|
||||||
|
- `ui_modal.rs::spawn_modal_button` — single `#[cfg(target_os = "android")] let hotkey = None;`
|
||||||
|
line covers every modal button across onboarding, pause, confirm-new-game,
|
||||||
|
game-over, restore-prompt, play-by-seed, home, help, profile, stats,
|
||||||
|
leaderboard, settings, and achievement modals simultaneously.
|
||||||
|
- `home_plugin.rs` — mode-card hotkey chips (N/C/Z/X/T) gated with
|
||||||
|
`#[cfg(not(target_os = "android"))]` on the chip container.
|
||||||
|
- `replay_overlay.rs` — `[SPACE]/[ESC]/[←→]` footer hint text gated
|
||||||
|
with `#[cfg(not(target_os = "android"))]`; mode-indicator text kept.
|
||||||
|
- `help_plugin.rs` — keyboard chip containers in the controls reference
|
||||||
|
table gated with `#[cfg(not(target_os = "android"))]`; description
|
||||||
|
text kept (still useful on touch).
|
||||||
|
|
||||||
|
## P4 — Stability / runtime
|
||||||
|
|
||||||
|
- [x] **B0004 ECS hierarchy warnings.** *Investigated 2026-05-11 — no
|
||||||
|
fix required.* B0004 fires via Bevy's `validate_parent_has_component<C>`
|
||||||
|
hook when a child entity has UI component `C` (e.g. `Node`,
|
||||||
|
`InheritedVisibility`) but its parent doesn't yet. In Bevy 0.18,
|
||||||
|
`.despawn()` is recursive (docs: "When a parent is despawned, all
|
||||||
|
children will also be despawned"), so all `.despawn()` calls in the
|
||||||
|
engine are safe. The warnings seen on the Pixel 7 AVD during startup
|
||||||
|
are a component-propagation timing artifact — UI children reach the
|
||||||
|
hook before the parent's inherited components finish initialising —
|
||||||
|
not a gameplay defect. `despawn_related::<Children>()` in
|
||||||
|
`card_plugin.rs` is explicit child-only teardown (parent kept alive)
|
||||||
|
and is correct. No gameplay bugs attributed to these warnings over 2+
|
||||||
|
min AVD runtime.
|
||||||
|
- [x] **AVD functional tests for JNI bridges.** *Closed 2026-05-11.*
|
||||||
|
Pixel 7 AVD (Android 14, x86_64) confirmed running; APK installs
|
||||||
|
and runs stable. Key findings:
|
||||||
|
|
||||||
|
**Keystore JNI — verified working.** Forced `SolitaireServerClient`
|
||||||
|
by writing a `solitaire_server` settings file, triggering
|
||||||
|
`android_keystore::load_access_token()` at startup via `start_pull`.
|
||||||
|
Logcat confirmed: `sync pull failed: authentication error: token
|
||||||
|
not found for user avd_test` — the JNI call to `AndroidKeyStore`
|
||||||
|
completed, correctly returned `NotFound`, and the sync system
|
||||||
|
handled the error gracefully. No panic, no crash from the JNI layer.
|
||||||
|
|
||||||
|
**Clipboard JNI — verified working.** Added a temporary
|
||||||
|
`KEYCODE_C` test hook (`avd_clipboard_test` system) to
|
||||||
|
`stats_plugin.rs`, rebuilt the APK, pressed C on the AVD.
|
||||||
|
Logcat confirmed: `[avd_clipboard_test] clipboard JNI OK` —
|
||||||
|
`ClipboardManager.setPrimaryClip()` succeeded on Android 14.
|
||||||
|
Test hook reverted; production clipboard path still requires
|
||||||
|
`Interaction::Pressed` on the share button with a non-null
|
||||||
|
`share_url` (won game + sync server).
|
||||||
|
|
||||||
|
**Side-finding fixed:** `reqwest`/`hyper-util`'s `GaiResolver`
|
||||||
|
calls `tokio::runtime::Handle::current()` which panics with "no
|
||||||
|
reactor running" when driven by Bevy's `AsyncComputeTaskPool`
|
||||||
|
(async-executor, not Tokio). Fixed in `sync_plugin.rs`: all three
|
||||||
|
`AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback
|
||||||
|
now wrap HTTP futures in a temporary
|
||||||
|
`tokio::runtime::Builder::new_current_thread().enable_all()` runtime.
|
||||||
|
|
||||||
|
**Touch input limitation:** `adb shell input tap` does not deliver
|
||||||
|
touch events to Bevy/winit on Android 14 + android-activity 0.6.1
|
||||||
|
in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P5 — UX polish (2026-05-12)
|
||||||
|
|
||||||
|
- [x] **UX-1 — Modal Done button unreachable in gesture zone.** *Closed
|
||||||
|
2026-05-12.* New `apply_safe_area_to_modal_scrims` system in
|
||||||
|
`safe_area.rs` pads every `ModalScrim` bottom by `insets.bottom /
|
||||||
|
window.scale_factor()` (logical pixels). Fires when `SafeAreaInsets`
|
||||||
|
changes AND when a new `ModalScrim` is spawned (`Added<ModalScrim>`
|
||||||
|
filter). Verified on device: Settings Done button reachable at physical
|
||||||
|
y ≈ 1800–2000 (was y ≈ 2232+, inside gesture zone).
|
||||||
|
- [x] **UX-5b — Home mode selector glyph corruption.** *Closed
|
||||||
|
2026-05-12.* `home_plugin.rs` mode glyphs changed from Geometric Shapes
|
||||||
|
block (U+25xx — absent from FiraMono, renders as rectangles) to card
|
||||||
|
suits U+2660 ♠ / U+2665 ♥ / U+2666 ♦. Affects Zen, Challenge, and
|
||||||
|
Daily mode selector buttons shown at level 5+.
|
||||||
|
- [x] **UX-7 — Help screen HUD button entry wraps to two lines.** *Closed
|
||||||
|
2026-05-12.* Android `CONTROL_SECTIONS` entry for ≡ button shortened
|
||||||
|
from `"Menu: Stats, Settings, Profile, Achievements"` to
|
||||||
|
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs`.
|
||||||
|
Fits on one line at 360 dp.
|
||||||
|
- [x] **BUG-3 — Multi-modal stacking (Stats + Profile simultaneously).** *Closed
|
||||||
|
2026-05-12.* `handle_menu_button` in `hud_plugin.rs` now checks
|
||||||
|
`scrims: Query<(), With<ModalScrim>>` and only calls
|
||||||
|
`spawn_menu_popover` when `scrims.is_empty()`. Tapping ≡ while any
|
||||||
|
modal is open is a no-op. Verified on device.
|
||||||
|
|
||||||
|
## Notes / decisions
|
||||||
|
|
||||||
|
* This list is screenshot-driven; expect more items to surface once
|
||||||
|
P0 unblocks actually moving cards on hardware.
|
||||||
|
* The pattern across all the bugs is "no one ran the relevant code
|
||||||
|
path on Android yet." The hard work — Bevy 0.18 on Android,
|
||||||
|
JNI bridges, signed CI builds — is done. What's left is a
|
||||||
|
coordinated pass of `#[cfg(target_os = "android")]` gates plus
|
||||||
|
making `LayoutResource` query the real surface size.
|
||||||
|
* Where possible, prefer responsive layout (query window size) over
|
||||||
|
branching `#[cfg]` blocks. Branches are fine for input methods
|
||||||
|
(touch vs. mouse) but not for screen geometry — a foldable or
|
||||||
|
desktop window of equivalent size should look the same.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Date:** 2026-04-28
|
> **Date:** 2026-04-28
|
||||||
> **Author:** Claude Code
|
> **Author:** Claude Code
|
||||||
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
|
> **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Solitaire Quest — Phase 1 + 2: Workspace & Core Game Engine
|
# Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
@@ -555,7 +555,7 @@ fn main() {
|
|||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins.set(WindowPlugin {
|
DefaultPlugins.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: "Solitaire Quest".into(),
|
title: "Ferrous Solitaire".into(),
|
||||||
resolution: (1280.0, 800.0).into(),
|
resolution: (1280.0, 800.0).into(),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
@@ -571,7 +571,7 @@ fn main() {
|
|||||||
```bash
|
```bash
|
||||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||||
```
|
```
|
||||||
Expected: A blank Bevy window titled "Solitaire Quest" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
|
Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1210,7 +1210,7 @@ fn main() {
|
|||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins.set(WindowPlugin {
|
DefaultPlugins.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: "Solitaire Quest".into(),
|
title: "Ferrous Solitaire".into(),
|
||||||
resolution: (1280.0, 800.0).into(),
|
resolution: (1280.0, 800.0).into(),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
### Infrastructure
|
### Infrastructure
|
||||||
|
|
||||||
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
||||||
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
- A running Ferrous Solitaire sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||||
- Verify the server is live before starting:
|
- Verify the server is live before starting:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **Why this exists.** The 24 mockups in this directory are mobile
|
> **Why this exists.** The 24 mockups in this directory are mobile
|
||||||
> (390 × 844 logical, iPhone 14 Pro frame) with one exception
|
> (390 × 844 logical, iPhone 14 Pro frame) with one exception
|
||||||
> (`home-menu-desktop.html`). The Stitch project that produced them
|
> (`home-menu-desktop.html`). The Stitch project that produced them
|
||||||
> is named "Solitaire Quest *Mobile* Redesign" — the mobile-first
|
> is named "Ferrous Solitaire *Mobile* Redesign" — the mobile-first
|
||||||
> framing was deliberate when the new Android target opened, but
|
> framing was deliberate when the new Android target opened, but
|
||||||
> desktop is still the primary delivery surface. Porting the mobile
|
> desktop is still the primary delivery surface. Porting the mobile
|
||||||
> mockups 1:1 would land a 390-px-wide column floating in the middle
|
> mockups 1:1 would land a 390-px-wide column floating in the middle
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a self-signed Android APK from solitaire_app's cdylib targets.
|
||||||
|
#
|
||||||
|
# Replaces the cargo-apk pipeline with explicit cargo-ndk + aapt2 + apksigner
|
||||||
|
# steps. The CI runner was hitting an SDK-discovery bug inside cargo-apk's
|
||||||
|
# ndk-build crate that we couldn't isolate; running each Android toolchain
|
||||||
|
# step explicitly gives us a debuggable pipeline.
|
||||||
|
#
|
||||||
|
# Required environment:
|
||||||
|
# ANDROID_HOME Path to Android SDK root
|
||||||
|
# ANDROID_NDK_HOME Path to the specific NDK version
|
||||||
|
# BUILD_TOOLS_VERSION e.g. "34.0.0"
|
||||||
|
# PLATFORM e.g. "android-34"
|
||||||
|
#
|
||||||
|
# Optional environment:
|
||||||
|
# PROFILE "debug" (default) | "release"
|
||||||
|
# ABIS Space-separated Android ABIs to build (default:
|
||||||
|
# "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to
|
||||||
|
# fit the runner's disk budget — a full three-ABI
|
||||||
|
# debug build can exceed 25 GB of target/ output.
|
||||||
|
# APK_OUT Output APK path (default: target/$PROFILE/apk/solitaire-quest.apk)
|
||||||
|
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
||||||
|
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
||||||
|
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
||||||
|
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
|
||||||
|
#
|
||||||
|
# Outputs:
|
||||||
|
# $APK_OUT Signed, zipaligned APK
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
|
||||||
|
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
|
||||||
|
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
|
||||||
|
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
|
||||||
|
|
||||||
|
PROFILE="${PROFILE:-debug}"
|
||||||
|
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
||||||
|
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/solitaire-quest.apk}"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
|
||||||
|
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
|
||||||
|
MANIFEST="solitaire_app/android/AndroidManifest.xml"
|
||||||
|
RES_DIR="solitaire_app/res"
|
||||||
|
ASSETS_DIR="assets"
|
||||||
|
|
||||||
|
# --- sanity ----------------------------------------------------------------
|
||||||
|
for f in "$BT/aapt2" "$BT/zipalign" "$BT/apksigner" "$PLATFORM_JAR" "$MANIFEST"; do
|
||||||
|
[ -e "$f" ] || { echo "missing: $f"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
STAGING="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGING"' EXIT
|
||||||
|
mkdir -p "$STAGING/lib" "$STAGING/compiled-res"
|
||||||
|
|
||||||
|
# --- 1. native libraries via cargo-ndk -------------------------------------
|
||||||
|
# `-o $STAGING/lib` lays out files as $STAGING/lib/<abi>/libsolitaire_app.so
|
||||||
|
# which is the directory structure the APK expects under lib/.
|
||||||
|
CARGO_NDK_ARGS=( --platform 26 -o "$STAGING/lib" )
|
||||||
|
for abi in $ABIS; do
|
||||||
|
CARGO_NDK_ARGS+=( -t "$abi" )
|
||||||
|
done
|
||||||
|
CARGO_NDK_ARGS+=( build --package solitaire_app --lib )
|
||||||
|
if [ "$PROFILE" = "release" ]; then
|
||||||
|
CARGO_NDK_ARGS+=( --release )
|
||||||
|
fi
|
||||||
|
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
|
||||||
|
cargo ndk "${CARGO_NDK_ARGS[@]}"
|
||||||
|
|
||||||
|
# --- 2. compile + link resources and manifest ------------------------------
|
||||||
|
if [ -d "$RES_DIR" ]; then
|
||||||
|
echo ">>> aapt2 compile resources"
|
||||||
|
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
|
||||||
|
fi
|
||||||
|
|
||||||
|
LINK_ARGS=(
|
||||||
|
link
|
||||||
|
-o "$STAGING/app-unsigned.apk"
|
||||||
|
-I "$PLATFORM_JAR"
|
||||||
|
--manifest "$MANIFEST"
|
||||||
|
)
|
||||||
|
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
|
||||||
|
# Add compiled resources if any
|
||||||
|
shopt -s nullglob
|
||||||
|
RES_FLATS=( "$STAGING/compiled-res"/*.flat )
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ ${#RES_FLATS[@]} -gt 0 ]; then
|
||||||
|
LINK_ARGS+=( "${RES_FLATS[@]}" )
|
||||||
|
fi
|
||||||
|
echo ">>> aapt2 link"
|
||||||
|
"$BT/aapt2" "${LINK_ARGS[@]}"
|
||||||
|
|
||||||
|
# --- 3. add native libraries to the APK ------------------------------------
|
||||||
|
echo ">>> bundle native libraries"
|
||||||
|
( cd "$STAGING" && zip -r -q app-unsigned.apk lib/ )
|
||||||
|
|
||||||
|
# --- 4. zipalign -----------------------------------------------------------
|
||||||
|
echo ">>> zipalign"
|
||||||
|
"$BT/zipalign" -p -f 4 "$STAGING/app-unsigned.apk" "$STAGING/app-aligned.apk"
|
||||||
|
# Free the unsigned intermediate now — apksigner reads $app-aligned.apk and
|
||||||
|
# writes $APK_OUT, and the runner's disk is tight after a multi-ABI build.
|
||||||
|
rm -f "$STAGING/app-unsigned.apk"
|
||||||
|
|
||||||
|
# --- 5. sign ---------------------------------------------------------------
|
||||||
|
if [ -z "${KEYSTORE:-}" ]; then
|
||||||
|
# Generate a deterministic debug keystore on the fly.
|
||||||
|
KEYSTORE="$STAGING/debug.keystore"
|
||||||
|
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
||||||
|
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
||||||
|
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||||
|
echo ">>> generating debug keystore at $KEYSTORE"
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore "$KEYSTORE" \
|
||||||
|
-storepass "$KEYSTORE_PASS" \
|
||||||
|
-alias "$KEY_ALIAS" \
|
||||||
|
-keypass "$KEY_PASS" \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-dname "CN=Android Debug,O=Android,C=US" > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
|
||||||
|
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
|
||||||
|
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$APK_OUT")"
|
||||||
|
echo ">>> apksigner sign -> $APK_OUT"
|
||||||
|
"$BT/apksigner" sign \
|
||||||
|
--ks "$KEYSTORE" \
|
||||||
|
--ks-pass "pass:$KEYSTORE_PASS" \
|
||||||
|
--ks-key-alias "$KEY_ALIAS" \
|
||||||
|
--key-pass "pass:$KEY_PASS" \
|
||||||
|
--out "$APK_OUT" \
|
||||||
|
"$STAGING/app-aligned.apk"
|
||||||
|
|
||||||
|
echo ">>> verify"
|
||||||
|
"$BT/apksigner" verify --verbose "$APK_OUT"
|
||||||
|
|
||||||
|
echo ">>> done: $APK_OUT"
|
||||||
@@ -60,6 +60,15 @@ package = "com.solitairequest.app"
|
|||||||
apk_name = "solitaire-quest"
|
apk_name = "solitaire-quest"
|
||||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||||
assets = "../assets"
|
assets = "../assets"
|
||||||
|
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
|
||||||
|
# packages them into the APK; the launcher selects the best-fit bucket
|
||||||
|
# for the device screen density. Sizes used:
|
||||||
|
# mdpi (1×, 48 dp) → 48 px (exact)
|
||||||
|
# hdpi (1.5×, 72 dp) → 64 px (88 %, aapt scales up slightly)
|
||||||
|
# xhdpi (2×, 96 dp) → 128 px (133 %, aapt scales down)
|
||||||
|
# xxhdpi (3×, 144 dp) → 256 px (178 %, aapt scales down)
|
||||||
|
# xxxhdpi (4×, 192 dp) → 256 px (133 %, aapt scales down)
|
||||||
|
resources = "res"
|
||||||
# No `runtime_libs` — we don't ship any precompiled .so files,
|
# No `runtime_libs` — we don't ship any precompiled .so files,
|
||||||
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
||||||
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
||||||
@@ -78,7 +87,15 @@ required = true
|
|||||||
name = "android.permission.INTERNET"
|
name = "android.permission.INTERNET"
|
||||||
|
|
||||||
[package.metadata.android.application]
|
[package.metadata.android.application]
|
||||||
label = "Solitaire Quest"
|
label = "Ferrous Solitaire"
|
||||||
|
# Launcher icon — references the density-bucketed mipmap resource above.
|
||||||
|
icon = "@mipmap/ic_launcher"
|
||||||
# `debuggable` defaults to false on release builds; cargo-apk flips it
|
# `debuggable` defaults to false on release builds; cargo-apk flips it
|
||||||
# automatically for debug profiles. Leaving the field unset keeps the
|
# automatically for debug profiles. Leaving the field unset keeps the
|
||||||
# default behaviour.
|
# default behaviour.
|
||||||
|
|
||||||
|
[package.metadata.android.application.activity]
|
||||||
|
# Lock to portrait — the current layout has only been designed and tested
|
||||||
|
# in portrait orientation. Remove (or add a landscape layout) before
|
||||||
|
# enabling auto-rotate.
|
||||||
|
orientation = "portrait"
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Mirrors what cargo-apk would generate from [package.metadata.android]
|
||||||
|
in solitaire_app/Cargo.toml. Kept in-tree so the CI workflow can drive
|
||||||
|
aapt2 directly without going through cargo-apk's brittle SDK discovery.
|
||||||
|
|
||||||
|
Keep in sync with:
|
||||||
|
* Cargo.toml: package, min_sdk_version, target_sdk_version,
|
||||||
|
uses_feature, uses_permission, application label/icon,
|
||||||
|
activity orientation
|
||||||
|
* [lib].name (currently "solitaire_app") — matches the
|
||||||
|
`android.app.lib_name` meta-data value below, which is the
|
||||||
|
shared object name without the `lib` prefix or `.so` suffix.
|
||||||
|
-->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.solitairequest.app"
|
||||||
|
android:versionCode="1"
|
||||||
|
android:versionName="1.0">
|
||||||
|
|
||||||
|
<uses-sdk
|
||||||
|
android:minSdkVersion="26"
|
||||||
|
android:targetSdkVersion="34" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="Ferrous Solitaire"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:hasCode="false">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="android.app.NativeActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="orientation|keyboardHidden|screenSize|keyboard|navigation|screenLayout">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.lib_name"
|
||||||
|
android:value="solitaire_app" />
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -18,21 +18,23 @@ 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"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::winit::WinitWindows;
|
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, AnalyticsPlugin, 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, SafeAreaInsetsPlugin,
|
||||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
SelectionPlugin, SettingsPlugin,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// App entry point — builds and runs the Bevy app.
|
/// App entry point — builds and runs the Bevy app.
|
||||||
@@ -76,6 +78,7 @@ pub fn run() {
|
|||||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
// 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) => (
|
||||||
@@ -103,7 +106,7 @@ pub fn run() {
|
|||||||
DefaultPlugins
|
DefaultPlugins
|
||||||
.set(WindowPlugin {
|
.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: "Solitaire Quest".into(),
|
title: "Ferrous Solitaire".into(),
|
||||||
// X11/Wayland WM_CLASS so taskbar managers group
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
// multiple windows of this app correctly.
|
// multiple windows of this app correctly.
|
||||||
name: Some("solitaire-quest".into()),
|
name: Some("solitaire-quest".into()),
|
||||||
@@ -116,6 +119,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,
|
||||||
@@ -126,11 +132,20 @@ pub fn run() {
|
|||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
// The `assets/` directory lives at the workspace root, but
|
// The `assets/` directory lives at the workspace root, but
|
||||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
// on desktop Bevy resolves `AssetPlugin::file_path` relative
|
||||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
// to the binary package's `CARGO_MANIFEST_DIR`
|
||||||
// Point one level up so `cargo run -p solitaire_app` finds
|
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
|
||||||
// card faces, backs, backgrounds, and the UI font.
|
// miss the workspace-root `assets/` without a `../` prefix.
|
||||||
|
//
|
||||||
|
// On Android cargo-apk packages the same directory into the
|
||||||
|
// APK at `assets/` (via `[package.metadata.android].assets`
|
||||||
|
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
|
||||||
|
// is already rooted there, so any `file_path` other than the
|
||||||
|
// default makes it walk *out* of the APK's assets root and
|
||||||
|
// all loads fail silently — which is what produced the
|
||||||
|
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||||
.set(bevy::asset::AssetPlugin {
|
.set(bevy::asset::AssetPlugin {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
file_path: "../assets".to_string(),
|
file_path: "../assets".to_string(),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
@@ -142,6 +157,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)
|
||||||
@@ -158,7 +180,10 @@ 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(SafeAreaInsetsPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin::default())
|
.add_plugins(HomePlugin::default())
|
||||||
@@ -168,6 +193,8 @@ pub fn run() {
|
|||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
|
.add_plugins(SyncSetupPlugin)
|
||||||
|
.add_plugins(AnalyticsPlugin)
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
@@ -195,6 +222,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);
|
||||||
}
|
}
|
||||||
@@ -215,6 +244,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>>,
|
||||||
@@ -335,6 +365,20 @@ fn set_window_icon(
|
|||||||
*applied = true;
|
*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"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Generates PNG assets for Solitaire Quest.
|
//! Generates PNG assets for Ferrous Solitaire.
|
||||||
//!
|
//!
|
||||||
//! Produces:
|
//! Produces:
|
||||||
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
|
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Generate difficulty-stratified seed catalogs for `EASY_SEEDS`, `MEDIUM_SEEDS`,
|
||||||
|
//! `HARD_SEEDS`, `EXPERT_SEEDS`, and `GRANDMASTER_SEEDS` in
|
||||||
|
//! `solitaire_data/src/difficulty_seeds.rs`.
|
||||||
|
//!
|
||||||
|
//! A seed's tier is determined by the **smallest** `SolverConfig` budget that
|
||||||
|
//! returns `SolverResult::Winnable`. Seeds that are `Unwinnable` at any budget
|
||||||
|
//! are discarded; `Inconclusive` at all budgets are also discarded (we only emit
|
||||||
|
//! provably-winnable seeds).
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p solitaire_assetgen --bin gen_difficulty_seeds --release -- \
|
||||||
|
//! --start 0xD1FF0000_00000000 --per-tier 40
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Flags:
|
||||||
|
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xD1FF000000000000)
|
||||||
|
//! --per-tier Seeds to emit per tier (default 40)
|
||||||
|
//! --help Print this message
|
||||||
|
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||||
|
|
||||||
|
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||||
|
// whose budget proves it Winnable.
|
||||||
|
const BUDGETS: &[(&str, u64, usize)] = &[
|
||||||
|
("Easy", 1_000, 1_000),
|
||||||
|
("Medium", 5_000, 5_000),
|
||||||
|
("Hard", 25_000, 25_000),
|
||||||
|
("Expert", 100_000, 100_000),
|
||||||
|
("Grandmaster", 200_000, 200_000),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
|
let mut start: u64 = 0xD1FF_0000_0000_0000;
|
||||||
|
let mut per_tier: usize = 40;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--start" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --start requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
start = parse_u64(&val);
|
||||||
|
}
|
||||||
|
"--per-tier" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --per-tier requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
per_tier = val.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: --per-tier must be a positive integer");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
eprintln!("gen_difficulty_seeds: generate tiered seed catalogs");
|
||||||
|
eprintln!(" --start <seed> starting seed (hex or decimal)");
|
||||||
|
eprintln!(" --per-tier <n> seeds per tier (default 40)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("error: unknown argument: {other}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if per_tier == 0 {
|
||||||
|
eprintln!("error: --per-tier must be > 0");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let draw_mode = DrawMode::DrawOne;
|
||||||
|
let num_tiers = BUDGETS.len();
|
||||||
|
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||||
|
let mut tried: u64 = 0;
|
||||||
|
let mut seed = start;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"gen_difficulty_seeds: finding {} seeds per tier from 0x{start:016X} (DrawOne) …",
|
||||||
|
per_tier
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
" Tiers: {}",
|
||||||
|
BUDGETS.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
while buckets.iter().any(|b| b.len() < per_tier) {
|
||||||
|
tried += 1;
|
||||||
|
'tier: for (i, &(name, move_budget, state_budget)) in BUDGETS.iter().enumerate() {
|
||||||
|
if buckets[i].len() >= per_tier {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cfg = SolverConfig { move_budget, state_budget };
|
||||||
|
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||||
|
SolverResult::Winnable => {
|
||||||
|
buckets[i].push(seed);
|
||||||
|
eprintln!(
|
||||||
|
" [{name} {:>3}/{}] 0x{seed:016X} (tried {tried})",
|
||||||
|
buckets[i].len(),
|
||||||
|
per_tier
|
||||||
|
);
|
||||||
|
break 'tier; // assign to the cheapest tier that proves it winnable
|
||||||
|
}
|
||||||
|
SolverResult::Unwinnable => {
|
||||||
|
// Definitely unsolvable — skip all remaining tiers.
|
||||||
|
break 'tier;
|
||||||
|
}
|
||||||
|
SolverResult::Inconclusive => {
|
||||||
|
// Budget exhausted without proof — try the next larger tier.
|
||||||
|
// If this is the last tier, the seed is discarded (Inconclusive
|
||||||
|
// at max budget means "probably but not provably winnable").
|
||||||
|
if i == num_tiers - 1 {
|
||||||
|
break 'tier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seed = seed.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("\nDone ({tried} seeds examined). Paste the blocks below into difficulty_seeds.rs:\n");
|
||||||
|
|
||||||
|
let date = current_date();
|
||||||
|
for (i, (tier_name, _, _)) in BUDGETS.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
" // Generated by solitaire_assetgen::gen_difficulty_seeds \
|
||||||
|
(tier={tier_name}, date={date})"
|
||||||
|
);
|
||||||
|
for chunk in buckets[i].chunks(5) {
|
||||||
|
for s in chunk {
|
||||||
|
println!(
|
||||||
|
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||||
|
(s >> 48) & 0xFFFF,
|
||||||
|
(s >> 32) & 0xFFFF,
|
||||||
|
(s >> 16) & 0xFFFF,
|
||||||
|
s & 0xFFFF,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64(s: &str) -> u64 {
|
||||||
|
let cleaned = s.replace('_', "");
|
||||||
|
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||||
|
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cleaned.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_date() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = secs / 86400;
|
||||||
|
let mut y = 1970u64;
|
||||||
|
let mut d = days;
|
||||||
|
loop {
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let days_in_year = if leap { 366 } else { 365 };
|
||||||
|
if d < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= days_in_year;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let month_days: [u64; 12] = [
|
||||||
|
31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||||||
|
];
|
||||||
|
let mut m = 0usize;
|
||||||
|
for &md in &month_days {
|
||||||
|
if d < md {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= md;
|
||||||
|
m += 1;
|
||||||
|
}
|
||||||
|
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! Generate provably-winnable Klondike seeds for `CHALLENGE_SEEDS`.
|
||||||
|
//!
|
||||||
|
//! Walks seeds incrementally from `--start`, calls the solver on each, and
|
||||||
|
//! collects only those that return `SolverResult::Winnable` (Inconclusive is
|
||||||
|
//! rejected — the curated list wants proof). Prints Rust source suitable for
|
||||||
|
//! pasting into `solitaire_data/src/challenge.rs`.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p solitaire_assetgen --bin gen_seeds --release -- \
|
||||||
|
//! --start 0xCAFE_BABE_0000_0000 --count 75
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Flags:
|
||||||
|
//! --start Starting seed (decimal or 0x-prefixed hex, default 0xCAFEBABE00000000)
|
||||||
|
//! --count Number of Winnable seeds to emit (default 75)
|
||||||
|
//! --help Print this message
|
||||||
|
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
|
let mut start: u64 = 0xCAFE_BABE_0000_0000;
|
||||||
|
let mut count: usize = 75;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--start" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --start requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
start = parse_u64(&val);
|
||||||
|
}
|
||||||
|
"--count" => {
|
||||||
|
let val = args.next().unwrap_or_else(|| {
|
||||||
|
eprintln!("error: --count requires a value");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
count = val.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: --count must be a positive integer");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
eprintln!("{}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/gen_seeds.rs")).lines().take(20).collect::<Vec<_>>().join("\n"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("error: unknown argument: {other}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
eprintln!("error: --count must be > 0");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let draw_mode = DrawMode::DrawOne;
|
||||||
|
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||||
|
let mut tried: u64 = 0;
|
||||||
|
let mut seed = start;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"gen_seeds: finding {count} Winnable seeds from 0x{start:016X} (DrawOne) …"
|
||||||
|
);
|
||||||
|
|
||||||
|
while found.len() < count {
|
||||||
|
tried += 1;
|
||||||
|
if matches!(
|
||||||
|
try_solve(seed, draw_mode.clone(), &cfg),
|
||||||
|
SolverResult::Winnable
|
||||||
|
) {
|
||||||
|
found.push(seed);
|
||||||
|
eprintln!(
|
||||||
|
" [{:>3}/{}] 0x{:016X} ({} tried so far)",
|
||||||
|
found.len(),
|
||||||
|
count,
|
||||||
|
seed,
|
||||||
|
tried
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seed = seed.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("\nDone. Paste the block below into CHALLENGE_SEEDS in solitaire_data/src/challenge.rs:\n");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" // Generated by solitaire_assetgen::gen_seeds \
|
||||||
|
(start=0x{start:016X}, count={count}, date={date})",
|
||||||
|
date = current_date()
|
||||||
|
);
|
||||||
|
for chunk in found.chunks(5) {
|
||||||
|
for s in chunk {
|
||||||
|
println!(
|
||||||
|
" 0x{:04X}_{:04X}_{:04X}_{:04X},",
|
||||||
|
(s >> 48) & 0xFFFF,
|
||||||
|
(s >> 32) & 0xFFFF,
|
||||||
|
(s >> 16) & 0xFFFF,
|
||||||
|
s & 0xFFFF,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64(s: &str) -> u64 {
|
||||||
|
let cleaned = s.replace('_', "");
|
||||||
|
if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) {
|
||||||
|
u64::from_str_radix(hex, 16).unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a hex u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cleaned.parse().unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: could not parse '{s}' as a decimal u64");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_date() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = secs / 86400;
|
||||||
|
// Gregorian calendar computation (Tomohiko Sakamoto's algorithm variant)
|
||||||
|
let mut y = 1970u64;
|
||||||
|
let mut d = days;
|
||||||
|
loop {
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let days_in_year = if leap { 366 } else { 365 };
|
||||||
|
if d < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= days_in_year;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
let leap = (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
|
||||||
|
let month_days: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
let mut m = 0usize;
|
||||||
|
for &md in &month_days {
|
||||||
|
if d < md {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d -= md;
|
||||||
|
m += 1;
|
||||||
|
}
|
||||||
|
format!("{y}-{:02}-{:02}", m + 1, d + 1)
|
||||||
|
}
|
||||||
@@ -50,6 +50,35 @@ pub enum DrawMode {
|
|||||||
DrawThree,
|
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.
|
||||||
@@ -112,6 +145,10 @@ pub struct GameState {
|
|||||||
/// Used by the `comeback` achievement condition.
|
/// Used by the `comeback` achievement condition.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recycle_count: u32,
|
pub recycle_count: u32,
|
||||||
|
/// When `true`, the player may move the top card of a foundation pile back
|
||||||
|
/// onto a compatible tableau column. Off by default — non-standard house rule.
|
||||||
|
#[serde(default)]
|
||||||
|
pub take_from_foundation: bool,
|
||||||
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||||
/// the field. The loader refuses any value other than
|
/// the field. The loader refuses any value other than
|
||||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||||
@@ -154,6 +191,7 @@ impl GameState {
|
|||||||
is_auto_completable: false,
|
is_auto_completable: false,
|
||||||
undo_count: 0,
|
undo_count: 0,
|
||||||
recycle_count: 0,
|
recycle_count: 0,
|
||||||
|
take_from_foundation: false,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
undo_stack: VecDeque::new(),
|
undo_stack: VecDeque::new(),
|
||||||
}
|
}
|
||||||
@@ -279,6 +317,18 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
|
if matches!(&from, PileType::Foundation(_)) {
|
||||||
|
if !self.take_from_foundation {
|
||||||
|
return Err(MoveError::RuleViolation(
|
||||||
|
"take-from-foundation rule is disabled".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return Err(MoveError::RuleViolation(
|
||||||
|
"only one card can return from foundation at a time".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||||
if !can_place_on_tableau(&bottom_card, dest) {
|
if !can_place_on_tableau(&bottom_card, dest) {
|
||||||
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
||||||
@@ -376,12 +426,11 @@ impl GameState {
|
|||||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||||
/// At that point the game can be completed without further player input.
|
/// At that point the game can be completed without further player input.
|
||||||
pub fn check_auto_complete(&self) -> bool {
|
pub fn check_auto_complete(&self) -> bool {
|
||||||
|
// Stock must be empty; waste may still have cards (they are resolved
|
||||||
|
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
||||||
if !self.piles[&PileType::Stock].cards.is_empty() {
|
if !self.piles[&PileType::Stock].cards.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !self.piles[&PileType::Waste].cards.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
(0..7).all(|i| {
|
(0..7).all(|i| {
|
||||||
self.piles[&PileType::Tableau(i)]
|
self.piles[&PileType::Tableau(i)]
|
||||||
.cards
|
.cards
|
||||||
@@ -409,42 +458,53 @@ impl GameState {
|
|||||||
if !self.is_auto_completable || self.is_won {
|
if !self.is_auto_completable || self.is_won {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Check waste top first — when stock is exhausted the waste may still
|
||||||
|
// contain cards that can go directly to a foundation.
|
||||||
|
let waste = PileType::Waste;
|
||||||
|
if let Some((card, slot)) = self.piles[&waste].cards.last()
|
||||||
|
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
||||||
|
{
|
||||||
|
let _ = card; // borrow ends here
|
||||||
|
return Some((waste, PileType::Foundation(slot)));
|
||||||
|
}
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let tableau = PileType::Tableau(i);
|
let tableau = PileType::Tableau(i);
|
||||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
if let Some(slot) = self.piles[&tableau].cards.last()
|
||||||
// Prefer the slot that already claims this card's suit so
|
.and_then(|c| self.foundation_slot_for(c))
|
||||||
// Aces don't sometimes land in slot 0 and then leave the
|
{
|
||||||
// matching suit-claimed slot empty.
|
return Some((tableau, PileType::Foundation(slot)));
|
||||||
let mut candidate: Option<u8> = None;
|
|
||||||
let mut empty_slot: Option<u8> = None;
|
|
||||||
for slot in 0..4_u8 {
|
|
||||||
let foundation = PileType::Foundation(slot);
|
|
||||||
let pile = &self.piles[&foundation];
|
|
||||||
if pile.cards.is_empty() {
|
|
||||||
if empty_slot.is_none() {
|
|
||||||
empty_slot = Some(slot);
|
|
||||||
}
|
|
||||||
} else if pile.claimed_suit() == Some(card.suit) {
|
|
||||||
candidate = Some(slot);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let target_slot = candidate.or_else(|| {
|
|
||||||
// Only fall back to an empty slot if the card is an Ace,
|
|
||||||
// which is the only rank that can claim an empty slot.
|
|
||||||
if card.rank.value() == 1 { empty_slot } else { None }
|
|
||||||
});
|
|
||||||
if let Some(slot) = target_slot {
|
|
||||||
let foundation = PileType::Foundation(slot);
|
|
||||||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
|
||||||
return Some((tableau, foundation));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the foundation slot index that `card` can legally move to, or
|
||||||
|
/// `None` if no such slot exists.
|
||||||
|
///
|
||||||
|
/// Prefers the slot already claiming this card's suit so Aces always land
|
||||||
|
/// in a consistent column. Falls back to an empty slot only for Aces.
|
||||||
|
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
|
||||||
|
let mut candidate: Option<u8> = None;
|
||||||
|
let mut empty_slot: Option<u8> = None;
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
let pile = &self.piles[&PileType::Foundation(slot)];
|
||||||
|
if pile.cards.is_empty() {
|
||||||
|
if empty_slot.is_none() {
|
||||||
|
empty_slot = Some(slot);
|
||||||
|
}
|
||||||
|
} else if pile.claimed_suit() == Some(card.suit) {
|
||||||
|
candidate = Some(slot);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let target = candidate.or_else(|| {
|
||||||
|
if card.rank.value() == 1 { empty_slot } else { None }
|
||||||
|
});
|
||||||
|
target.filter(|&slot| {
|
||||||
|
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||||
pub fn compute_time_bonus(&self) -> i32 {
|
pub fn compute_time_bonus(&self) -> i32 {
|
||||||
scoring_time_bonus(self.elapsed_seconds)
|
scoring_time_bonus(self.elapsed_seconds)
|
||||||
@@ -972,24 +1032,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_complete_false_when_waste_not_empty() {
|
fn auto_complete_true_when_stock_empty_waste_has_cards() {
|
||||||
|
// Waste no longer blocks auto-complete — draw() drains it during
|
||||||
|
// auto-complete steps. Only stock-not-empty and face-down tableau
|
||||||
|
// cards block the flag.
|
||||||
let mut g = new_game();
|
let mut g = new_game();
|
||||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
// Leave the waste pile untouched (it may be empty after clearing stock,
|
|
||||||
// so add a card explicitly to ensure the waste guard is exercised).
|
|
||||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||||
id: 99,
|
id: 99,
|
||||||
suit: Suit::Clubs,
|
suit: Suit::Clubs,
|
||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
});
|
});
|
||||||
// Make all tableau cards face-up so only the waste guard is the blocker.
|
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
||||||
c.face_up = true;
|
c.face_up = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(!g.check_auto_complete());
|
assert!(g.check_auto_complete());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1225,4 +1285,71 @@ mod tests {
|
|||||||
"must target the Hearts-claimed slot, not the empty slot 0",
|
"must target the Hearts-claimed slot, not the empty slot 0",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_take_from_foundation_game() -> GameState {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Clear the board so we control the layout exactly.
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
// Foundation slot 0: A♠, 2♠ (top = 2♠)
|
||||||
|
let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap();
|
||||||
|
f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||||
|
f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true });
|
||||||
|
// Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1)
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true,
|
||||||
|
});
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_from_foundation_blocked_by_default() {
|
||||||
|
let mut g = setup_take_from_foundation_game();
|
||||||
|
assert!(!g.take_from_foundation);
|
||||||
|
let err = g
|
||||||
|
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, MoveError::RuleViolation(_)),
|
||||||
|
"expected RuleViolation, got {err:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_from_foundation_allowed_when_enabled() {
|
||||||
|
let mut g = setup_take_from_foundation_game();
|
||||||
|
g.take_from_foundation = true;
|
||||||
|
g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap();
|
||||||
|
// Foundation slot 0 should now hold only the Ace.
|
||||||
|
assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1);
|
||||||
|
assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace);
|
||||||
|
// The 2♠ should be on top of tableau 0 above the 3♥.
|
||||||
|
let t0 = &g.piles[&PileType::Tableau(0)].cards;
|
||||||
|
assert_eq!(t0.len(), 2);
|
||||||
|
assert_eq!(t0[1].rank, Rank::Two);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_from_foundation_rejects_illegal_tableau_placement() {
|
||||||
|
let mut g = setup_take_from_foundation_game();
|
||||||
|
g.take_from_foundation = true;
|
||||||
|
// Tableau 1 is empty — only a King can go there; 2♠ is not a King.
|
||||||
|
let err = g
|
||||||
|
.move_cards(PileType::Foundation(0), PileType::Tableau(1), 1)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_from_foundation_rejects_count_gt_1() {
|
||||||
|
let mut g = setup_take_from_foundation_game();
|
||||||
|
g.take_from_foundation = true;
|
||||||
|
let err = g
|
||||||
|
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 2)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async-trait = { workspace = true }
|
|||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
# `keyring-core` is the typed Entry/Error API used by
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
@@ -26,6 +27,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,6 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
fn backend_name(&self) -> &'static str;
|
fn backend_name(&self) -> &'static str;
|
||||||
/// Returns true if the user is currently authenticated with this backend.
|
/// Returns true if the user is currently authenticated with this backend.
|
||||||
fn is_authenticated(&self) -> bool;
|
fn is_authenticated(&self) -> bool;
|
||||||
/// Mirror an achievement unlock to this backend (no-op for most backends).
|
|
||||||
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
/// Fetch the global leaderboard from this backend. Returns an empty list
|
/// Fetch the global leaderboard from this backend. Returns an empty list
|
||||||
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
|
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
|
||||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
@@ -83,9 +79,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
fn is_authenticated(&self) -> bool {
|
fn is_authenticated(&self) -> bool {
|
||||||
(**self).is_authenticated()
|
(**self).is_authenticated()
|
||||||
}
|
}
|
||||||
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
|
|
||||||
(**self).mirror_achievement(id).await
|
|
||||||
}
|
|
||||||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
(**self).fetch_leaderboard().await
|
(**self).fetch_leaderboard().await
|
||||||
}
|
}
|
||||||
@@ -138,6 +131,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 +143,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,
|
||||||
@@ -164,5 +163,8 @@ pub use replay::{
|
|||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod matomo_client;
|
||||||
|
pub use matomo_client::MatomoClient;
|
||||||
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub use platform::data_dir;
|
pub use platform::data_dir;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Matomo HTTP Tracking API client.
|
||||||
|
//!
|
||||||
|
//! Buffers game-play events and flushes them via the Matomo bulk tracking
|
||||||
|
//! endpoint. Errors are silently discarded — analytics must never affect
|
||||||
|
//! gameplay or block the UI.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Sends game-play events to a self-hosted Matomo instance via the
|
||||||
|
/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api).
|
||||||
|
///
|
||||||
|
/// Construct once per session and share via `Arc`. `event` is cheap and
|
||||||
|
/// can be called from the Bevy main thread; `flush` is async and must be
|
||||||
|
/// called from a background task.
|
||||||
|
pub struct MatomoClient {
|
||||||
|
tracking_url: String,
|
||||||
|
site_id: u32,
|
||||||
|
/// 16 hex-char visitor ID, stable for the lifetime of this client.
|
||||||
|
visitor_id: String,
|
||||||
|
uid: Option<String>,
|
||||||
|
client: Client,
|
||||||
|
/// Pre-encoded query strings, one per buffered event.
|
||||||
|
pending: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MatomoClient {
|
||||||
|
/// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`).
|
||||||
|
pub fn new(base_url: impl AsRef<str>, site_id: u32, uid: Option<String>) -> Self {
|
||||||
|
let base = base_url.as_ref().trim_end_matches('/');
|
||||||
|
let tracking_url = format!("{}/matomo.php", base);
|
||||||
|
// Take the lower 64 bits of a v4 UUID and format as 16 hex chars.
|
||||||
|
let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64);
|
||||||
|
Self {
|
||||||
|
tracking_url,
|
||||||
|
site_id,
|
||||||
|
visitor_id,
|
||||||
|
uid,
|
||||||
|
client: Client::new(),
|
||||||
|
pending: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer one Matomo custom event. Never blocks; never fails visibly.
|
||||||
|
///
|
||||||
|
/// When the buffer exceeds 100 events the oldest 50 are dropped to
|
||||||
|
/// prevent unbounded memory growth during extended offline play.
|
||||||
|
pub fn event(
|
||||||
|
&self,
|
||||||
|
category: &str,
|
||||||
|
action: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
value: Option<f64>,
|
||||||
|
) {
|
||||||
|
let Ok(mut guard) = self.pending.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut qs = format!(
|
||||||
|
"idsite={}&rec=1&apiv=1&send_image=0\
|
||||||
|
&url=game%3A%2F%2Fsolitaire%2Fevent\
|
||||||
|
&_id={}&e_c={}&e_a={}",
|
||||||
|
self.site_id,
|
||||||
|
self.visitor_id,
|
||||||
|
url_encode(category),
|
||||||
|
url_encode(action),
|
||||||
|
);
|
||||||
|
if let Some(n) = name {
|
||||||
|
qs.push_str(&format!("&e_n={}", url_encode(n)));
|
||||||
|
}
|
||||||
|
if let Some(v) = value {
|
||||||
|
qs.push_str(&format!("&e_v={v}"));
|
||||||
|
}
|
||||||
|
if let Some(uid) = &self.uid {
|
||||||
|
qs.push_str(&format!("&uid={}", url_encode(uid)));
|
||||||
|
}
|
||||||
|
|
||||||
|
guard.push(qs);
|
||||||
|
if guard.len() > 100 {
|
||||||
|
guard.drain(0..50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint.
|
||||||
|
///
|
||||||
|
/// The buffer is drained *before* the HTTP call so events recorded during
|
||||||
|
/// an in-flight flush are not lost. Network errors are silently discarded.
|
||||||
|
pub async fn flush(&self) {
|
||||||
|
let pending = {
|
||||||
|
let Ok(mut guard) = self.pending.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if guard.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::mem::take(&mut *guard)
|
||||||
|
};
|
||||||
|
|
||||||
|
let requests: Vec<String> = pending.into_iter().map(|qs| format!("?{qs}")).collect();
|
||||||
|
let body = serde_json::json!({ "requests": requests });
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.post(&self.tracking_url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_encode(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.flat_map(|c| match c {
|
||||||
|
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
|
||||||
|
vec![c]
|
||||||
|
}
|
||||||
|
c => format!("%{:02X}", c as u32).chars().collect(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use 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";
|
||||||
@@ -49,7 +49,7 @@ pub enum SyncBackend {
|
|||||||
#[default]
|
#[default]
|
||||||
#[serde(rename = "local")]
|
#[serde(rename = "local")]
|
||||||
Local,
|
Local,
|
||||||
/// Sync with a self-hosted Solitaire Quest server.
|
/// Sync with a self-hosted Ferrous Solitaire server.
|
||||||
#[serde(rename = "solitaire_server")]
|
#[serde(rename = "solitaire_server")]
|
||||||
SolitaireServer {
|
SolitaireServer {
|
||||||
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||||
@@ -143,11 +143,10 @@ pub struct Settings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub window_geometry: Option<WindowGeometry>,
|
pub window_geometry: Option<WindowGeometry>,
|
||||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||||
/// the theme's `theme.ron` manifest. `"default"` is the bundled
|
/// the theme's `theme.ron` manifest. `"classic"` and `"dark"` are
|
||||||
/// theme and is always present in the registry; user-supplied
|
/// always present; user-supplied themes register under their own ids.
|
||||||
/// themes register under their own ids when they're imported.
|
/// Older `settings.json` files that stored `"default"` will fall
|
||||||
/// Older `settings.json` files default cleanly to `"default"` via
|
/// back to the dark embedded theme at runtime.
|
||||||
/// `#[serde(default = ...)]`.
|
|
||||||
#[serde(default = "default_theme_id")]
|
#[serde(default = "default_theme_id")]
|
||||||
pub selected_theme_id: String,
|
pub selected_theme_id: String,
|
||||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
@@ -224,6 +223,40 @@ 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>,
|
||||||
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
|
/// before submission. Older `settings.json` files written before this
|
||||||
|
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub leaderboard_display_name: Option<String>,
|
||||||
|
/// When `true`, the player may drag the top card of a completed foundation
|
||||||
|
/// pile back onto a compatible tableau column — a non-standard house rule.
|
||||||
|
/// Off by default. Older `settings.json` files deserialize cleanly to
|
||||||
|
/// `false` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub take_from_foundation: bool,
|
||||||
|
/// When `true`, anonymous game-play events (game start, game won, etc.)
|
||||||
|
/// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
|
||||||
|
/// Requires `matomo_url` to be set. Older `settings.json` files deserialize
|
||||||
|
/// cleanly to `false` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub analytics_enabled: bool,
|
||||||
|
/// Base URL of the Matomo instance to send events to, e.g.
|
||||||
|
/// `"https://analytics.example.com"`. When `None` the analytics toggle has
|
||||||
|
/// no effect. Older `settings.json` files deserialize cleanly to `None`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub matomo_url: Option<String>,
|
||||||
|
/// Matomo site ID assigned when the tracked site was created in Matomo.
|
||||||
|
/// Defaults to `1` (the first site created in a fresh Matomo install).
|
||||||
|
#[serde(default = "default_matomo_site_id")]
|
||||||
|
pub matomo_site_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -239,7 +272,7 @@ fn default_music_volume() -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_theme_id() -> String {
|
fn default_theme_id() -> String {
|
||||||
"default".to_string()
|
"classic".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||||
@@ -292,6 +325,10 @@ fn default_replay_move_interval_secs() -> f32 {
|
|||||||
0.45
|
0.45
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_matomo_site_id() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
/// Lower bound of the player-tunable replay-playback per-move interval,
|
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||||
/// in seconds. Below this the cards barely register visually before
|
/// in seconds. Below this the cards barely register visually before
|
||||||
/// the next move fires; the cap keeps the playback legible.
|
/// the next move fires; the cap keeps the playback legible.
|
||||||
@@ -342,6 +379,12 @@ 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,
|
||||||
|
leaderboard_display_name: None,
|
||||||
|
take_from_foundation: false,
|
||||||
|
analytics_enabled: false,
|
||||||
|
matomo_url: None,
|
||||||
|
matomo_site_id: default_matomo_site_id(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ impl StatsExt for StatsSnapshot {
|
|||||||
// Time Attack uses its own session-level scoring; a per-game best
|
// 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! | Struct | Backend |
|
//! | Struct | Backend |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
|
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
|
||||||
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) |
|
//! | [`SolitaireServerClient`] | Self-hosted Ferrous Solitaire server (JWT auth) |
|
||||||
//!
|
//!
|
||||||
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
|
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
|
||||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
@@ -55,7 +55,7 @@ impl SyncProvider for LocalOnlyProvider {
|
|||||||
// SolitaireServerClient
|
// SolitaireServerClient
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// HTTP sync client for the self-hosted Solitaire Quest server.
|
/// HTTP sync client for the self-hosted Ferrous Solitaire server.
|
||||||
///
|
///
|
||||||
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||||
/// client automatically attempts a token refresh and retries the request once
|
/// client automatically attempts a token refresh and retries the request once
|
||||||
@@ -83,18 +83,96 @@ impl SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authenticate with a username + password and return `(access_token, refresh_token)`.
|
||||||
|
///
|
||||||
|
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||||
|
/// The client's `username` field is used as the credential — the caller must
|
||||||
|
/// construct the client with the correct username before calling this.
|
||||||
|
pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/login", self.base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": self.username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
Self::extract_auth_tokens(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new account with a username + password and return `(access_token, refresh_token)`.
|
||||||
|
///
|
||||||
|
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||||
|
pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/register", self.base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": self.username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
Self::extract_auth_tokens(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response.
|
||||||
|
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
let msg = body["error"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| body["message"].as_str())
|
||||||
|
.unwrap_or("authentication failed");
|
||||||
|
return Err(if status == reqwest::StatusCode::CONFLICT {
|
||||||
|
SyncError::Auth("username already taken".into())
|
||||||
|
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
|
SyncError::Auth("invalid credentials".into())
|
||||||
|
} else if status == reqwest::StatusCode::BAD_REQUEST {
|
||||||
|
SyncError::Auth(msg.to_string())
|
||||||
|
} else {
|
||||||
|
SyncError::Network(format!("server returned {status}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
let access = body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| SyncError::Serialization("missing access_token".into()))?
|
||||||
|
.to_string();
|
||||||
|
let refresh = body["refresh_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))?
|
||||||
|
.to_string();
|
||||||
|
Ok((access, refresh))
|
||||||
|
}
|
||||||
|
|
||||||
/// Attempt to refresh the access token using the stored refresh token.
|
/// Attempt to refresh the access token using the stored refresh token.
|
||||||
///
|
///
|
||||||
/// On success the new access token is persisted to the OS keychain,
|
/// The server rotates refresh tokens on each call: the response includes a
|
||||||
/// replacing the previous one. The refresh token itself is unchanged.
|
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||||
|
/// to the OS keychain on success.
|
||||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||||
let refresh = load_refresh_token(&self.username)
|
let old_refresh = load_refresh_token(&self.username)
|
||||||
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/api/auth/refresh", self.base_url))
|
.post(format!("{}/api/auth/refresh", self.base_url))
|
||||||
.json(&serde_json::json!({ "refresh_token": refresh }))
|
.json(&serde_json::json!({ "refresh_token": old_refresh }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
@@ -112,9 +190,11 @@ impl SolitaireServerClient {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
||||||
|
|
||||||
// store_tokens replaces both access and refresh; we keep the old
|
// Server rotates refresh tokens — store the new one.
|
||||||
// refresh token unchanged so its 30-day TTL is preserved.
|
// Fall back to the old token if the field is absent (pre-rotation server).
|
||||||
store_tokens(&self.username, new_access, &refresh)
|
let new_refresh = body["refresh_token"].as_str().unwrap_or(&old_refresh);
|
||||||
|
|
||||||
|
store_tokens(&self.username, new_access, new_refresh)
|
||||||
.map_err(|e| SyncError::Auth(e.to_string()))
|
.map_err(|e| SyncError::Auth(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -418,3 +418,56 @@ async fn pull_after_account_deletion_returns_default_or_error() {
|
|||||||
|
|
||||||
let _ = delete_tokens(username);
|
let _ = delete_tokens(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Push retry on 401.**
|
||||||
|
///
|
||||||
|
/// Mirrors `jwt_refresh_on_401_succeeds` but for the `push()` path.
|
||||||
|
/// We install an expired access token so the first push attempt returns 401,
|
||||||
|
/// the client refreshes, and the retry push succeeds.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn push_retries_after_401_on_expired_access_token() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_push_expiring";
|
||||||
|
|
||||||
|
let (_real_access, real_refresh) =
|
||||||
|
register_user_raw(&base, username, "pushexpirepass1!").await;
|
||||||
|
let user_id = decode_sub(&_real_access);
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||||
|
let expired_access = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&Claims {
|
||||||
|
sub: user_id.clone(),
|
||||||
|
exp,
|
||||||
|
kind: "access".into(),
|
||||||
|
},
|
||||||
|
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||||
|
)
|
||||||
|
.expect("failed to encode expired access token");
|
||||||
|
|
||||||
|
store_tokens(username, &expired_access, &real_refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
let payload = make_payload(&user_id, 17);
|
||||||
|
|
||||||
|
// Push: server returns 401, client refreshes, retries, succeeds.
|
||||||
|
let push_resp = client
|
||||||
|
.push(&payload)
|
||||||
|
.await
|
||||||
|
.expect("push must succeed after the client transparently refreshes the access token");
|
||||||
|
assert_eq!(
|
||||||
|
push_resp.merged.stats.games_played, 17,
|
||||||
|
"merged games_played must reflect what was pushed after auto-refresh"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
usvg = { workspace = true }
|
usvg = { workspace = true }
|
||||||
resvg = { workspace = true }
|
resvg = { workspace = true }
|
||||||
@@ -32,6 +33,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 }
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<defs>
|
||||||
|
<pattern id="dp" x="0" y="0" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||||
|
<rect width="28" height="28" fill="#1a3a6e"/>
|
||||||
|
<polygon points="14,2 26,14 14,26 2,14" fill="#2255aa"/>
|
||||||
|
<polygon points="14,7 21,14 14,21 7,14" fill="#1a3a6e"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<!-- White card background -->
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14" fill="#FAFAF8"/>
|
||||||
|
<!-- Red outer border -->
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="none" stroke="#CC1111" stroke-width="4"/>
|
||||||
|
<!-- Navy diamond pattern inset -->
|
||||||
|
<rect x="16" y="16" width="224" height="352" rx="8" ry="8" fill="url(#dp)"/>
|
||||||
|
<!-- Thin red frame around pattern -->
|
||||||
|
<rect x="16" y="16" width="224" height="352" rx="8" ry="8"
|
||||||
|
fill="none" stroke="#CC1111" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 924 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">10</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">10</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">2</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">2</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">3</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">3</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">4</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">4</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">5</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">5</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">6</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">6</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">7</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">7</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">8</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">8</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">9</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">9</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">A</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">A</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">J</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">J</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">K</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">K</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">Q</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) 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="#111111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#111111">Q</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<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="#111111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">10</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">10</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">2</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">2</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">3</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">3</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">4</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">4</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">5</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">5</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">6</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">6</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">7</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">7</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">8</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">8</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">9</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">9</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">A</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">A</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
|
||||||
|
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
|
||||||
|
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Top-left corner: rank label + small suit -->
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">J</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Centre: large suit, 64x64 in 256x384 card -->
|
||||||
|
<g transform="translate(96 160) scale(2)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
|
||||||
|
<g transform="rotate(180 128 192)">
|
||||||
|
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
|
||||||
|
fill="#CC1111">J</text>
|
||||||
|
<g transform="translate(14 50) scale(0.625)">
|
||||||
|
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |