Compare commits

..

18 Commits

Author SHA1 Message Date
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
17 changed files with 279 additions and 77 deletions
+40 -53
View File
@@ -6,10 +6,6 @@ on:
- 'v*' - 'v*'
env: env:
ANDROID_HOME: /opt/android-sdk
NDK_VERSION: 30.0.14904198
BUILD_TOOLS_VERSION: "36.1.0"
PLATFORM: android-34
APK_OUT: target/release/apk/ferrous-solitaire.apk APK_OUT: target/release/apk/ferrous-solitaire.apk
GITEA_URL: https://git.aleshym.co GITEA_URL: https://git.aleshym.co
REPO: funman300/Ferrous-Solitaire REPO: funman300/Ferrous-Solitaire
@@ -17,75 +13,56 @@ env:
jobs: jobs:
build-apk: build-apk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: git.aleshym.co/funman300/android-builder:latest
credentials:
username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Java 17 - name: Cache Cargo registry
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Cache Android SDK
uses: actions/cache@v4
id: android-cache
with:
path: /opt/android-sdk
key: android-sdk-${{ env.NDK_VERSION }}-${{ env.BUILD_TOOLS_VERSION }}
- name: Install Android SDK + NDK
if: steps.android-cache.outputs.cache-hit != 'true'
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
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"ndk;$NDK_VERSION" \
"build-tools;$BUILD_TOOLS_VERSION" \
"platforms;$PLATFORM"
- name: Set up Rust (stable + aarch64-android)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
- name: Cache Cargo
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.cargo/registry/index /usr/local/cargo/registry/index
~/.cargo/registry/cache /usr/local/cargo/registry/cache
~/.cargo/git/db /usr/local/cargo/git/db
~/.cargo/bin/cargo-ndk key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
target/aarch64-linux-android restore-keys: cargo-registry-android-
key: cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-android-
- name: Install cargo-ndk - name: Cache sccache
run: cargo-ndk --version 2>/dev/null || cargo install cargo-ndk --version 4.1.2 --locked 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 - name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
- name: Build release APK - name: Build release APK
env: env:
ANDROID_NDK_HOME: /opt/android-sdk/ndk/${{ env.NDK_VERSION }}
PROFILE: release PROFILE: release
ABIS: arm64-v8a ABIS: arm64-v8a
KEYSTORE: ./release.jks KEYSTORE: ./release.jks
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }} KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
KEY_ALIAS: release KEY_ALIAS: release
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }} 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 run: bash scripts/build_android_apk.sh
- name: Get tag name - name: sccache stats
id: tag if: always()
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: sccache --show-stats
- name: Create or get Gitea release - name: Create or get Gitea release
id: release id: release
@@ -94,7 +71,6 @@ jobs:
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}" BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}" AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
# Re-use an existing release for this tag (e.g. created manually).
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \ ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \ | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
2>/dev/null || true) 2>/dev/null || true)
@@ -116,7 +92,18 @@ jobs:
- name: Upload APK to release - name: Upload APK to release
run: | 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 \ curl -sf -X POST \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \ -H "$AUTH" \
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \ -F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
"${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}/releases/${{ steps.release.outputs.id }}/assets" "$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
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
- name: Install kustomize - name: Install kustomize
run: | 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 sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests - name: Pin image tag in deploy manifests
+37
View File
@@ -6,6 +6,43 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [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 ## [0.28.0] — 2026-05-14
### Changed ### Changed
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: d761a150 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
+15
View File
@@ -75,12 +75,27 @@ if [ -d "$RES_DIR" ]; then
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res" "$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
fi 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_ARGS=(
link link
-o "$STAGING/app-unsigned.apk" -o "$STAGING/app-unsigned.apk"
-I "$PLATFORM_JAR" -I "$PLATFORM_JAR"
--manifest "$MANIFEST" --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" ) [ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
# Add compiled resources if any # Add compiled resources if any
shopt -s nullglob shopt -s nullglob
+1 -3
View File
@@ -13,9 +13,7 @@
shared object name without the `lib` prefix or `.so` suffix. shared object name without the `lib` prefix or `.so` suffix.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.ferrousapp.solitaire" package="com.ferrousapp.solitaire">
android:versionCode="1"
android:versionName="1.0">
<uses-sdk <uses-sdk
android:minSdkVersion="26" android:minSdkVersion="26"
+4 -5
View File
@@ -275,7 +275,7 @@ fn default_music_volume() -> f32 {
} }
fn default_theme_id() -> String { fn default_theme_id() -> String {
"dark".to_string() "classic".to_string()
} }
/// Default tooltip-hover dwell delay in seconds. Mirrors /// Default tooltip-hover dwell delay in seconds. Mirrors
@@ -402,11 +402,10 @@ impl Settings {
/// their respective ranges after deserialization or hand-editing of /// their respective ranges after deserialization or hand-editing of
/// `settings.json`. /// `settings.json`.
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
// Migrate stale theme IDs: "default" was removed when the theme was // Migrate stale theme IDs: "default" was the original name before it
// renamed to "dark"; "classic" was briefly the default before "dark" // was renamed to "dark".
// was restored as the shipped default.
let selected_theme_id = match self.selected_theme_id.as_str() { 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.selected_theme_id,
}; };
Self { Self {
+2 -2
View File
@@ -484,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| { let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
std::array::from_fn(|rank| { std::array::from_fn(|rank| {
asset_server.load(format!( asset_server.load(format!(
"cards/faces/{}{}.png", "cards/faces/classic/{}{}.png",
RANK_STRS[rank], SUIT_CHARS[suit] RANK_STRS[rank], SUIT_CHARS[suit]
)) ))
}) })
}); });
let backs = std::array::from_fn(|i| { 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 { commands.insert_resource(CardImageSet {
faces, faces,
+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); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards. // Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so // Expected: card_height + 6 fan steps.
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5. let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
let expected = layout.card_size.y * 2.5;
assert!( assert!(
(size.y - expected).abs() < 1e-3, (size.y - expected).abs() < 1e-3,
"expected {expected}, got {}", "expected {expected}, got {}",
+2 -2
View File
@@ -75,7 +75,7 @@ const VERTICAL_GAP_FRAC: f32 = 0.2;
/// column must fit at this fraction). On desktop (height-limited) windows the /// column must fit at this fraction). On desktop (height-limited) windows the
/// adaptive computation returns this value exactly; on portrait phones it /// adaptive computation returns this value exactly; on portrait phones it
/// expands to fill available vertical space. /// 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 /// Minimum fraction for face-down tableau cards. Scales proportionally with
/// the adaptive face-up fraction so hit-testing and rendering stay in sync. /// the adaptive face-up fraction so hit-testing and rendering stay in sync.
@@ -84,7 +84,7 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
/// enough of each card back to read as a meaningful stack rather than a /// 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 /// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
/// the adaptive scaling in `compute_layout`. /// 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 /// 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 /// after every face-down card has flipped on column 7. Layout sizing must keep
+30
View File
@@ -59,6 +59,25 @@ header {
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; } .hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
.hud-right { display: flex; align-items: center; gap: 10px; } .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; } .logo { font-size: 16px; font-weight: 700; }
.muted { color: var(--text-muted); font-size: 12px; } .muted { color: var(--text-muted); font-size: 12px; }
.home-link { .home-link {
@@ -98,6 +117,16 @@ button:disabled { opacity: 0.4; cursor: default; }
/* ── Board ───────────────────────────────────────────────────────────── */ /* ── Board ───────────────────────────────────────────────────────────── */
.board-undo {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 50;
font-size: 15px;
padding: 8px 20px;
pointer-events: auto;
}
main { main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -108,6 +137,7 @@ main {
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */ /* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
#board { #board {
position: relative;
flex: 1; flex: 1;
background: var(--felt); background: var(--felt);
display: flex; display: flex;
+7
View File
@@ -40,12 +40,19 @@
<input type="checkbox" id="chk-draw3"> Draw 3 <input type="checkbox" id="chk-draw3"> Draw 3
</label> </label>
<button id="btn-theme" title="Switch card theme">Dark</button> <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> </div>
</header> </header>
<main> <main>
<section id="board"> <section id="board">
<div id="card-area"></div> <div id="card-area"></div>
<button id="btn-board-undo" class="board-undo" title="Undo (Z)" disabled>↩ Undo</button>
</section> </section>
</main> </main>
+34 -7
View File
@@ -103,7 +103,8 @@ const hudMoves = document.getElementById("hud-moves");
const hudTimer = document.getElementById("hud-timer"); const hudTimer = document.getElementById("hud-timer");
const hudStock = document.getElementById("hud-stock"); const hudStock = document.getElementById("hud-stock");
const hudSeed = document.getElementById("hud-seed"); const hudSeed = document.getElementById("hud-seed");
const btnUndo = document.getElementById("btn-undo"); const btnUndo = document.getElementById("btn-undo");
const btnBoardUndo = document.getElementById("btn-board-undo");
const btnNew = document.getElementById("btn-new"); const btnNew = document.getElementById("btn-new");
const chkDraw3 = document.getElementById("chk-draw3"); const chkDraw3 = document.getElementById("chk-draw3");
const btnTheme = document.getElementById("btn-theme"); const btnTheme = document.getElementById("btn-theme");
@@ -243,7 +244,8 @@ function render(s) {
hudScore.textContent = `Score: ${s.score}`; hudScore.textContent = `Score: ${s.score}`;
hudMoves.textContent = `Moves: ${s.move_count}`; hudMoves.textContent = `Moves: ${s.move_count}`;
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`; if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
btnUndo.disabled = s.undo_stack_len === 0; btnUndo.disabled = s.undo_stack_len === 0;
btnBoardUndo.disabled = s.undo_stack_len === 0;
const visible = new Map(); const visible = new Map();
const addPile = (name, cards) => const addPile = (name, cards) =>
@@ -385,10 +387,9 @@ function flashIllegal(cardIds) {
// ── Input ───────────────────────────────────────────────────────────────────── // ── Input ─────────────────────────────────────────────────────────────────────
function attachHandlers() { function attachHandlers() {
btnUndo.addEventListener("click", () => { const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
const r = game.undo(); btnUndo.addEventListener("click", doUndo);
if (r.ok) render(r.snapshot); btnBoardUndo.addEventListener("click", doUndo);
});
btnNew.addEventListener("click", () => startGame(randomSeed())); btnNew.addEventListener("click", () => startGame(randomSeed()));
btnWinNew.addEventListener("click", () => startGame(randomSeed())); btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => { chkDraw3.addEventListener("change", () => {
@@ -404,7 +405,7 @@ function attachHandlers() {
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return; 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()); 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]); 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 ───────────────────────────────────────────────────────────────────── // ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error); bootstrap().catch(console.error);
loadAvatar();
+1
View File
@@ -30,6 +30,7 @@
<section id="board"></section> <section id="board"></section>
<section id="controls"> <section id="controls">
<button id="btn-restart" disabled>⏮ Restart</button>
<button id="btn-prev" disabled>◀ Back</button> <button id="btn-prev" disabled>◀ Back</button>
<button id="btn-play">▶ Play</button> <button id="btn-play">▶ Play</button>
<button id="btn-step">⏭ Step</button> <button id="btn-step">⏭ Step</button>
+11
View File
@@ -62,6 +62,7 @@ const resultEl = document.getElementById("result");
const btnPlay = document.getElementById("btn-play"); const btnPlay = document.getElementById("btn-play");
const btnStep = document.getElementById("btn-step"); const btnStep = document.getElementById("btn-step");
const btnPrev = document.getElementById("btn-prev"); const btnPrev = document.getElementById("btn-prev");
const btnRestart = document.getElementById("btn-restart");
let player = null; let player = null;
let replayJson = null; let replayJson = null;
@@ -122,6 +123,7 @@ function resetPlayer() {
} }
player = new ReplayPlayer(replayJson); player = new ReplayPlayer(replayJson);
btnPrev.disabled = true; btnPrev.disabled = true;
btnRestart.disabled = true;
btnStep.disabled = false; btnStep.disabled = false;
btnPlay.disabled = false; btnPlay.disabled = false;
render(player.state()); render(player.state());
@@ -134,6 +136,7 @@ function step() {
return null; return null;
} }
btnPrev.disabled = false; btnPrev.disabled = false;
btnRestart.disabled = false;
render(snap); render(snap);
return snap; return snap;
} }
@@ -319,6 +322,7 @@ function stepBack() {
} }
render(player.state()); render(player.state());
btnPrev.disabled = player.step_idx() === 0; btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false; btnStep.disabled = false;
btnPlay.disabled = false; btnPlay.disabled = false;
} }
@@ -327,4 +331,11 @@ btnPrev.addEventListener("click", () => {
if (player) stepBack(); if (player) stepBack();
}); });
btnRestart.addEventListener("click", () => {
if (!replayJson) return;
cardEls.forEach((el) => el.remove());
cardEls.clear();
resetPlayer();
});
bootstrap(); bootstrap();