Compare commits

...

42 Commits

Author SHA1 Message Date
funman300 4df13695fc fix(engine): use classic theme fallback in load_initial_theme
Android Release / build-apk (push) Successful in 3m21s
SettingsResource is not yet available at Startup, so load_initial_theme
fell back to "dark" on every run. On AMOLED the dark back (▒151515) is
invisible, showing only a 24×32 px red badge — the "tiny red squares"
bug. Cascade-collapse and top-row legibility were visual consequences of
the same invisible face-down cards, not layout bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:06:34 -07:00
funman300 df22338c8a fix(ui): remove grey HUD band background and constrain stock badge to pile bounds
Android Release / build-apk (push) Successful in 4m30s
Bug 1: StockCountBadge was centred 12 px inward from the stock pile's right
edge but its half-width of 17 px pushed the right edge 5 px past the pile
boundary. On Android (H_GAP_DIVISOR=32, inter-pile gap ~4 px) the badge
corner covered the waste pile's left edge at Z=30, making the waste card
appear clipped. STOCK_BADGE_INSET.x: -12 → -20 keeps the right edge 3 px
inside the stock pile on every device.

Bug 2: The top HUD band Node had an opaque dark-grey BackgroundColor sized to
HUD_BAND_HEIGHT (64/80 px). With only Tier-1 content (~30 px) visible in
typical gameplay the grey block appeared far taller than its content. Removed
BackgroundColor from the band entity; layout reservation in compute_layout is
unchanged and the bottom action bar retains its own background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:48:52 -07:00
funman300 7f450aab17 fix(android): default to classic theme to fix AMOLED card-back invisibility
Android Release / build-apk (push) Successful in 4m7s
Dark theme back.svg uses #151515 (near-black) as the card back background,
which AMOLED screens render as fully-off pixels, leaving only the tiny
#a54242 red badge visible — user sees solid red squares instead of card backs.

