Compare commits

...

15 Commits

Author SHA1 Message Date
funman300 26283b5478 Merge pull request 'fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)' (#111) from fix/apk-signing-v1-obtainium into master
Android Release / build-apk (push) Successful in 5m15s
2026-06-25 18:16:57 +00:00
funman300 3627e9f9cf fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)
The apksigner step relied on auto scheme selection, which produced an APK
carrying invalid v1 (JAR) signature files: META-INF/*.SF and *.RSA were
present but failed v1 verification (apksigner reports `v1 scheme: false`
while v2/v3 verify). Android installs such an APK fine via v2/v3, but
Obtainium parses the legacy v1 certificate at install time, gets an empty
cert list, and crashes with:

  RangeError (length): Invalid value: valid value range is empty: 0

This is why the app adds fine in Obtainium (Gitea API only) but fails on
install (APK parse). minSdk is 26, so v1/JAR signing is unnecessary —
sign explicit v2+v3 only (matching modern Android tooling for minSdk >= 24)
and pass --min-sdk-version 26. Adds a post-sign guard that fails the build
if any META-INF v1 signature files remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:16:38 -07:00
funman300 b81a79c51c Merge pull request 'docs(handoff): mark physical-device smoke test as the only v0.40.0 item left' (#110) from docs/handoff-only-smoke-test into master 2026-06-25 17:48:31 +00:00
funman300 968721eeb4 docs(handoff): mark physical-device smoke test as the only v0.40.0 item left
Elevates the physical-device smoke test as the single remaining task for the
v0.40.0 release and clarifies the Matomo validation is an independent task,
not a release blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:48:16 -07:00
funman300 780e82ca4b Merge pull request 'docs(handoff): record v0.40.0 release' (#109) from docs/handoff-v0.40.0 into master 2026-06-25 17:46:06 +00:00
funman300 207747db4b docs(handoff): record v0.40.0 release
Updates SESSION_HANDOFF.md with the v0.40.0 Android release (PRs #105/#106/#108),
the pre-release validation performed, and the still-open physical-device smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:45:49 -07:00
funman300 c66baceb10 Merge pull request 'docs(android): update NDK reference to 30.0.14904198' (#108) from docs/android-ndk-version into master
Android Release / build-apk (push) Successful in 5m39s
2026-06-25 17:37:03 +00:00
funman300 329f224ffd docs(android): update NDK reference to 30.0.14904198
The setup doc pinned NDK 26.3.11579264, but newer NDKs build fine
(verified locally on 30.0.14904198 / build-tools 37.0.0: cross-compile,
android-target clippy, and a full signed arm64-v8a APK). Note that the
exact versions are not load-bearing and build_android_apk.sh
auto-discovers the newest installed NDK/build-tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:36:48 -07:00
Gitea CI 2fc190ee42 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m22s
Web E2E / web-e2e (push) Successful in 4m39s
2026-06-25 17:20:48 +00:00
funman300 060efaee7b Merge pull request 'docs(changelog): note Draw-Three waste fan hit-test fix' (#107) from docs/changelog-waste-fan into master 2026-06-25 17:13:51 +00:00
funman300 ef599ffa17 docs(changelog): note Draw-Three waste fan hit-test fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:13:39 -07:00
funman300 22334e0dd5 Merge pull request 'fix(engine): share Draw-Three waste fan step between renderer and hit-test' (#106) from fix/draw-three-waste-fan-hittest into master
Build and Deploy / build-and-push (push) Successful in 1m51s
Web WASM Rebuild / rebuild (push) Successful in 7m36s
2026-06-25 17:11:26 +00:00
funman300 942b9c2161 fix(engine): share Draw-Three waste fan step between renderer and hit-test
The waste fan x-offset was computed two ways: the renderer used
tableau_col_step * 0.224 while the hit-test hard-coded card_size.x * 0.28.
These coincide on desktop (col_step = 1.25*cw) but drift on Android, where
tighter column spacing (H_GAP_DIVISOR=32, col_step ~= 1.03*cw) makes the
renderer fan at ~0.231*cw. The top fanned waste card's sprite then sits
~14px left of its click target, so dragging the visible top card grabs
the card beneath it.

Extract waste_fan_step() and tableau_col_step() as the single source for
both the renderer (card_plugin::card_positions) and the hit-test
(input_plugin::card_position) so they can no longer diverge. Add a
regression test that simulates Android-tight spacing and asserts the hit
target tracks the renderer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:10:57 -07:00
funman300 fde863a4e4 Merge pull request 'test(engine): regression tests for waste-card draggability' (#105) from test/waste-draggable-regression into master
Build and Deploy / build-and-push (push) Successful in 2m6s
Web WASM Rebuild / rebuild (push) Successful in 6m42s
2026-06-25 16:58:15 +00:00
funman300 0cf5fc4293 test(engine): regression tests for waste-card draggability
Adds two find_draggable_at tests covering the reported stock/waste
drag bug: clicking the visible top of a multi-card waste must pick the
top index (not the buffer card beneath), and a lone waste card must
still be draggable. Both pass against current logic, pinning the
correct behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:56 -07:00
7 changed files with 213 additions and 47 deletions
+5
View File
@@ -47,6 +47,11 @@ project follows [Semantic Versioning](https://semver.org/).
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste - **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
clicks, delayed first-run onboarding until splash teardown, and kept dragged clicks, delayed first-run onboarding until splash teardown, and kept dragged
stacks above all piles. stacks above all piles.
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
previously diverged under Android's tighter column spacing, shifting the top
fanned waste card's hit target onto the card beneath it, so dragging the visible
card played the wrong one.
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface - **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior. sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched - **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
+52 -17
View File
@@ -1,16 +1,40 @@
# Ferrous Solitaire — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-09AVD Android launch smoke passed; physical-device gate remains. **Last updated:** 2026-06-25v0.40.0 released (Android APK published); physical-device gate remains.
--- ---
## Current state ## Current state
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes. - **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105#108).
- **Latest tag:** `v0.39.0` - **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
- **Working tree:** clean. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed. - **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke. - **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up. - **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
---
## v0.40.0 release (2026-06-25)
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
pick it up automatically.
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
| PR | Summary |
|----|---------|
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
| #108 | **docs(android):** NDK reference updated `26.3.11579264``30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
Pre-release validation performed locally this session: workspace clippy/test/build
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
proving the `build_android_apk.sh` packaging pipeline end-to-end.
--- ---
@@ -103,12 +127,23 @@ Three bugs fixed:
## Open punch list ## Open punch list
### 1. Android APK launch verification (Option A) ### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
Physical device test: install the latest APK on a real Android device (not AVD), This is the **single outstanding task** for the v0.40.0 Android release. Everything
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI. else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device clippy, release manifest sanity, a full local signed APK, the published release, and
smoke testing is the only gate. Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
The signed release APK is published (grab it from the release page, or use the local
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed `target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
@@ -117,13 +152,13 @@ rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR. filtered logcat showed no Ferrous panic/fatal/ANR.
### 2. Matomo analytics live validation ### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine Separate, ongoing task unrelated to the Android release. `Settings` has
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
validation against the deployed Matomo instance. Use `AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
`docs/analytics-validation.md` for the native validation checklist and the deployed Matomo instance. Use `docs/analytics-validation.md` for the native
current web/WASM decision notes. validation checklist and the current web/WASM decision notes.
--- ---
+7 -2
View File
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
echo '' echo ''
echo '# Android dev' echo '# Android dev'
echo 'export ANDROID_HOME="$HOME/Android/Sdk"' echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"' echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"' echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"' echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
} >> ~/.bashrc } >> ~/.bashrc
@@ -49,10 +49,15 @@ sdkmanager \
"platform-tools" \ "platform-tools" \
"platforms;android-34" \ "platforms;android-34" \
"build-tools;34.0.0" \ "build-tools;34.0.0" \
"ndk;26.3.11579264" \ "ndk;30.0.14904198" \
"emulator" \ "emulator" \
"system-images;android-34;google_apis;x86_64" "system-images;android-34;google_apis;x86_64"
# The exact NDK/build-tools versions above are not load-bearing — newer ones
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
# (step 3) to whatever version you actually install here.
# 6. AVD for testing (one-time). # 6. AVD for testing (one-time).
echo no | avdmanager create avd \ echo no | avdmanager create avd \
-n bevy_test \ -n bevy_test \
+21 -1
View File
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")" mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT" echo ">>> apksigner sign -> $APK_OUT"
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
# certificate at install time, gets an empty cert list, and crashes with
# "RangeError (length): Invalid value: valid value range is empty: 0".
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
# modern Android tooling produces for minSdk >= 24.
"$BT/apksigner" sign \ "$BT/apksigner" sign \
--ks "$KEYSTORE" \ --ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \ --ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \ --ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \ --key-pass "pass:$KEY_PASS" \
--min-sdk-version 26 \
--v1-signing-enabled false \
--v2-signing-enabled true \
--v3-signing-enabled true \
--out "$APK_OUT" \ --out "$APK_OUT" \
"$STAGING/app-aligned.apk" "$STAGING/app-aligned.apk"
echo ">>> verify" echo ">>> verify"
"$BT/apksigner" verify --verbose "$APK_OUT" "$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
# not) is what tripped Obtainium. Fail the build if any slipped through.
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
exit 1
fi
echo ">>> done: $APK_OUT" echo ">>> done: $APK_OUT"
+36 -22
View File
@@ -63,6 +63,36 @@ pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
// foundation piles bleeding through when a 2 sits on an Ace. // foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025; pub const STACK_FAN_FRAC: f32 = 0.025;
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
///
/// Derived from the actual tableau column spacing (`Tableau2.x Tableau1.x`)
/// rather than a fixed fraction of card width, so the fan scales with the
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
/// land on the card beneath it.
pub fn waste_fan_step(layout: &Layout) -> f32 {
tableau_col_step(layout) * 0.224
}
/// Horizontal distance between adjacent tableau columns (`Tableau2.x
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
pub fn tableau_col_step(layout: &Layout) -> f32 {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
}
/// Font size as a fraction of card width. /// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28; const FONT_SIZE_FRAC: f32 = 0.28;
@@ -908,34 +938,18 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
(KlondikePile::Tableau(Tableau::Tableau7), false), (KlondikePile::Tableau(Tableau::Tableau7), false),
]; ];
// Compute the Draw-Three waste fan step proportional to the column spacing // Draw-Three waste fan step, proportional to the column spacing so it scales
// (waste_x stock_x = card_width + h_gap) rather than a fixed fraction of // with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and // `waste_fan_step` so the two never drift (a drift puts the top fanned card's
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android // click target on the card beneath it).
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping let waste_fan_step = waste_fan_step(layout);
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let tableau_col_step = {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
};
let waste_fan_step = tableau_col_step * 0.224;
for (pile_type, is_stock_area) in piles { for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else { let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue; continue;
}; };
if matches!(pile_type, KlondikePile::Stock) && is_stock_area { if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step; base.x -= tableau_col_step(layout);
} }
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_)); let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area; let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
+92 -5
View File
@@ -35,7 +35,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve}; use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{ use crate::card_plugin::{
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
}; };
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
@@ -1175,12 +1175,15 @@ fn card_position(
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match // In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers. // so clicking the visually rightmost (top) card actually registers — a
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
// on Android (tighter column spacing), shifting the top card's hit target
// onto the card beneath it.
let pile_len = game.waste_cards().len(); let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = stack_index.saturating_sub(visible_start) as f32; let slot = stack_index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
} else { } else {
base base
} }
@@ -1829,7 +1832,7 @@ const _VEC3_REFERENCED: Option<Vec3> = None;
mod tests { mod tests {
use super::*; use super::*;
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{Foundation, Tableau}; use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
fn clear_test_piles(game: &mut GameState) { fn clear_test_piles(game: &mut GameState) {
@@ -1910,6 +1913,90 @@ mod tests {
assert_eq!(result.2.len(), 1); assert_eq!(result.2.len(), 1);
} }
#[test]
fn find_draggable_picks_waste_top_with_multiple_cards() {
// Reproduces the reported "drags the wrong waste card" bug: with several
// cards in the waste, clicking the visible top must pick the actual top
// (last index), not the buffer card underneath it.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
}
#[test]
fn find_draggable_picks_lone_waste_card() {
// "can't play the first card in the stock" — a waste of one card must
// still be draggable.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
game.set_test_waste_cards(vec![card.clone()]);
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
assert_eq!(result.0, KlondikePile::Stock);
assert_eq!(result.1, 0);
assert_eq!(result.2, vec![card]);
}
#[test]
fn draw_three_waste_hit_test_matches_render_fan_step() {
// Regression: the Draw-Three waste hit-test must use the same fan step as
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
// 1.25*cw); under tighter Android-style spacing the two drift and the top
// fanned card's click target lands on the card beneath it — so dragging
// the visible top card plays the wrong one.
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Force tight (Android-like) column spacing: ~1.03 * card_width.
let cw = layout.card_size.x;
let base = layout.pile_positions[&KlondikePile::Stock];
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
layout.pile_positions.insert(
KlondikePile::Tableau(Tableau::Tableau2),
Vec2::new(t1.x + cw * 1.03, t1.y),
);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
];
game.set_test_waste_cards(waste.clone());
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
let top_index = waste.len() - 1;
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let expected = base.x + 2.0 * waste_fan_step(&layout);
assert!(
(pos.x - expected).abs() < 1e-3,
"hit-test must use the shared waste fan step"
);
// The old fixed constant would have drifted from the renderer here.
let old = base.x + 2.0 * cw * 0.28;
assert!(
(pos.x - old).abs() > 1.0,
"shared step must differ from the old fixed step under tight spacing"
);
}
#[test] #[test]
fn find_draggable_skips_face_down_cards() { fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawStockConfig::DrawOne); let game = GameState::new(42, DrawStockConfig::DrawOne);
Binary file not shown.