Fix: change fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly visible on all display types).
Also remove the stale "classic" -> "dark" sanitize migration, correct wrong
asset paths in load_card_images (classic/ subdirectory was missing), and
update tests that hardcoded the old TABLEAU_FAN_FRAC=0.25 constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:24:25 -07:00
funman300 d8f67dcad3 fix(ci): collapse multi-line Python to one-liner to fix YAML block scalar indentation error
Android Release / build-apk (push) Successful in 4m3s
2026-05-16 12:34:40 -07:00
funman300 ccb77f76b8 chore(release): promote Unreleased to 0.30.0 2026-05-16 12:31:51 -07:00
funman300 da54faf8e2 feat(engine): tighten tableau card fan offset (0.25→0.18, 0.20→0.14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:31:18 -07:00
funman300 f3d01b5890 fix(ci): delete existing APK assets before upload to avoid duplicates on re-runs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:20:10 -07:00
funman300 faefca0445 fix(android): remove hardcoded versionCode/Name from manifest so aapt2 CI injection works
Android Release / build-apk (push) Successful in 3m44s
aapt2 --version-code/--version-name only inject when the attribute is
absent — they silently no-op when the manifest already has a value.
Removed both attributes from AndroidManifest.xml so the CI flags take
effect. Local debug builds fall back to code=1 / name=0.0.0-dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:11:22 -07:00
funman300 24d83c9ae3 fix(ci): add Node.js 20 to android-builder for Gitea Actions composite steps
Build Android Builder Image / build (push) Successful in 5m7s
Android Release / build-apk (push) Successful in 5m43s
2026-05-16 11:30:10 -07:00
funman300 9d4234cded fix(ci): add build-essential to android-builder image for cargo-ndk compile
Build Android Builder Image / build (push) Successful in 7m5s
Android Release / build-apk (push) Failing after 3m30s
2026-05-16 10:51:48 -07:00
funman300 e48f652454 feat(ci): pre-built Android builder image + sccache
Build Android Builder Image / build (push) Failing after 3m54s
Replaces the 5 per-run tool-install steps (~2m 30s) with a pre-built
container image (git.aleshym.co/funman300/android-builder) that ships
Ubuntu 22.04 + Java 17 + Android SDK/NDK + Rust stable + aarch64 target
+ cargo-ndk + sccache. android-release.yml now runs inside the container
and adds two cache steps instead: Cargo registry and sccache directory.

sccache (RUSTC_WRAPPER) caches at the translation-unit level so partial
hits survive Cargo.lock changes — far more resilient than caching the
full target/ directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:47:05 -07:00
funman300 c24c7f6b61 chore(release): promote Unreleased to 0.29.0
Android Release / build-apk (push) Failing after 2m46s
2026-05-16 10:35:32 -07:00
funman300 686f57252c fix(android): stamp versionCode and versionName from the release tag
AndroidManifest.xml had hardcoded versionCode=1 / versionName=1.0, so
every shipped APK looked identical to Android and Obtainium could never
confirm the installed version matched the latest release tag — causing
a persistent false-update notification loop.

VERSION_NAME is now passed into the build script from the CI tag
(e.g. "v0.28.0" → versionCode=2800, versionName="0.28.0") and
forwarded to aapt2 link via --version-code / --version-name, overriding
the manifest without touching the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:34:14 -07:00
Gitea CI 059af2ac28 chore(deploy): bump image to 858012d9 [skip ci] 2026-05-16 17:29:27 +00:00
funman300 858012d926 fix(ci): pin kustomize to v5.4.3 to avoid GitHub API rate-limit failures
Build and Deploy / build-and-push (push) Successful in 22s
Replaced the curl|bash install_kustomize.sh approach (which makes an
unauthenticated GitHub API call to resolve the latest version) with a
direct pinned tarball download. Eliminates the tar glob failure that
broke run #226.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:29:02 -07:00
funman300 f6be961419 feat(web): show profile picture avatar in game page header
Build and Deploy / build-and-push (push) Failing after 4m17s
Fetches /api/me with the stored fs_token and renders a 32px circular
avatar in hud-right. Shows the profile photo when set, or the first
letter of the username as initials otherwise. Hidden when not signed in.
Clicking the avatar navigates to /account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:37:59 -07:00
Gitea CI 8a145154db chore(deploy): bump image to e17667d0 [skip ci] 2026-05-16 00:36:52 +00:00
funman300 e17667d034 feat(web): add undo button directly on the game board
Build and Deploy / build-and-push (push) Successful in 4m37s
Places a floating "↩ Undo" button at the bottom-right of the green felt
surface so it is visible without looking in the header. Both the board
button and the header button share the same handler; both track
undo_stack_len and disable when nothing can be undone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:32:16 -07:00
Gitea CI 005e29d1ab chore(deploy): bump image to a9285ccb [skip ci] 2026-05-16 00:25:22 +00:00
funman300 9d3cc94831 feat(web): add Restart button to replay viewer
Build and Deploy / build-and-push (push) Successful in 4m31s
Splits the old single "⏮ Restart" button into two: "⏮ Restart" (resets
to step 0 with card fade-in from dealt positions) and "◀ Back" (steps
back one move at a time via fast-forward replay). Both are disabled at
step 0 and enabled after any forward step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:24:25 -07:00
funman300 a9285ccb41 feat(web): add step-back to replay viewer
Build and Deploy / build-and-push (push) Successful in 3m47s
The "⏮ Restart" button now steps back one move at a time instead of
resetting to the beginning. Re-creates the ReplayPlayer and fast-forwards
to (step_idx - 1) without rendering intermediate frames; the CSS transform
transition then animates each card back to its previous position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:21:32 -07:00
funman300 648c3ed11d fix(engine): add opaque background behind Android corner label
The rank+suit text overlay was transparent, letting the card art's
own small corner text show through underneath — giving the appearance
of two sets of labels on each face-up card.

Add AndroidCornerBg, a CARD_FACE_COLOUR sprite child sized at
(2.0 × font_size) × (1.25 × font_size) rendered at z+0.015,
just below the text overlay (z+0.02). This covers the art corner
text so only the large overlay label is visible.

resize_android_corner_labels now also resizes AndroidCornerBg so
both layers stay aligned on orientation change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:58:34 -07:00
funman300 102506f799 feat(engine): add Android corner-label overlay for card readability
On Android, face-up cards now render a large rank+suit overlay in
the upper-left corner (FONT_SIZE_FRAC_MOBILE = 0.35 × card_width,
using Anchor::TOP_LEFT) so the rank and suit are legible at phone
scale. The baked-in SVG art corner text is only ~10–15 px physical;
the overlay is ~52 px physical — roughly 3-4× larger.

Accompanying changes:
- H_GAP_DIVISOR on Android raised 8 → 32, widening cards from
  112.5 → 124.1 logical px (135 → 149 physical px on Pixel 7 AVD).
- AndroidCornerLabel marker component tracks overlay entities so
  resize_android_corner_labels can update font-size + transform
  on orientation change without a full card respawn.
- Uses text_colour() for overlay tint so black suits render as
  near-white (BLACK_SUIT_COLOUR) on the dark Terminal card face,
  matching the existing fallback overlay behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:49:50 -07:00
funman300 9b00af29d9 fix(engine): Android HUD QA — glyph, avatar, toggle, modal-dismiss safety
Bug A: Replace U+21C4 (tofu on FiraMono) with plain ASCII "M" on the
Modes action button.

Bug B: HudAvatar disc was invisible against BG_HUD_BAND (same dark
grey). Switch background to ACCENT_PRIMARY and text to TEXT_PRIMARY so
the disc is clearly visible.

Bug C/D: toggle_hud_on_tap improvements:
- Drain buffered TouchInput events in the early-return path (scrim
  present or paused) so the modal-dismiss frame does not replay the
  button tap's Started+Ended pair as a spurious toggle.
- Stop clearing start_pos on TouchPhase::Moved — Android fires Moved
  even for clean taps (jitter), and the distance check at Ended already
  rejects real drags via drag.is_idle(). Clearing it silently swallowed
  toggle attempts on physical devices.
- Increase HUD_TAP_SLOP_PX from 15 → 25 for better tap recognition.

Also reduces Android HUD_BAND_HEIGHT from 128 → 80 px now that action
buttons live in the bottom bar rather than the top band.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:42:46 -07:00
funman300 ea28121675 feat(engine): add mini-tableau preview panel to replay overlay
Right-edge panel shows foundation tops (F: A♠ 7♥ 5♦ K♣) and
stock/waste head (STK:14 WST:7♥) while a replay plays, giving
players a compact game-state readout without scanning the dim tableau.

Architectural changes:
- DespawnWithReplay marker on every sibling root entity so
  react_to_state_change uses a single despawn query instead of
  one per entity type — future overlay surfaces just add the marker.
- react_to_state_change reduced from 9 args to 5 via the above.
- Two update systems (update_mini_tableau_foundations,
  update_mini_tableau_stock_waste) watch GameStateResource.is_changed()
  and repaint; split to avoid Bevy B0001 query conflict on &mut Text.

New format helpers: format_rank_short, format_suit_glyph,
format_card_short, format_foundations_row, format_stock_waste_row —
all use FiraMono-covered suit glyphs (U+2660–U+2666, verified Android).

+9 tests (lifecycle + format helper unit coverage).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:25:32 -07:00
funman300 ba17c026a3 chore(release): promote Unreleased to 0.28.0
Android Release / build-apk (push) Successful in 7m58s
Rename Solitaire Quest → Ferrous Solitaire; package id
com.solitairequest.app → com.ferrousapp.solitaire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:11:12 -07:00
funman300 6cedf36b01 fix(readme): use Dart class name "Codeberg" as overrideSource in Obtainium badge
Obtainium matches overrideSource via runtimeType.toString(), which is the
Dart class name "Codeberg", not the display name "Forgejo (Codeberg)".
The wrong name caused "URL does not match the source" on import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:06:38 -07:00
funman300 eb0831893d fix(readme): pass apkUrls/otherAssetUrls as JSON-encoded strings in Obtainium badge
Obtainium's fromJson calls jsonDecode(json['apkUrls']), so the field must
be a JSON-encoded string ("[]") not a raw array ([]). Passing a raw array
caused the Dart runtime error: List<dynamic> is not a subtype of String.
Also adds allowIdChange and otherAssetUrls fields required by v1.4.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:59:18 -07:00
funman300 ad9ac9c7bb fix(readme): correct Obtainium badge to URL-encoded JSON format
The redirect service at apps.obtainium.imranr.dev/redirect parses the
obtainium://app/ payload via JSON.parse(decodeURIComponent(...)), so
base64 caused an "invalid URL" error. Switch to URL-percent-encoded JSON.
Also updates package id to com.ferrousapp.solitaire (renamed package).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:56:48 -07:00
funman300 5f9f2745f9 docs: fix Obtainium deep link — use Forgejo (Codeberg) source, not Gitea
Obtainium has no dedicated Gitea source provider. The correct override
is "Forgejo (Codeberg)" which uses the same /api/v1/repos/ API that
Gitea exposes. Previous overrideSource "Gitea" was silently ignored,
falling back to failed auto-detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:51:31 -07:00
funman300 a18bcb84d3 docs: switch Obtainium badge to app/ deep link with Gitea source pre-set
The add/ scheme relies on auto-detection which fails for this self-hosted
Gitea instance. The app/ scheme encodes the full config (including
overrideSource: Gitea) in base64 so no detection is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:46:21 -07:00
funman300 d5c7a149cb docs: clarify Obtainium setup requires manual Gitea source type selection
The self-hosted Gitea instance's /api/v1/meta endpoint returns 404,
causing Obtainium's auto-detection to fail on custom domains. Rewrite
the install steps to lead with the manual Add App flow (URL + set source
type to Gitea) and demote the one-tap badge to a secondary option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:32:02 -07:00
Gitea CI fceb2be381 chore(deploy): bump image to d761a150 [skip ci] 2026-05-15 02:28:31 +00:00
funman300 d761a150d7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Build and Deploy / build-and-push (push) Successful in 4m40s
Updates all in-tree references:
- Android package: com.solitairequest.app → com.ferrousapp.solitaire
- APK name: solitaire-quest → ferrous-solitaire
- Data dir: solitaire_quest → ferrous_solitaire (across all 6 data modules + engine)
- Keyring service: solitaire_quest_server → ferrous_solitaire_server
- Android Keystore key: solitaire_quest_token_key → ferrous_solitaire_token_key
- Gitea repo: Rusty_Solitare → Ferrous-Solitaire (also fixes "Solitare" typo)
- Renamed pkg/solitaire-quest* → pkg/ferrous-solitaire*
- Updated ArgoCD, docker-compose, CI workflow, build script, all docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:23:49 -07:00
funman300 d105fee319 docs: add Obtainium badge with deep-link to README 2026-05-14 19:13:29 -07:00
funman300 94c68a46a4 docs: add Android install section with Obtainium instructions 2026-05-14 19:12:59 -07:00
funman300 58f33da6bf fix(ci): suppress SIGPIPE from yes | sdkmanager --licenses
Android Release / build-apk (push) Successful in 7m29s
2026-05-14 19:04:07 -07:00
funman300 1b3fcca0d5 ci(android): add tag-triggered release workflow with Obtainium support
Android Release / build-apk (push) Failing after 1m30s
Fires on any v* tag push. Steps:
- Installs Android SDK + NDK 30.0.14904198 (cached by SDK version key)
- Builds release APK for arm64-v8a via scripts/build_android_apk.sh
- Signs with the release keystore stored in RELEASE_KEYSTORE_B64 secret
- Creates (or reuses) the Gitea release for the tag
- Uploads solitaire-quest.apk as a release asset

Obtainium users can track releases by adding:
  https://git.aleshym.co/funman300/Rusty_Solitare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:00:23 -07:00
funman300 4e480d7cb5 ci: scope server workflow to server paths; harden deploy push
Build and Deploy / build-and-push (push) Failing after 25s
Switch docker-build.yml from paths-ignore to an explicit paths allowlist
so the workflow only fires on changes to solitaire_server/, solitaire_sync/,
solitaire_core/, Cargo.toml, Cargo.lock, or the workflow file itself.

Also harden the "commit and push updated kustomization" step:
- exit 0 early when the kustomization has no staged diff (nothing to push)
- retry the pull+push loop up to 3 times with a 5 s delay to handle
  concurrent pushes that race the CI commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:55:43 -07:00
Gitea CI 42a0a0bb8a chore(deploy): bump image to ca5d8a9c [skip ci] 2026-05-15 01:53:50 +00:00
funman300 ca5d8a9c55 fix(engine): silence Android-target dead-code and unused-import warnings
Build and Deploy / build-and-push (push) Successful in 34s
All 10 warnings were caused by hotkey/keyboard UI code behind
#[cfg(not(target_os = "android"))] call sites whose definitions lacked
the matching gate. Fixes:
- help_plugin: gate keyboard-chip imports and font_kbd; #[allow(dead_code)]
  on ControlRow (keys field is data, not dead)
- hud_plugin/ui_modal: replace cfg shadow pattern with cfg!() expression
  so the hotkey parameter is read on every platform
- home_plugin: gate fn hotkey behind not(android)
- onboarding_plugin: gate HotkeyRow, HOTKEYS, spawn_slide_hotkeys and
  their exclusive imports behind not(android)
- replay_overlay: gate keybind_footer_hint_text behind not(android)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:53:24 -07:00
Gitea CI 48befd7e9b chore(deploy): bump image to 5559f326 [skip ci] 2026-05-15 01:16:54 +00:00
51 changed files with 1365 additions and 272 deletions
+109
View File
@@ -0,0 +1,109 @@
name: Android Release
on:
push:
tags:
- 'v*'
env:
APK_OUT: target/release/apk/ferrous-solitaire.apk
GITEA_URL: https://git.aleshym.co
REPO: funman300/Ferrous-Solitaire
jobs:
build-apk:
runs-on: ubuntu-latest
container:
image: git.aleshym.co/funman300/android-builder:latest
credentials:
username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry/index
/usr/local/cargo/registry/cache
/usr/local/cargo/git/db
key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry-android-
- name: Cache sccache
uses: actions/cache@v4
with:
path: /root/.cache/sccache
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: sccache-android-aarch64-
- name: Get tag name
id: tag
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
- name: Build release APK
env:
PROFILE: release
ABIS: arm64-v8a
KEYSTORE: ./release.jks
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
KEY_ALIAS: release
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
VERSION_NAME: ${{ steps.tag.outputs.name }}
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /root/.cache/sccache
run: bash scripts/build_android_apk.sh
- name: sccache stats
if: always()
run: sccache --show-stats
- name: Create or get Gitea release
id: release
run: |
TAG="${{ steps.tag.outputs.name }}"
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
2>/dev/null || true)
if [ -z "$ID" ]; then
ID=$(curl -sf -X POST \
-H "$AUTH" -H "Content-Type: application/json" \
"$BASE/releases" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$TAG\",
\"body\": \"## Android release $TAG\n\n**Install / update with Obtainium** — add this source URL:\n\`\`\`\nhttps://git.aleshym.co/funman300/Ferrous-Solitaire\n\`\`\`\n\nOr download \`ferrous-solitaire.apk\` below and sideload it directly.\",
\"draft\": false,
\"prerelease\": false
}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
fi
echo "id=$ID" >> "$GITHUB_OUTPUT"
- name: Upload APK to release
run: |
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
RELEASE_ID="${{ steps.release.outputs.id }}"
# Remove any existing APK assets so re-runs don't accumulate duplicates.
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
| while read AID; do
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
done
curl -sf -X POST \
-H "$AUTH" \
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
"$BASE/releases/$RELEASE_ID/assets"
+41
View File
@@ -0,0 +1,41 @@
name: Build Android Builder Image
on:
push:
branches: [master]
paths:
- 'docker/android-builder.Dockerfile'
- '.gitea/workflows/builder-image.yml'
env:
IMAGE: git.aleshym.co/funman300/android-builder
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: git.aleshym.co
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: docker/android-builder.Dockerfile
push: true
tags: ${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
+14 -9
View File
@@ -3,11 +3,13 @@ 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'
paths:
- 'solitaire_server/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/docker-build.yml'
env:
REGISTRY: git.aleshym.co
@@ -55,7 +57,7 @@ jobs:
- name: Install kustomize
run: |
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests
@@ -68,6 +70,9 @@ jobs:
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
git diff --cached --quiet && exit 0 # nothing to commit — skip push
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
for i in 1 2 3; do
git pull --rebase origin master && git push && break
sleep 5
done
+8 -8
View File
@@ -58,7 +58,7 @@ Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enf
## 2. Workspace Structure
```
solitaire_quest/
ferrous_solitaire/
├── Cargo.toml # Workspace manifest
├── .env.example # Server environment variable template
@@ -366,12 +366,12 @@ Minimum window: 800×600. At this size cards are small but usable.
### Local Storage
All files stored under `dirs::data_dir() / "solitaire_quest"/`:
All files stored under `dirs::data_dir() / "ferrous_solitaire"/`:
```
~/.local/share/solitaire_quest/ (Linux)
~/Library/Application Support/solitaire_quest/ (macOS)
%APPDATA%\solitaire_quest\ (Windows)
~/.local/share/ferrous_solitaire/ (Linux)
~/Library/Application Support/ferrous_solitaire/ (macOS)
%APPDATA%\ferrous_solitaire\ (Windows)
├── stats.json # StatsSnapshot
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
@@ -426,7 +426,7 @@ pub enum SyncBackend {
url: String,
username: String,
// JWT access + refresh tokens stored in OS keychain
// key: "solitaire_quest_server_{username}"
// key: "ferrous_solitaire_server_{username}"
},
}
```
@@ -980,8 +980,8 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
### Docker Compose (Recommended)
```bash
git clone https://github.com/yourname/solitaire_quest
cd solitaire_quest
git clone https://github.com/yourname/ferrous_solitaire
cd ferrous_solitaire
cp .env.example .env
# Edit .env — set JWT_SECRET and SERVER_PORT
docker compose up -d
+82 -4
View File
@@ -6,6 +6,84 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.33.0] — 2026-05-16
### Fixed
- **Face-down cards render as tiny red squares (startup ordering bug)**. The
`load_initial_theme` system fell back to `"dark"` when `SettingsResource` was
not yet available at `Startup`, which happens on every fresh run before the
settings file is read. The dark theme's near-black card back (#151515) renders
as fully-off pixels on AMOLED screens, leaving only a 24×32 px red badge
visible. Changed the fallback to `"classic"` so startup behaviour matches the
`default_theme_id()` set in v0.31.0. Cascade-collapse and top-row legibility
issues were visual consequences of the same invisible-card-back problem, not
separate layout bugs.
## [0.32.0] — 2026-05-16
### Fixed
- **Stock-count badge overlaps waste pile on Android** (Bug 1). The badge was
centred 12 px inward from the stock pile's right edge, but its half-width of
17 px pushed it 5 px past the edge. On Android (`H_GAP_DIVISOR = 32`) the
inter-pile gap is only ~4 px, so the badge's top-right corner covered the
left edge of the adjacent waste card at `Z_STOCK_BADGE = 30` (above the
card's Z ≈ 1). Fixed by moving the inset to 20 px so the badge right edge
sits 3 px inside the stock card on every device.
- **Oversized grey header bar** (Bug 2). The top HUD band was a full-width
`Node` with an opaque dark-grey `BackgroundColor` sized to `HUD_BAND_HEIGHT`
(64 px desktop / 80 px Android). Typical gameplay only shows one tier of
score text (~30 px), leaving a large empty grey block. Removed the
`BackgroundColor` from the band entity; the green felt now shows through and
only the score text and avatar button are visible in the header area.
## [0.31.0] — 2026-05-16
### Fixed
- **Face-down cards rendered as solid red squares on AMOLED phones** (Bug 1).
The dark theme's card back (`back.svg`) uses a near-black background
(`#151515`) which AMOLED screens render as fully-off pixels, leaving only a
tiny `#a54242` red badge visible — exactly what was reported. Fixed by
changing the fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly readable on all display types).
Also corrected stale asset paths in `load_card_images` (`cards/backs/back_N`
`cards/backs/classic/back_N`, `cards/faces/XY``cards/faces/classic/XY`)
so the PNG fallback loads correctly when the embedded theme hasn't arrived yet.
## [0.30.0] — 2026-05-16
### Changed
- **Tableau card spacing tightened.** Face-up card fan reduced from 25% to 18%
of card height; face-down from 20% to 14%. Cards on tableau piles sit closer
together while still showing enough of each card to read the pile depth.
## [0.29.0] — 2026-05-16
### Fixed
- **APK versionCode hardcoded to 1** (`AndroidManifest.xml`, `build_android_apk.sh`).
Every release shipped with `versionCode="1"` / `versionName="1.0"`, so Android
silently refused upgrades and Obtainium permanently showed a false update
notification. The CI now derives the version code from the release tag
(e.g. v0.29.0 → 2900) and stamps it into the APK via `aapt2 link
--version-code / --version-name`.
- **CI kustomize install flaky** (`.gitea/workflows/docker-build.yml`).
The `curl | bash install_kustomize.sh` pattern hit GitHub API rate limits
on the shared runner IP, causing a `tar: no such file` failure. Replaced
with a direct pinned tarball download (kustomize v5.4.3).
## [0.28.0] — 2026-05-14
### Changed
- **Rename: Solitaire Quest → Ferrous Solitaire.** Android package id changed
from `com.solitairequest.app` to `com.ferrousapp.solitaire`; existing installs
must be uninstalled first (Android treats the new id as a new app).
Data directory renamed from `solitaire_quest/` to `ferrous_solitaire/`.
### Fixed
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
@@ -1431,7 +1509,7 @@ candidate — the app-icon round — stays open.
- **Android build target — first working APK** (`fb8b2ac`).
`cargo apk build -p solitaire_app --target x86_64-linux-android`
now produces a 54 MB debug-signed APK at
`target/debug/apk/solitaire-quest.apk`. Five gating points
`target/debug/apk/ferrous-solitaire.apk`. Five gating points
resolved end-to-end:
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
@@ -1548,7 +1626,7 @@ candidate — the app-icon round — stays open.
achievements, replays, game-state, time-attack sessions, user
themes). New `solitaire_data::platform::data_dir()` shim falls
through to `dirs::data_dir()` on desktop and returns the per-app
sandbox at `/data/data/com.solitairequest.app/files` on Android
sandbox at `/data/data/com.ferrousapp.solitaire/files` on Android
— no JNI needed, since the package id is pinned in
`[package.metadata.android]`. Six call sites across
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
@@ -1690,7 +1768,7 @@ fully reverted and is not part of this release.
The test's single-frame `app.update()` was sensitive to
first-frame `Time::delta_secs()` variance under heavy parallel
cargo-test load, and to production-disk
`~/.local/share/solitaire_quest/game_state.json` state leaking
`~/.local/share/ferrous_solitaire/game_state.json` state leaking
into the test world via `GamePlugin::build`'s load path.
`test_app` now resets `PendingRestoredGame(None)` after plugin
build (preventing the dev machine's saved-game state from
@@ -2386,7 +2464,7 @@ the binary shipped with bundled artwork.
patterns.
- **Ambient audio loop** wired through the kira mixer.
- **Arch Linux PKGBUILDs** for the game client and sync server (under
the separate `solitaire-quest-pkgbuild` directory).
the separate `ferrous-solitaire-pkgbuild` directory).
- **Workspace README, CI workflow, migration guide.**
### Changed
+1 -1
View File
@@ -447,7 +447,7 @@ raw `z_index` values — they drift and cause ordering bugs.
```bash
cargo apk build --package solitaire_app --lib
adb install -r target/debug/apk/solitaire-quest.apk
adb install -r target/debug/apk/ferrous-solitaire.apk
```
## 15.2 Coordinate system reminder
+17
View File
@@ -31,6 +31,23 @@ optional self-hosted sync so your stats follow you across machines.
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
glyph
## Android Install
### Obtainium (recommended — automatic updates)
1. Install [Obtainium](https://github.com/ImranR98/Obtainium/releases) on your device
2. Tap the badge below on your Android device — the source type is pre-configured, no manual selection needed:
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="40">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.ferrousapp.solitaire%22%2C%22url%22%3A%22https%3A//git.aleshym.co/funman300/Ferrous-Solitaire%22%2C%22author%22%3A%22funman300%22%2C%22name%22%3A%22Ferrous%20Solitaire%22%2C%22installedVersion%22%3Anull%2C%22latestVersion%22%3Anull%2C%22apkUrls%22%3A%22%5B%5D%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%7D%22%2C%22lastUpdateCheck%22%3Anull%2C%22pinned%22%3Afalse%2C%22categories%22%3A%5B%5D%2C%22releaseDate%22%3Anull%2C%22changeLog%22%3Anull%2C%22overrideSource%22%3A%22Codeberg%22%2C%22allowIdChange%22%3Afalse%2C%22otherAssetUrls%22%3A%22%5B%5D%22%7D)
3. Tap **Install** to download the current release — Obtainium will notify you when updates are available.
### Direct APK
Download the latest `ferrous-solitaire.apk` from the
[Releases](https://git.aleshym.co/funman300/Ferrous-Solitaire/releases) page,
enable **Install from unknown sources** in your device settings, and open the file.
## Building
**Prerequisites**
+1 -1
View File
@@ -6,7 +6,7 @@ metadata:
spec:
project: default
source:
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: master
path: deploy
destination:
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: c35c045f
newTag: 858012d9
+50
View File
@@ -0,0 +1,50 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=30.0.14904198 \
BUILD_TOOLS_VERSION=36.1.0 \
PLATFORM=android-34 \
RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \
PATH=/usr/local/cargo/bin:$PATH
RUN apt-get update && apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless \
build-essential wget unzip curl ca-certificates git zip python3 \
&& rm -rf /var/lib/apt/lists/*
# Node.js 20 — required by Gitea Actions composite actions (checkout, cache, etc.)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Android SDK command-line tools
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
-O /tmp/cmdtools.zip \
&& unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/cmdtools.zip \
&& yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true \
&& "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"ndk;${NDK_VERSION}" \
"build-tools;${BUILD_TOOLS_VERSION}" \
"platforms;${PLATFORM}"
# Rust stable + aarch64-linux-android target
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable \
&& rustup target add aarch64-linux-android
# cargo-ndk (compiled once into the image)
RUN cargo install cargo-ndk --version 4.1.2 --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git"
# sccache — pre-built musl binary, no Rust compile needed
RUN curl -sL "https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-x86_64-unknown-linux-musl.tar.gz" \
| tar xz -C /tmp \
&& mv /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache \
&& rm -rf /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl \
&& chmod +x /usr/local/bin/sccache
+8 -8
View File
@@ -6,7 +6,7 @@ later sections document what's known to compile, what's stubbed, and
the next milestones.
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
> debug-signed `solitaire-quest.apk` for `x86_64-linux-android`. Has
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
> NOT yet been verified to launch on a device or emulator — that's
> the next milestone.
@@ -121,7 +121,7 @@ cargo apk build -p solitaire_app --target x86_64-linux-android
Output:
```
target/debug/apk/solitaire-quest.apk
target/debug/apk/ferrous-solitaire.apk
```
Targets shipped via `[package.metadata.android].build_targets` in
@@ -164,8 +164,8 @@ Physical device:
```bash
adb devices # confirm connection
adb install target/debug/apk/solitaire-quest.apk
adb shell am start -n com.solitairequest.app/android.app.NativeActivity
adb install target/debug/apk/ferrous-solitaire.apk
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
```
@@ -174,7 +174,7 @@ Emulator:
```bash
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
adb wait-for-device
adb install target/debug/apk/solitaire-quest.apk
adb install target/debug/apk/ferrous-solitaire.apk
# ... same start + logcat steps as above.
```
@@ -203,7 +203,7 @@ What's NOT yet ported / not yet measured:
- `dirs::data_dir()` returns `None` on Android. Callers in
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
`achievements.rs`, `settings.rs` all need an Android-aware
helper (likely `/data/data/com.solitairequest.app/files`).
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
- Touch UX pass — hit-target sizes, modal scaling on small screens,
app lifecycle (suspend / resume), font scaling.
- Android Keystore via JNI for `auth_tokens`.
@@ -221,8 +221,8 @@ cargo build -p solitaire_app # desktop sanity
cargo clippy --workspace --all-targets -- -D warnings # gate
cargo test --workspace # gate
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
adb install -r target/debug/apk/solitaire-quest.apk # `-r` reinstalls
adb logcat -c && adb shell am start -n com.solitairequest.app/android.app.NativeActivity
adb install -r target/debug/apk/ferrous-solitaire.apk # `-r` reinstalls
adb logcat -c && adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire"
```
+8 -8
View File
@@ -39,13 +39,13 @@ Before starting, delete any existing local save files to ensure a clean state:
```
# Linux
rm -rf ~/.local/share/solitaire_quest/
rm -rf ~/.local/share/ferrous_solitaire/
# macOS
rm -rf ~/Library/Application\ Support/solitaire_quest/
rm -rf ~/Library/Application\ Support/ferrous_solitaire/
# Windows
rmdir /s %APPDATA%\solitaire_quest\
rmdir /s %APPDATA%\ferrous_solitaire\
```
---
@@ -130,10 +130,10 @@ On the machine where you want to test (Linux example):
```bash
# List keychain entries (uses secret-tool on GNOME)
secret-tool search service solitaire_quest_server
secret-tool search service ferrous_solitaire_server
# Overwrite alice's access token with a deliberately invalid value
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
secret-tool store --label="alice_access" service ferrous_solitaire_server account alice_access <<< "invalid.token.value"
```
### Step 2 — Trigger a sync with the expired/invalid token
@@ -148,7 +148,7 @@ secret-tool store --label="alice_access" service solitaire_quest_server account
```bash
# Extract the new token from the keychain
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
secret-tool lookup service ferrous_solitaire_server account alice_access | head -c 50
# Should look like a valid JWT (three base64 segments separated by dots)
```
@@ -157,8 +157,8 @@ secret-tool lookup service solitaire_quest_server account alice_access | head -c
1. Corrupt both the access token and the refresh token in the keychain:
```bash
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
secret-tool store --label="alice_access" service ferrous_solitaire_server account alice_access <<< "bad"
secret-tool store --label="alice_refresh" service ferrous_solitaire_server account alice_refresh <<< "bad"
```
2. Launch the game and trigger a sync.
@@ -1,10 +1,10 @@
# Maintainer: funman300 <funman300@gmail.com>
pkgname=solitaire-quest-server
pkgname=ferrous-solitaire-server
pkgver=0.1.0
pkgrel=1
pkgdesc='Self-hosted sync server for Solitaire Quest (stats, achievements, leaderboards)'
url='https://github.com/funman300/solitaire-quest'
pkgdesc='Self-hosted sync server for Ferrous Solitaire (stats, achievements, leaderboards)'
url='https://github.com/funman300/ferrous-solitaire'
license=('MIT')
arch=('x86_64')
makedepends=('cargo' 'rust')
@@ -12,12 +12,12 @@ depends=(
'gcc-libs'
'glibc'
)
backup=('etc/solitaire-quest-server/server.env')
backup=('etc/ferrous-solitaire-server/server.env')
# Build from the local workspace (two levels above this PKGBUILD).
_srcdir="$startdir/../.."
source=(
'solitaire-quest-server.service'
'ferrous-solitaire-server.service'
'server.env'
)
b2sums=('SKIP'
@@ -49,12 +49,12 @@ package() {
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
# systemd service
install -Dm0644 "$srcdir/solitaire-quest-server.service" \
"$pkgdir/usr/lib/systemd/system/solitaire-quest-server.service"
install -Dm0644 "$srcdir/ferrous-solitaire-server.service" \
"$pkgdir/usr/lib/systemd/system/ferrous-solitaire-server.service"
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
install -Dm0640 "$srcdir/server.env" \
"$pkgdir/etc/solitaire-quest-server/server.env"
"$pkgdir/etc/ferrous-solitaire-server/server.env"
# License and docs
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
@@ -0,0 +1,23 @@
[Unit]
Description=Ferrous Solitaire Sync Server
Documentation=https://github.com/funman300/ferrous-solitaire/blob/main/README_SERVER.md
After=network.target
[Service]
Type=simple
User=ferrous-solitaire
Group=ferrous-solitaire
EnvironmentFile=/etc/ferrous-solitaire-server/server.env
ExecStart=/usr/bin/solitaire_server
Restart=on-failure
RestartSec=5s
# Harden the service
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/ferrous-solitaire-server
[Install]
WantedBy=multi-user.target
@@ -1,10 +1,10 @@
# Solitaire Quest Server — environment configuration
# This file is installed to /etc/solitaire-quest-server/server.env (mode 0640).
# Ferrous Solitaire Server — environment configuration
# This file is installed to /etc/ferrous-solitaire-server/server.env (mode 0640).
# Edit these values before starting the service.
# Path to the SQLite database file.
# The directory must be writable by the solitaire-quest service user.
DATABASE_URL=sqlite:///var/lib/solitaire-quest-server/solitaire.db
# The directory must be writable by the ferrous-solitaire service user.
DATABASE_URL=sqlite:///var/lib/ferrous-solitaire-server/solitaire.db
# HS256 signing secret for JWT tokens.
# Generate a strong secret with: openssl rand -hex 32
@@ -1,10 +1,10 @@
# Maintainer: funman300 <funman300@gmail.com>
pkgname=solitaire-quest
pkgname=ferrous-solitaire
pkgver=0.1.0
pkgrel=1
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
url='https://github.com/funman300/solitaire-quest'
url='https://github.com/funman300/ferrous-solitaire'
license=('MIT')
arch=('x86_64')
makedepends=('cargo' 'rust')
@@ -1,23 +0,0 @@
[Unit]
Description=Solitaire Quest Sync Server
Documentation=https://github.com/funman300/solitaire-quest/blob/main/README_SERVER.md
After=network.target
[Service]
Type=simple
User=solitaire-quest
Group=solitaire-quest
EnvironmentFile=/etc/solitaire-quest-server/server.env
ExecStart=/usr/bin/solitaire_server
Restart=on-failure
RestartSec=5s
# Harden the service
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/solitaire-quest-server
[Install]
WantedBy=multi-user.target
+17 -2
View File
@@ -18,7 +18,7 @@
# "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)
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.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")
@@ -35,7 +35,7 @@ set -euo pipefail
PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/solitaire-quest.apk}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
@@ -75,12 +75,27 @@ if [ -d "$RES_DIR" ]; then
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
fi
# Derive versionCode/versionName from VERSION_NAME env var (e.g. "v0.28.0" → code 2800, name "0.28.0").
# AndroidManifest.xml intentionally has no versionCode/versionName — aapt2's --version-* flags only
# inject when absent, so the manifest must be clean for CI injection to work. Local debug builds
# fall back to code=1 / name="0.0.0-dev".
if [ -n "${VERSION_NAME:-}" ]; then
VN="${VERSION_NAME#v}"
IFS='.' read -r _MAJ _MIN _PAT <<< "$VN"
VERSION_CODE=$(( ${_MAJ:-0} * 10000 + ${_MIN:-0} * 100 + ${_PAT:-0} ))
else
VERSION_CODE=1
VERSION_NAME="0.0.0-dev"
fi
LINK_ARGS=(
link
-o "$STAGING/app-unsigned.apk"
-I "$PLATFORM_JAR"
--manifest "$MANIFEST"
)
[ -n "$VERSION_CODE" ] && LINK_ARGS+=( --version-code "$VERSION_CODE" )
[ -n "${VERSION_NAME:-}" ] && LINK_ARGS+=( --version-name "${VERSION_NAME#v}" )
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
# Add compiled resources if any
shopt -s nullglob
+2 -2
View File
@@ -56,8 +56,8 @@ tiny-skia = { workspace = true }
# already uses ships into the APK without copy-tree gymnastics.
# `apk_name` keeps the output filename predictable across machines.
[package.metadata.android]
package = "com.solitairequest.app"
apk_name = "solitaire-quest"
package = "com.ferrousapp.solitaire"
apk_name = "ferrous-solitaire"
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
assets = "../assets"
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
+1 -3
View File
@@ -13,9 +13,7 @@
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">
package="com.ferrousapp.solitaire">
<uses-sdk
android:minSdkVersion="26"
+1 -1
View File
@@ -109,7 +109,7 @@ pub fn run() {
title: "Ferrous Solitaire".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
name: Some("ferrous-solitaire".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
+1 -1
View File
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
pub use solitaire_sync::AchievementRecord;
const APP_DIR_NAME: &str = "solitaire_quest";
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`.
+1 -1
View File
@@ -19,7 +19,7 @@ use std::path::PathBuf;
use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "solitaire_quest_token_key";
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
#[derive(Serialize, Deserialize)]
struct TokenBlob {
+2 -2
View File
@@ -1,6 +1,6 @@
//! Secure storage for JWT access and refresh tokens using the OS keychain.
//!
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
//! Tokens are stored under service name `"ferrous_solitaire_server"` with entry
//! keys `"{username}_access"` and `"{username}_refresh"`.
//!
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
@@ -46,7 +46,7 @@ pub enum TokenError {
/// Service name used to namespace all keychain entries for this application.
#[cfg(not(target_os = "android"))]
const SERVICE: &str = "solitaire_quest_server";
const SERVICE: &str = "ferrous_solitaire_server";
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
#[cfg(not(target_os = "android"))]
+7 -7
View File
@@ -3,7 +3,7 @@
//! The rest of `solitaire_data` (settings, stats, achievements,
//! replays, progress, game state) and the engine's user-themes
//! discovery all need a base path under which to nest
//! `solitaire_quest/<file>`. On desktop the right answer is
//! `ferrous_solitaire/<file>`. On desktop the right answer is
//! `dirs::data_dir()` (which resolves to platform-appropriate
//! locations: `~/.local/share` on Linux, `~/Library/Application
//! Support` on macOS, `%APPDATA%` on Windows). On Android the
@@ -12,9 +12,9 @@
//!
//! [`data_dir`] is a thin shim that returns the right base path
//! per target. Callers continue to append
//! `solitaire_quest/<file>` themselves, so the on-disk layout is
//! `ferrous_solitaire/<file>` themselves, so the on-disk layout is
//! identical across platforms (the per-app Android sandbox makes
//! the extra `solitaire_quest/` segment harmless, and a `tar`
//! the extra `ferrous_solitaire/` segment harmless, and a `tar`
//! export from one platform deserialises cleanly on another).
//!
//! # Why hardcode on Android?
@@ -24,7 +24,7 @@
//! `AndroidApp` context through Bevy's startup hooks and a
//! per-call JNI bridge — meaningfully more code than the
//! sandbox-guaranteed `/data/data/<package>/files` path. The
//! package name `com.solitairequest.app` is fixed at compile
//! package name `com.ferrousapp.solitaire` is fixed at compile
//! time in `solitaire_app/Cargo.toml`'s
//! `[package.metadata.android]` block, so a hardcoded path is
//! safe until that ever changes (at which point this constant
@@ -40,14 +40,14 @@ use std::path::PathBuf;
/// constant and the Cargo metadata together if the package id
/// ever changes.
#[cfg(target_os = "android")]
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.solitairequest.app/files";
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.ferrousapp.solitaire/files";
/// Returns the per-user data directory for the current target,
/// or `None` if the platform doesn't expose one (rare; usually
/// indicates a broken `$HOME` or `$XDG_*` configuration on a
/// minimal Linux container).
///
/// Callers append `solitaire_quest/<file>` themselves. See the
/// Callers append `ferrous_solitaire/<file>` themselves. See the
/// module-level doc comment for the per-platform behaviour and
/// why Android uses a hardcoded path.
pub fn data_dir() -> Option<PathBuf> {
@@ -87,6 +87,6 @@ mod tests {
#[test]
fn data_dir_returns_sandbox_path_on_android() {
let dir = data_dir().expect("android must report a data dir");
assert_eq!(dir, PathBuf::from("/data/data/com.solitairequest.app/files"));
assert_eq!(dir, PathBuf::from("/data/data/com.ferrousapp.solitaire/files"));
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use chrono::{Datelike, NaiveDate};
pub use solitaire_sync::progress::level_for_xp;
pub use solitaire_sync::PlayerProgress;
const APP_DIR_NAME: &str = "solitaire_quest";
const APP_DIR_NAME: &str = "ferrous_solitaire";
const FILE_NAME: &str = "progress.json";
/// Deterministic seed derived from a date, identical for all players globally.
+3 -3
View File
@@ -1,7 +1,7 @@
//! Win-game replay recording + storage.
//!
//! When a player wins, the engine freezes the in-memory recording into a
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
//! [`Replay`] and persists it to `<data_dir>/ferrous_solitaire/latest_replay.json`
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
//! action that loads it via [`load_latest_replay_from`] so the player can
//! revisit (or, in a future build, watch the engine re-execute) the path
@@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "solitaire_quest";
const APP_DIR_NAME: &str = "ferrous_solitaire";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -221,7 +221,7 @@ impl Replay {
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/solitaire_quest/replays.json` (see
/// `<data_dir>/ferrous_solitaire/replays.json` (see
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
/// when [`append_replay_to_history`] pushes past the cap, the oldest
/// entry is dropped so the file never grows unbounded.
+5 -6
View File
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
const APP_DIR_NAME: &str = "solitaire_quest";
const APP_DIR_NAME: &str = "ferrous_solitaire";
const SETTINGS_FILE_NAME: &str = "settings.json";
/// Animation playback speed for card transitions.
@@ -275,7 +275,7 @@ fn default_music_volume() -> f32 {
}
fn default_theme_id() -> String {
"dark".to_string()
"classic".to_string()
}
/// Default tooltip-hover dwell delay in seconds. Mirrors
@@ -402,11 +402,10 @@ impl Settings {
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
// Migrate stale theme IDs: "default" was removed when the theme was
// renamed to "dark"; "classic" was briefly the default before "dark"
// was restored as the shipped default.
// Migrate stale theme IDs: "default" was the original name before it
// was renamed to "dark".
let selected_theme_id = match self.selected_theme_id.as_str() {
"default" | "classic" => "dark".to_string(),
"default" => "dark".to_string(),
_ => self.selected_theme_id,
};
Self {
+1 -1
View File
@@ -13,7 +13,7 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use crate::stats::StatsSnapshot;
const APP_DIR_NAME: &str = "solitaire_quest";
const APP_DIR_NAME: &str = "ferrous_solitaire";
const STATS_FILE_NAME: &str = "stats.json";
const GAME_STATE_FILE_NAME: &str = "game_state.json";
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
+5 -5
View File
@@ -29,7 +29,7 @@ static USER_THEME_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
/// Sub-folder under `dirs::data_dir()` where the project keeps every
/// per-user file. Matches the existing convention used by
/// `solitaire_data` for `settings.json`, `stats.json`, etc.
const APP_DIR_NAME: &str = "solitaire_quest";
const APP_DIR_NAME: &str = "ferrous_solitaire";
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
const THEME_DIR_NAME: &str = "themes";
@@ -97,19 +97,19 @@ mod tests {
use super::*;
#[test]
fn user_theme_dir_for_appends_solitaire_quest_themes() {
fn user_theme_dir_for_appends_ferrous_solitaire_themes() {
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
assert_eq!(
dir,
PathBuf::from("/tmp/data/solitaire_quest/themes"),
"user dir must nest under solitaire_quest/themes"
PathBuf::from("/tmp/data/ferrous_solitaire/themes"),
"user dir must nest under ferrous_solitaire/themes"
);
}
#[test]
fn user_theme_dir_for_handles_empty_root() {
let dir = user_theme_dir_for(PathBuf::new());
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
assert_eq!(dir, PathBuf::from("ferrous_solitaire/themes"));
}
#[test]
+175 -15
View File
@@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use bevy::prelude::*;
use bevy::window::WindowResized;
#[cfg(target_os = "android")]
use bevy::sprite::Anchor;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
@@ -65,6 +67,12 @@ pub const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
/// Font-size fraction for the large-print readability overlay on Android.
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
/// scale, where the baked-in PNG corner text is only ~10 px physical.
#[cfg(target_os = "android")]
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
@@ -163,6 +171,25 @@ pub struct CardEntity {
#[derive(Component, Debug)]
pub struct CardLabel;
/// Marker for the large-print rank+suit corner overlay on Android.
///
/// Spawned on top of PNG face cards (face-up only) at font size
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
/// readable at phone scale. Only exists when `CardImageSet` is present
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
#[cfg(target_os = "android")]
#[derive(Component, Debug, Clone, Copy)]
struct AndroidCornerLabel;
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
///
/// Covers the card art's own small corner rank/suit text so only the
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
#[cfg(target_os = "android")]
#[derive(Component, Debug, Clone, Copy)]
struct AndroidCornerBg;
/// Marker component indicating the card is currently highlighted as a hint.
/// `remaining` counts down in real seconds; the highlight is removed when it
/// reaches zero and the card sprite colour is restored to its normal value.
@@ -339,8 +366,8 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
));
}
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
/// back PNG has a visible perimeter against the dark felt.
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
/// thin perimeter against the dark felt, regardless of face state.
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardBackFrame,
@@ -429,6 +456,9 @@ impl Plugin for CardPlugin {
snap_cards_on_window_resize.after(collect_resize_events),
),
);
#[cfg(target_os = "android")]
app.add_systems(Update, resize_android_corner_labels);
}
}
@@ -454,13 +484,13 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
std::array::from_fn(|rank| {
asset_server.load(format!(
"cards/faces/{}{}.png",
"cards/faces/classic/{}{}.png",
RANK_STRS[rank], SUIT_CHARS[suit]
))
})
});
let backs = std::array::from_fn(|i| {
asset_server.load(format!("cards/backs/back_{i}.png"))
asset_server.load(format!("cards/backs/classic/back_{i}.png"))
});
commands.insert_resource(CardImageSet {
faces,
@@ -754,15 +784,15 @@ fn spawn_card_entity(
entity.with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
// Face-down cards get a thin contrasting border frame so the dark back
// PNG reads as a distinct rectangle against the dark felt.
if !card.face_up {
// Every card gets a thin border frame so it reads as a distinct
// rectangle against the dark felt, regardless of face state.
entity.with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
}
// When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
// On Android we additionally spawn a large-print corner label even in
// image mode so the rank/suit are legible at phone scale.
if card_images.is_none() {
entity.with_children(|b| {
b.spawn((
@@ -778,6 +808,12 @@ fn spawn_card_entity(
));
});
}
#[cfg(target_os = "android")]
if card_images.is_some() {
entity.with_children(|b| {
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast);
});
}
}
#[allow(clippy::too_many_arguments)]
@@ -831,16 +867,15 @@ fn update_card_entity(
// Despawn any stale children and re-add the per-card drop shadow plus,
// in solid-colour fallback mode, the label overlay. In image mode the
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
// rank/suit are baked into the PNG; on Android we also add a large-print
// corner overlay so they are legible at phone scale.
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
if !card.face_up {
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
}
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
@@ -856,6 +891,12 @@ fn update_card_entity(
));
});
}
#[cfg(target_os = "android")]
if card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast);
});
}
}
fn label_for(card: &Card) -> String {
@@ -928,6 +969,87 @@ fn label_visibility(card: &Card) -> Visibility {
}
}
/// Rank+suit string for the Android readability overlay.
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660U+2666, covered by FiraMono).
#[cfg(target_os = "android")]
fn mobile_label_for(card: &Card) -> String {
let rank = match card.rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit {
Suit::Clubs => "",
Suit::Diamonds => "",
Suit::Hearts => "",
Suit::Spades => "",
};
format!("{rank}{suit}")
}
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards. The background sprite covers the card art's own small
/// corner text so only the large overlay is visible.
#[cfg(target_os = "android")]
fn add_android_corner_label(
parent: &mut ChildSpawnerCommands,
card: &Card,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
) {
if !card.face_up {
return;
}
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
// Background covers ~3 monospace chars wide × 1 line tall.
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
// (3 chars = 1.8× font_size) plus a small margin.
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
// Solid background that hides the card art's small corner label.
parent.spawn((
AndroidCornerBg,
Sprite {
color: CARD_FACE_COLOUR,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
-card_size.x / 2.0 + inset + bg_w / 2.0,
card_size.y / 2.0 - inset - bg_h / 2.0,
0.015,
),
));
// Large rank+suit text drawn on top of the background.
parent.spawn((
AndroidCornerLabel,
CardLabel,
Text2d::new(mobile_label_for(card)),
TextFont { font_size, ..default() },
TextColor(text_colour(card, color_blind, high_contrast)),
Anchor::TOP_LEFT,
Transform::from_xyz(
-card_size.x / 2.0 + inset,
card_size.y / 2.0 - inset,
0.02,
),
));
}
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
@@ -1492,10 +1614,11 @@ fn update_stock_empty_indicator(
// ---------------------------------------------------------------------------
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
/// the centre of the count badge. A small inward offset keeps the chip from
/// drifting half-off the card while still reading as "attached" to the
/// corner.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
/// the centre of the count badge. Must satisfy `|x| >= STOCK_BADGE_SIZE.x / 2`
/// so the badge right edge stays inside the stock pile and never overlaps the
/// adjacent waste pile — critical on Android where `H_GAP_DIVISOR = 32` gives
/// an inter-pile gap of only ~4 px.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-20.0, -8.0);
/// Width / height of the badge background sprite, in world pixels. Sized so
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
@@ -1836,6 +1959,43 @@ fn resize_cards_in_place(
}
}
/// Updates font size and top-left anchor transform of every
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
/// change or any window resize). The full despawn/respawn path in
/// `update_card_entity` already handles game-state changes; this system
/// covers the resize-only path where children are mutated in place.
#[cfg(target_os = "android")]
fn resize_android_corner_labels(
layout: Res<LayoutResource>,
card_images: Option<Res<CardImageSet>>,
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
mut bg_query: Query<
(&mut Sprite, &mut Transform),
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
>,
) {
if !layout.is_changed() || card_images.is_none() {
return;
}
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
let text_x = -layout.0.card_size.x / 2.0 + inset;
let text_y = layout.0.card_size.y / 2.0 - inset;
for (mut font, mut transform) in text_query.iter_mut() {
font.font_size = font_size;
transform.translation.x = text_x;
transform.translation.y = text_y;
}
for (mut sprite, mut transform) in bg_query.iter_mut() {
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
transform.translation.x = text_x + bg_w / 2.0;
transform.translation.y = text_y - bg_h / 2.0;
}
}
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
/// expands as the player reveals cards while staying within the window.
+2 -2
View File
@@ -1302,7 +1302,7 @@ mod tests {
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
/// and don't touch `~/.local/share/solitaire_quest/game_state.json`.
/// and don't touch `~/.local/share/ferrous_solitaire/game_state.json`.
fn test_app(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
@@ -1316,7 +1316,7 @@ mod tests {
// can't leak into per-test world state and trip the
// `pending.0.is_some()` guard in `auto_save_game_state` /
// `save_game_state_on_exit`. Without this clear, an
// unrelated `~/.local/share/solitaire_quest/game_state.json`
// unrelated `~/.local/share/ferrous_solitaire/game_state.json`
// would silently disable the auto-save path under test.
app.insert_resource(PendingRestoredGame(None));
// Override the system-time seed with a known value.
+5 -4
View File
@@ -13,10 +13,9 @@ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
#[cfg(not(target_os = "android"))]
use crate::ui_theme::{BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TYPE_CAPTION, VAL_SPACE_1};
/// Marker on the help overlay root node.
#[derive(Component, Debug)]
@@ -123,6 +122,7 @@ fn scroll_help_panel(
}
/// Each entry in the controls reference table.
#[allow(dead_code)]
struct ControlRow {
keys: &'static str,
description: &'static str,
@@ -243,6 +243,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
};
let font_row = font_section.clone();
#[cfg(not(target_os = "android"))]
let font_kbd = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
+1
View File
@@ -174,6 +174,7 @@ impl HomeMode {
/// The keyboard accelerator that dispatches the same launch event,
/// shown in a small chip on the card.
#[cfg(not(target_os = "android"))]
fn hotkey(self) -> &'static str {
match self {
HomeMode::Classic => "N",
+228 -62
View File
@@ -13,12 +13,14 @@ use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::avatar_plugin::AvatarResource;
use solitaire_data::SyncBackend;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop, SafeAreaInsets};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
@@ -138,6 +140,13 @@ pub struct HudColumn;
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Marker on the circular profile-picture button anchored to the
/// top-right of the HUD band. Pressing it opens the Profile overlay.
/// Shows the server avatar image when loaded; falls back to the player's
/// initial on a filled disc when no image is available.
#[derive(Component, Debug)]
pub struct HudAvatar;
/// Controls whether the in-game HUD (band, score column, action buttons) is
/// visible. Toggled on Android by tapping empty board space; always `Visible`
/// on desktop. Resets to `Visible` whenever a modal opens.
@@ -152,10 +161,13 @@ pub enum HudVisibility {
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>,
/// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility.
started_on_button: bool,
}
#[cfg(target_os = "android")]
const HUD_TAP_SLOP_PX: f32 = 15.0;
const HUD_TAP_SLOP_PX: f32 = 25.0;
/// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
@@ -395,13 +407,14 @@ impl Plugin for HudPlugin {
// WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
.add_systems(Update, update_hud.after(GameMutation))
.add_systems(
Update,
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
)
.add_systems(Update, restore_hud_on_modal)
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud)
@@ -446,7 +459,12 @@ impl Plugin for HudPlugin {
// Otherwise on a hover-state change (`Changed<Interaction>`),
// `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip.
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
;
// Desktop-only: cursor-proximity fade. On Android the bar
// visibility is toggled explicitly; cursor_position() returning
// Some(touch_pos) during a tap would otherwise fade the bar out.
#[cfg(not(target_os = "android"))]
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
@@ -461,16 +479,13 @@ impl Plugin for HudPlugin {
}
}
/// Spawns the translucent HUD band that anchors the action buttons
/// and primary readouts visually. Sits behind every other HUD element
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
/// without intercepting clicks from the buttons it sits under.
/// Spawns the invisible HUD band that reserves vertical space at the top of
/// the screen so the card layout (computed by `layout::compute_layout` using
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
///
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
/// same constant the card layout reserves at the top), so the band's
/// bottom edge lines up exactly with the top edge of the highest
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
/// alpha, so the green felt reads through subtly.
/// The entity carries no `BackgroundColor` — the green felt shows through.
/// A slim grey background is handled by each content section individually
/// (the bottom action bar has its own `BG_HUD_BAND` background).
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
const BASE_TOP: f32 = 0.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
@@ -483,10 +498,6 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
height: Val::Px(HUD_BAND_HEIGHT),
..default()
},
BackgroundColor(BG_HUD_BAND),
// Sit one z-rung below the HUD content so the buttons and text
// paint on top, but above the card sprites (which are 2D-world
// entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
HudBand,
@@ -684,6 +695,135 @@ fn spawn_hud(
});
}
/// Spawns the circular avatar / initials button anchored to the top-right
/// of the HUD band. Initial content is seeded from whatever resources are
/// available at startup; `update_hud_avatar` replaces the children whenever
/// `AvatarResource` or `SettingsResource` later changes.
fn spawn_hud_avatar(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands,
) {
const SIZE: f32 = 32.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let id = commands
.spawn((
HudAvatar,
Button,
Tooltip::new("Your profile — tap to open."),
Node {
position_type: PositionType::Absolute,
top: Val::Px(SPACE_2 + top_inset),
right: VAL_SPACE_3,
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(ACCENT_PRIMARY),
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.id();
spawn_avatar_child(
&mut commands,
id,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Re-spawns the avatar circle content (image or initials) whenever either
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
/// image arriving after download and the username changing after login.
fn update_hud_avatar(
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
q: Query<Entity, With<HudAvatar>>,
mut commands: Commands,
) {
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
if !avatar_changed && !settings_changed {
return;
}
let Ok(entity) = q.single() else {
return;
};
commands.entity(entity).despawn_related::<Children>();
spawn_avatar_child(
&mut commands,
entity,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Populates the avatar container with either the downloaded image or an
/// initials fallback disc. Called from both the startup spawn and the
/// reactive update system so the rendering logic lives in one place.
fn spawn_avatar_child(
commands: &mut Commands,
parent: Entity,
avatar: Option<&AvatarResource>,
settings: Option<&SettingsResource>,
font_res: Option<&FontResource>,
) {
const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| {
b.spawn((
ImageNode::new(handle),
Node {
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
..default()
},
));
});
} else {
let initial = settings
.and_then(|s| match &s.0.sync_backend {
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
SyncBackend::Local => None,
})
.and_then(|c| c.to_uppercase().next())
.unwrap_or('?');
commands.entity(parent).with_children(|b| {
b.spawn((
Text::new(initial.to_string()),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 14.0,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
}
}
/// Opens the Profile overlay when the avatar button is pressed.
fn handle_avatar_button(
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
toggle_profile.write(ToggleProfileRequestEvent);
}
}
}
/// Spawns the action button bar anchored to the top-right of the window.
/// Each child is a clickable button mirroring a keyboard accelerator —
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
@@ -697,23 +837,19 @@ fn spawn_action_buttons(
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let bottom_inset = insets.as_deref().copied().unwrap_or_default().bottom;
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
// fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band
// of 370 dp). On desktop, keep the descriptive text labels.
// On Android, compact Unicode symbols fit all 7 buttons in one row.
// On desktop, keep the descriptive text labels.
#[cfg(target_os = "android")]
let (max_width, col_gap, row_gap_val) =
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
let col_gap = Val::Px(4.0);
#[cfg(not(target_os = "android"))]
let (max_width, col_gap, row_gap_val) =
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
let col_gap = VAL_SPACE_2;
#[cfg(target_os = "android")]
let labels = (
@@ -721,9 +857,8 @@ fn spawn_action_buttons(
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
/* help */ "?",
/* hint */ "\u{2192}", // rightwards arrow (Arrows block, confirmed FiraMono)
/* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono)
// replaces ▾ (U+25BE) which is absent from FiraMono
/* hint */ "!", // ! attention/alert — semantically: "look here"
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
/* new */ "+",
);
#[cfg(not(target_os = "android"))]
@@ -737,23 +872,33 @@ fn spawn_action_buttons(
"New Game",
);
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `bottom` is set to `bottom_inset` initially; `SafeAreaAnchoredBottom` keeps
// it correct as Android insets arrive in later frames.
commands
.spawn((
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: Val::Px(SPACE_2 + top_inset),
bottom: Val::Px(bottom_inset),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
max_width,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::FlexEnd,
justify_content: JustifyContent::Center,
column_gap: col_gap,
row_gap: row_gap_val,
row_gap: VAL_SPACE_2,
align_items: AlignItems::Center,
padding: UiRect {
left: VAL_SPACE_3,
right: VAL_SPACE_3,
top: VAL_SPACE_2,
bottom: VAL_SPACE_2,
},
..default()
},
BackgroundColor(BG_HUD_BAND),
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
SafeAreaAnchoredBottom { base_bottom: 0.0 },
HudActionBar,
))
.with_children(|row| {
@@ -799,8 +944,7 @@ fn spawn_action_button<M: Component>(
// visibly clutter the narrow-viewport action row. Force the hint
// off on Android; the chevrons on Menu/Modes remain because they
// indicate dropdown behaviour and still apply on touch.
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let hotkey = if cfg!(target_os = "android") { None } else { hotkey };
let hotkey_font = TextFont {
font: font.font.clone(),
@@ -1013,6 +1157,14 @@ fn spawn_modes_popover(
));
}
// Popover opens upward from just above the bottom action bar.
// Use a platform-aware offset that clears the bar height + safe-area
// gesture zone on Android, and the flat bar height on desktop.
#[cfg(target_os = "android")]
let popover_bottom = Val::Px(200.0);
#[cfg(not(target_os = "android"))]
let popover_bottom = Val::Px(80.0);
commands
.spawn((
ModesPopover,
@@ -1020,7 +1172,7 @@ fn spawn_modes_popover(
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: Val::Px(50.0),
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
@@ -1205,6 +1357,12 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
),
];
// Same upward-opening placement as ModesPopover.
#[cfg(target_os = "android")]
let popover_bottom = Val::Px(200.0);
#[cfg(not(target_os = "android"))]
let popover_bottom = Val::Px(80.0);
commands
.spawn((
MenuPopover,
@@ -1212,7 +1370,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: Val::Px(50.0),
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
@@ -1424,9 +1582,9 @@ impl Default for HudActionFade {
}
}
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
/// in as the cursor approaches, not only once it crosses into the band.
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself.
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
@@ -1435,7 +1593,7 @@ const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if
/// the cursor is in the reveal zone (top of window) or off-screen
/// the cursor is in the reveal zone (bottom of window) or off-screen
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
@@ -1447,8 +1605,9 @@ fn update_action_fade(
let Ok(window) = windows.single() else {
return;
};
let height = window.resolution.height();
fade.target = match window.cursor_position() {
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
Some(_) => 0.0,
// Off-window cursor: assume keyboard navigation and keep the
// bar visible so Tab cycling doesn't lead to invisible focus.
@@ -2281,15 +2440,9 @@ fn update_hud_typography(
}
}
#[allow(clippy::type_complexity)]
fn apply_hud_visibility(
hud_vis: Res<HudVisibility>,
mut nodes: Query<
&mut Visibility,
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
>,
window_entities: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
) {
if !hud_vis.is_changed() {
return;
@@ -2299,16 +2452,11 @@ fn apply_hud_visibility(
} else {
Visibility::Hidden
};
for mut node_vis in &mut nodes {
*node_vis = v;
}
if let Some((entity, window)) = window_entities.iter().next() {
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
for mut vis in &mut action_bar {
*vis = v;
}
// The bottom action bar is a pure overlay — it does not claim any
// space in the card layout, so no WindowResized event is needed.
}
fn restore_hud_on_modal(
@@ -2328,29 +2476,47 @@ fn toggle_hud_on_tap(
paused: Option<Res<PausedResource>>,
mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>,
buttons: Query<&Interaction, With<ActionButton>>,
) {
use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
// Drain buffered events so they don't replay in the frame after
// the scrim despawns, which would trigger a spurious visibility
// toggle as the resume/close button tap's Started+Ended pair
// replays in the now-scrim-free frame.
for _ in touch_events.read() {}
tracker.start_pos = None;
tracker.started_on_button = false;
return;
}
for event in touch_events.read() {
match event.phase {
TouchPhase::Started => {
tracker.start_pos = Some(event.position);
// Record whether the finger-down landed on a button so
// the finger-up doesn't double-fire (toggle bar + press
// button at the same time).
tracker.started_on_button =
buttons.iter().any(|i| *i != Interaction::None);
}
TouchPhase::Ended if drag.is_idle() => {
let on_button = tracker.started_on_button;
if let Some(start) = tracker.start_pos.take() {
if (event.position - start).length() < HUD_TAP_SLOP_PX {
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
}
tracker.started_on_button = false;
}
TouchPhase::Canceled | TouchPhase::Moved => {
// Moved: don't clear start_pos — Android fires Moved for normal
// tap jitter, and the distance check at Ended already rejects
// real drags. Clearing here would silently swallow tap toggles.
TouchPhase::Canceled => {
tracker.start_pos = None;
tracker.started_on_button = false;
}
_ => {}
}
+2 -3
View File
@@ -1785,9 +1785,8 @@ mod tests {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
let expected = layout.card_size.y * 2.5;
// Expected: card_height + 6 fan steps.
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
assert!(
(size.y - expected).abs() < 1e-3,
"expected {expected}, got {}",
+37 -20
View File
@@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
/// which rendered the cards ~3.6 % squashed vertically.
const CARD_ASPECT: f32 = 1.4523;
/// Divisor used to derive the horizontal gap between columns from the card
/// width: `h_gap = card_width / H_GAP_DIVISOR`.
///
/// This constant also drives `card_width_width_based`:
/// total layout width = 7*card_width + 8*h_gap = card_width*(7 + 8/H_GAP_DIVISOR)
/// → card_width = window.x / (7 + 8/H_GAP_DIVISOR)
///
/// Desktop (H_GAP_DIVISOR = 4): card_width = window.x / 9 — existing behaviour.
/// Android (H_GAP_DIVISOR = 32): card_width = window.x / 7.25 — cards are ~10 %
/// wider than at divisor 8, with very tight gaps (~4 px) that are still visible
/// as a faint seam between columns. The primary readability boost on Android
/// comes from the `AndroidCornerLabel` overlay in `card_plugin`, but maximising
/// the physical card size helps too.
#[cfg(not(target_os = "android"))]
const H_GAP_DIVISOR: f32 = 4.0;
#[cfg(target_os = "android")]
const H_GAP_DIVISOR: f32 = 32.0;
/// Fraction of card height used as vertical padding between the top row and
/// the tableau row.
const VERTICAL_GAP_FRAC: f32 = 0.2;
@@ -57,7 +75,7 @@ const VERTICAL_GAP_FRAC: f32 = 0.2;
/// column must fit at this fraction). On desktop (height-limited) windows the
/// adaptive computation returns this value exactly; on portrait phones it
/// expands to fill available vertical space.
const TABLEAU_FAN_FRAC: f32 = 0.25;
const TABLEAU_FAN_FRAC: f32 = 0.18;
/// Minimum fraction for face-down tableau cards. Scales proportionally with
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
@@ -66,7 +84,7 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
/// enough of each card back to read as a meaningful stack rather than a
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
/// the adaptive scaling in `compute_layout`.
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
/// after every face-down card has flipped on column 7. Layout sizing must keep
@@ -77,15 +95,14 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
///
/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line.
/// Android: 128 px accommodates the two-row button wrap on narrow phones
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
/// buttons overlaps the top card row.
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
/// Android: 80 px gives the same content rows comfortable clearance.
/// (Previously 128 px when action buttons lived in the top band; those are
/// now in the bottom bar so the larger reserve is no longer needed.)
#[cfg(not(target_os = "android"))]
pub const HUD_BAND_HEIGHT: f32 = 64.0;
#[cfg(target_os = "android")]
pub const HUD_BAND_HEIGHT: f32 = 128.0;
pub const HUD_BAND_HEIGHT: f32 = 80.0;
/// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -150,8 +167,10 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
let window = window.max(MIN_WINDOW);
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
let card_width_width_based = window.x / 9.0;
// Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR.
// Total = card_width*(7 + 8/H_GAP_DIVISOR) = window.x → card_width = window.x/card_width_divisor.
let card_width_divisor = 7.0 + 8.0 / H_GAP_DIVISOR;
let card_width_width_based = window.x / card_width_divisor;
// Height-based candidate. The vertical budget below the top row must hold
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
@@ -176,13 +195,12 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
let card_height = card_width * CARD_ASPECT;
let card_size = Vec2::new(card_width, card_height);
let h_gap = card_width / 4.0;
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
// sizing is height-limited (tall/narrow windows), this is smaller than
// window.x, so the grid is centred horizontally; otherwise side_margin
// collapses to h_gap and the geometry matches the original width-based
// layout exactly.
let total_grid_width = 9.0 * card_width;
let h_gap = card_width / H_GAP_DIVISOR;
// Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width.
// When card sizing is height-limited (tall/narrow windows) this is smaller than
// window.x and the grid is centred horizontally; otherwise side_margin collapses
// to h_gap and the geometry fills the window exactly.
let total_grid_width = card_width_divisor * card_width;
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
let left_edge = -window.x / 2.0;
let col_x = |col: usize| -> f32 {
@@ -402,11 +420,10 @@ mod tests {
#[test]
fn tall_narrow_window_keeps_width_based_sizing() {
// Tall narrow window: there's plenty of vertical budget, so width is
// the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly.
// the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR).
let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let width_based = window.x / 9.0;
let width_based = window.x / (7.0 + 8.0 / H_GAP_DIVISOR);
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
"expected width-based sizing (card_width {} should equal {})",
+7 -2
View File
@@ -31,9 +31,11 @@ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
#[cfg(not(target_os = "android"))]
use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
// ---------------------------------------------------------------------------
@@ -86,6 +88,7 @@ pub struct OnboardingSlideIndex(pub u8);
// ---------------------------------------------------------------------------
/// A single `key — description` pair shown on slide 3.
#[cfg(not(target_os = "android"))]
struct HotkeyRow {
keys: &'static str,
description: &'static str,
@@ -96,6 +99,7 @@ struct HotkeyRow {
/// Updating the list in `help_plugin.rs` should be mirrored here. The
/// ARCHITECTURE.md decision log calls out that we copy values rather than
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
HotkeyRow { keys: "U", description: "Undo last move" },
@@ -359,6 +363,7 @@ fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResour
}
/// Slide 3 — Keyboard shortcuts.
#[cfg(not(target_os = "android"))]
fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont {
+337 -24
View File
@@ -33,8 +33,11 @@ use crate::replay_playback::{
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
toggle_pause_replay_playback, ReplayPlaybackState,
};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_data::ReplayMove;
use crate::resources::GameStateResource;
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
@@ -154,6 +157,12 @@ const MOVE_LOG_PREV_ROWS: usize = 2;
/// preview-shape might need rethinking.
const MOVE_LOG_NEXT_ROWS: usize = 2;
/// Vertical offset from the top edge of the window to the top edge of the
/// mini-tableau preview panel. Places the panel 8 px below the banner's
/// bottom edge so the two surfaces don't overlap. Derived from
/// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows.
const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0;
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
/// reads as a clear "this is a UI strip" callout while still letting the
/// felt show through enough to anchor the banner to the play surface.
@@ -404,6 +413,34 @@ pub struct ReplayOverlayMoveLogNextRow {
pub offset: u8,
}
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
/// to despawn all of them, rather than keeping a separate query per
/// entity type. Future sibling overlay surfaces just need this marker
/// at spawn time — no changes to the despawn logic required.
#[derive(Component, Debug)]
pub struct DespawnWithReplay;
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
/// panel that shows a compact summary of the live game state during
/// replay: the four foundation tops and the stock / waste heads.
/// Spawned as a sibling root entity (same lifecycle pattern as
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauPanel;
/// Marker on the foundations row `Text` inside the mini-tableau panel.
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauFoundations;
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
/// [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauStockWaste;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -451,6 +488,8 @@ impl Plugin for ReplayOverlayPlugin {
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
handle_pause_button,
handle_step_button,
@@ -476,10 +515,8 @@ impl Plugin for ReplayOverlayPlugin {
fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
roots: Query<Entity, With<ReplayOverlayRoot>>,
despawnable: Query<Entity, With<DespawnWithReplay>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
@@ -487,30 +524,15 @@ fn react_to_state_change(
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = existing.iter().next().is_some();
let already_spawned = roots.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
for entity in &existing {
commands.entity(entity).despawn();
}
// Floating chip lives outside the UI tree (world-space
// entity), so the banner-root despawn doesn't reach it.
// Despawn separately on the same state transition so both
// disappear together when the replay ends.
for entity in &floating_chips {
commands.entity(entity).despawn();
}
// Move-log panel is also a separate root entity (sibling
// of the banner anchored to the viewport's bottom edge),
// so the banner-root despawn doesn't reach it either.
for entity in &move_log_panels {
commands.entity(entity).despawn();
}
// Tableau dim layer is also a separate root entity — same
// pattern as the move-log panel.
for entity in &dim_layers {
// Despawn all sibling root entities in one loop — every entity
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
// exactly this purpose.
for entity in &despawnable {
commands.entity(entity).despawn();
}
}
@@ -546,6 +568,8 @@ fn spawn_overlay(
// entity spawned after the banner closure closes. Mirrors the
// floating-chip clone reasoning.
let font_handle_for_move_log = font_handle.clone();
// Fourth clone for the mini-tableau preview panel.
let font_handle_for_mini_tableau = font_handle.clone();
let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
@@ -562,6 +586,7 @@ fn spawn_overlay(
// component — purely visual.
commands.spawn((
ReplayTableauDimLayer,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -585,6 +610,7 @@ fn spawn_overlay(
commands
.spawn((
ReplayOverlayRoot,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -967,6 +993,7 @@ fn spawn_overlay(
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
DespawnWithReplay,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
@@ -996,6 +1023,7 @@ fn spawn_overlay(
commands
.spawn((
ReplayOverlayMoveLogPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -1111,6 +1139,68 @@ fn spawn_overlay(
));
}
});
// Mini-tableau preview panel — right-edge anchor, just below the banner.
// Compact two-row readout: foundation tops then stock/waste head.
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayMiniTableauPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(0.0),
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: VAL_SPACE_1,
border: UiRect::left(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
panel.spawn((
Text::new("\u{258C} BOARD"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauFoundations,
Text::new("F: -- -- -- --"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauStockWaste,
Text::new("STK:-- WST:--"),
TextFont {
font: font_handle_for_mini_tableau,
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}
/// Pure helper — returns the scrub-fill width as a percentage of the
@@ -1165,6 +1255,7 @@ fn keybind_footer_mode_text() -> &'static str {
/// pause/resume, the ESC accelerator for stop, and the ← / →
/// accelerators for paused single-move stepping. The footer never
/// lists unimplemented keybinds (would lie to users).
#[cfg(not(target_os = "android"))]
fn keybind_footer_hint_text() -> &'static str {
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
}
@@ -1553,6 +1644,118 @@ fn format_active_move_row(state: &ReplayPlaybackState) -> String {
format!("\u{25B6} {body}") // ▶
}
// ---------------------------------------------------------------------------
// Mini-tableau format helpers and update system
// ---------------------------------------------------------------------------
/// Pure helper — short rank symbol. Single character for all ranks
/// except Ten which uses "T" (keeps every card a consistent 2-char
/// wide render: rank-char + suit-glyph). Players familiar with
/// solitaire shorthand read "T" instantly; the suit glyph immediately
/// follows and disambiguates from an ambiguous "T".
fn format_rank_short(rank: Rank) -> &'static str {
match rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
}
}
/// Pure helper — Unicode suit glyph from FiraMono's covered range
/// (U+2660U+2666). These four code points are confirmed present in
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
fn format_suit_glyph(suit: Suit) -> &'static str {
match suit {
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Diamonds => "\u{2666}", // ♦
Suit::Clubs => "\u{2663}", // ♣
}
}
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
/// known card, or `"--"` for an absent top card (empty pile).
fn format_card_short(card: Option<&Card>) -> String {
match card {
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
None => "--".to_string(),
}
}
/// Pure helper — one-line summary of the four foundation tops.
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
/// Foundation slots are displayed in their natural 0-3 order
/// (matching the visual left-to-right order on screen).
fn format_foundations_row(game: &GameState) -> String {
let slots: [String; 4] = std::array::from_fn(|i| {
let top = game.piles
.get(&PileType::Foundation(i as u8))
.and_then(|p| p.cards.last());
format_card_short(top)
});
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
}
/// Pure helper — one-line stock / waste summary.
/// Renders as `STK:N WST:X♠` where N is the stock card count and
/// X♠ is the top waste card (or `--` when the waste pile is empty).
fn format_stock_waste_row(game: &GameState) -> String {
let stock_count = game.piles
.get(&PileType::Stock)
.map(|p| p.cards.len())
.unwrap_or(0);
let waste_top = game.piles
.get(&PileType::Waste)
.and_then(|p| p.cards.last());
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
}
/// Repaints the foundations row whenever [`GameStateResource`] changes.
/// Split into its own system (rather than combined with the stock/waste
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
/// queries in one system are always ambiguous regardless of marker
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
fn update_mini_tableau_foundations(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_foundations_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
/// guard, separate system to avoid the B0001 query conflict.
fn update_mini_tableau_stock_waste(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_stock_waste_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
// ---------------------------------------------------------------------------
// Playback-control button handlers
// ---------------------------------------------------------------------------
@@ -1762,6 +1965,7 @@ fn handle_stop_keyboard(
mod tests {
use super::*;
use chrono::NaiveDate;
use solitaire_core::card::{Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
@@ -3989,4 +4193,113 @@ mod tests {
fn dim_layer_z_is_below_replay_chrome() {
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
}
// -----------------------------------------------------------------------
// Mini-tableau preview tests
// -----------------------------------------------------------------------
fn mini_tableau_panel_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayMiniTableauPanel>()
.iter(app.world())
.count()
}
/// Mini-tableau panel spawns alongside the other overlay surfaces
/// when playback starts and despawns when it ends.
#[test]
fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
let mut app = headless_app();
app.update();
assert_eq!(
mini_tableau_panel_count(&mut app),
0,
"no mini-tableau panel while playback is Inactive",
);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
mini_tableau_panel_count(&mut app),
1,
"mini-tableau panel must spawn when playback starts",
);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
mini_tableau_panel_count(&mut app),
0,
"mini-tableau panel must despawn when playback ends",
);
}
/// `format_rank_short` maps every `Rank` variant to a single ASCII
/// character except Ten which maps to `"T"`.
#[test]
fn format_rank_short_all_ranks() {
assert_eq!(format_rank_short(Rank::Ace), "A");
assert_eq!(format_rank_short(Rank::Two), "2");
assert_eq!(format_rank_short(Rank::Three), "3");
assert_eq!(format_rank_short(Rank::Four), "4");
assert_eq!(format_rank_short(Rank::Five), "5");
assert_eq!(format_rank_short(Rank::Six), "6");
assert_eq!(format_rank_short(Rank::Seven), "7");
assert_eq!(format_rank_short(Rank::Eight), "8");
assert_eq!(format_rank_short(Rank::Nine), "9");
assert_eq!(format_rank_short(Rank::Ten), "T");
assert_eq!(format_rank_short(Rank::Jack), "J");
assert_eq!(format_rank_short(Rank::Queen), "Q");
assert_eq!(format_rank_short(Rank::King), "K");
}
/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
/// glyphs for each `Suit` variant (U+2660U+2666 confirmed on Android).
#[test]
fn format_suit_glyph_all_suits() {
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
}
/// `format_foundations_row` with a freshly-dealt game (all empty).
#[test]
fn format_foundations_row_empty_board() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
);
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
}
/// `format_stock_waste_row` with a freshly-dealt game: stock has
/// 24 cards, waste is empty.
#[test]
fn format_stock_waste_row_initial_state() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
);
let text = format_stock_waste_row(&game);
assert!(
text.starts_with("STK:"),
"row must start with STK: prefix; got {text:?}",
);
assert!(
text.contains("WST:--"),
"waste must show -- on a fresh deal; got {text:?}",
);
}
}
+1 -1
View File
@@ -552,7 +552,7 @@ mod tests {
.add_plugins(GamePlugin::headless())
.add_plugins(ReplayPlaybackPlugin);
// Disable game-state persistence so tests don't touch the
// real ~/.local/share/solitaire_quest/game_state.json.
// real ~/.local/share/ferrous_solitaire/game_state.json.
app.insert_resource(crate::game_plugin::GameStatePath(None));
app.insert_resource(crate::game_plugin::ReplayPath(None));
// Tick once so any startup systems flush before the first
+31 -1
View File
@@ -51,12 +51,25 @@ pub struct SafeAreaAnchoredTop {
pub base_top: f32,
}
/// Marker for `Node` entities whose `bottom` offset should be re-applied
/// as `base_bottom + SafeAreaInsets::bottom / scale`.
///
/// Use this for elements anchored to the bottom edge (e.g. a bottom action
/// bar) so they clear the Android gesture-navigation zone automatically.
#[derive(Component, Debug, Clone, Copy)]
pub struct SafeAreaAnchoredBottom {
pub base_bottom: f32,
}
pub struct SafeAreaInsetsPlugin;
impl Plugin for SafeAreaInsetsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SafeAreaInsets>()
.add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
.add_systems(
Update,
(apply_safe_area_anchors, apply_safe_area_bottom_anchors, apply_safe_area_to_modal_scrims),
);
#[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets);
@@ -89,6 +102,23 @@ fn apply_safe_area_anchors(
}
}
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) {
if !insets.is_changed() {
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let bottom_logical = insets.bottom / scale;
for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
}
}
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards don't extend into the Android gesture-navigation zone.
///
+2 -2
View File
@@ -64,7 +64,7 @@ pub struct StatsCell;
/// Resource holding the rolling [`ReplayHistory`] of recent winning
/// replays.
///
/// Populated from `<data_dir>/solitaire_quest/replays.json` at startup
/// Populated from `<data_dir>/ferrous_solitaire/replays.json` at startup
/// and refreshed in-place whenever the engine writes a new winning
/// replay so the Stats overlay's selector always reflects the current
/// on-disk history.
@@ -166,7 +166,7 @@ impl Default for StatsPlugin {
impl StatsPlugin {
/// Plugin configured with no persistence. Use in tests and headless apps
/// where touching `~/.local/share/solitaire_quest/stats.json` would be
/// where touching `~/.local/share/ferrous_solitaire/stats.json` would be
/// incorrect.
pub fn headless() -> Self {
Self { storage_path: None }
+1 -1
View File
@@ -129,7 +129,7 @@ fn load_initial_theme(
let id = settings
.as_deref()
.map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("dark");
.unwrap_or("classic");
let url = bundled_theme_url(id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
+1 -1
View File
@@ -307,7 +307,7 @@ mod tests {
.add_plugins(TimeAttackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
// Disable session persistence — tests must not touch the real
// ~/.local/share/solitaire_quest/time_attack_session.json.
// ~/.local/share/ferrous_solitaire/time_attack_session.json.
app.insert_resource(TimeAttackSessionPath(None));
// The plugin's startup-load hook may have populated TimeAttackResource
// from a real on-disk session. Reset it so each test starts inactive.
+1 -2
View File
@@ -335,8 +335,7 @@ pub fn spawn_modal_button<M: Component>(
variant: ButtonVariant,
font_res: Option<&FontResource>,
) {
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let hotkey = if cfg!(target_os = "android") { None } else { hotkey };
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont {
font: font_handle.clone(),
+1 -1
View File
@@ -3,7 +3,7 @@ services:
build:
context: ..
dockerfile: solitaire_server/Dockerfile
image: solitaire-quest-server:latest
image: ferrous-solitaire-server:latest
restart: unless-stopped
ports:
- "${SERVER_PORT:-8080}:8080"
+30
View File
@@ -59,6 +59,25 @@ header {
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
.hud-right { display: flex; align-items: center; gap: 10px; }
.hud-avatar-link { display: flex; align-items: center; text-decoration: none; }
.hud-avatar-inner {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.15);
background: var(--panel-hi);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
transition: border-color 120ms;
}
.hud-avatar-link:hover .hud-avatar-inner { border-color: var(--accent); }
.hud-avatar-inner img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.logo { font-size: 16px; font-weight: 700; }
.muted { color: var(--text-muted); font-size: 12px; }
.home-link {
@@ -98,6 +117,16 @@ button:disabled { opacity: 0.4; cursor: default; }
/* ── Board ───────────────────────────────────────────────────────────── */
.board-undo {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 50;
font-size: 15px;
padding: 8px 20px;
pointer-events: auto;
}
main {
flex: 1;
display: flex;
@@ -108,6 +137,7 @@ main {
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
#board {
position: relative;
flex: 1;
background: var(--felt);
display: flex;
+7
View File
@@ -40,12 +40,19 @@
<input type="checkbox" id="chk-draw3"> Draw 3
</label>
<button id="btn-theme" title="Switch card theme">Dark</button>
<a id="hud-avatar" href="/account" title="Account" class="hud-avatar-link" style="display:none">
<div class="hud-avatar-inner">
<img id="hud-avatar-img" src="" alt="" style="display:none">
<span id="hud-avatar-initials"></span>
</div>
</a>
</div>
</header>
<main>
<section id="board">
<div id="card-area"></div>
<button id="btn-board-undo" class="board-undo" title="Undo (Z)" disabled>↩ Undo</button>
</section>
</main>
+32 -5
View File
@@ -104,6 +104,7 @@ const hudTimer = document.getElementById("hud-timer");
const hudStock = document.getElementById("hud-stock");
const hudSeed = document.getElementById("hud-seed");
const btnUndo = document.getElementById("btn-undo");
const btnBoardUndo = document.getElementById("btn-board-undo");
const btnNew = document.getElementById("btn-new");
const chkDraw3 = document.getElementById("chk-draw3");
const btnTheme = document.getElementById("btn-theme");
@@ -244,6 +245,7 @@ function render(s) {
hudMoves.textContent = `Moves: ${s.move_count}`;
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
btnUndo.disabled = s.undo_stack_len === 0;
btnBoardUndo.disabled = s.undo_stack_len === 0;
const visible = new Map();
const addPile = (name, cards) =>
@@ -385,10 +387,9 @@ function flashIllegal(cardIds) {
// ── Input ─────────────────────────────────────────────────────────────────────
function attachHandlers() {
btnUndo.addEventListener("click", () => {
const r = game.undo();
if (r.ok) render(r.snapshot);
});
const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
btnUndo.addEventListener("click", doUndo);
btnBoardUndo.addEventListener("click", doUndo);
btnNew.addEventListener("click", () => startGame(randomSeed()));
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => {
@@ -404,7 +405,7 @@ function attachHandlers() {
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.key === "z" || e.key === "Z") { const r = game.undo(); if (r.ok) render(r.snapshot); }
if (e.key === "z" || e.key === "Z") doUndo();
if (e.key === "n" || e.key === "N") startGame(randomSeed());
});
@@ -662,5 +663,31 @@ function onBoardDblClick(e) {
if (!smartMove(hit.pileName, fromIndex)) flashIllegal([cards[fromIndex].id]);
}
// ── Avatar ────────────────────────────────────────────────────────────────────
async function loadAvatar() {
const token = localStorage.getItem("fs_token");
if (!token) return;
try {
const res = await fetch("/api/me", {
headers: { Authorization: "Bearer " + token },
});
if (!res.ok) return;
const me = await res.json();
const link = document.getElementById("hud-avatar");
const img = document.getElementById("hud-avatar-img");
const init = document.getElementById("hud-avatar-initials");
link.style.display = "flex";
if (me.avatar_url) {
img.src = me.avatar_url;
img.style.display = "block";
init.style.display = "none";
} else {
img.style.display = "none";
init.textContent = (me.username || "P")[0].toUpperCase();
}
} catch { /* not signed in — avatar stays hidden */ }
}
// ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error);
loadAvatar();
+2 -1
View File
@@ -30,7 +30,8 @@
<section id="board"></section>
<section id="controls">
<button id="btn-prev" disabled>⏮ Restart</button>
<button id="btn-restart" disabled>⏮ Restart</button>
<button id="btn-prev" disabled>◀ Back</button>
<button id="btn-play">▶ Play</button>
<button id="btn-step">⏭ Step</button>
<span id="progress" class="muted">step 0 / 0</span>
+30 -5
View File
@@ -62,6 +62,7 @@ const resultEl = document.getElementById("result");
const btnPlay = document.getElementById("btn-play");
const btnStep = document.getElementById("btn-step");
const btnPrev = document.getElementById("btn-prev");
const btnRestart = document.getElementById("btn-restart");
let player = null;
let replayJson = null;
@@ -122,6 +123,7 @@ function resetPlayer() {
}
player = new ReplayPlayer(replayJson);
btnPrev.disabled = true;
btnRestart.disabled = true;
btnStep.disabled = false;
btnPlay.disabled = false;
render(player.state());
@@ -134,6 +136,7 @@ function step() {
return null;
}
btnPrev.disabled = false;
btnRestart.disabled = false;
render(snap);
return snap;
}
@@ -301,13 +304,35 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS);
});
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
/// forwards to (step_idx - 1) without rendering intermediate frames, then
/// renders once so the CSS transition animates each card to its previous
/// position.
function stepBack() {
if (!player || player.step_idx() === 0) return;
if (playInterval) {
clearInterval(playInterval);
playInterval = null;
btnPlay.textContent = "▶ Play";
}
const target = player.step_idx() - 1;
player = new ReplayPlayer(replayJson);
for (let i = 0; i < target; i++) {
player.step();
}
render(player.state());
btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false;
btnPlay.disabled = false;
}
btnPrev.addEventListener("click", () => {
if (player) stepBack();
});
btnRestart.addEventListener("click", () => {
if (!replayJson) return;
// Drop every existing card so the next render fades them all in
// at the freshly-dealt positions. Without this, cards from the
// current state would slide to wherever the new deal puts them
// — confusing since the deal is supposed to look like a fresh
// start, not a continuation.
cardEls.forEach((el) => el.remove());
cardEls.clear();
resetPlayer();