Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 456b4d42e3 | |||
| e1c8ae0743 | |||
| 8f86d66ffe | |||
| 87aec5bdf2 | |||
| 6f5cebdb02 | |||
| 9c96e2fade | |||
| eb6c93fb55 | |||
| 4aafc0a53d | |||
| c8878d6e8b | |||
| 2e52f544f1 | |||
| 2301cc65d3 | |||
| 0ecc1a92fd | |||
| 132fea911c | |||
| 18d7937b51 | |||
| fa84152429 | |||
| ffed6b27e9 | |||
| 7fc98f8801 | |||
| a4dfb0c6db | |||
| 67271266e1 | |||
| aa7b0f6eed | |||
| 69c6e88188 | |||
| 1eb40433a9 | |||
| f8f1f26d64 | |||
| 3bb3ddb6f8 | |||
| d3d8094ebb | |||
| 04e99a8d24 | |||
| 980312c22c | |||
| 9623bdeede | |||
| 4df13695fc |
@@ -6,6 +6,20 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.32.0] — 2026-05-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
||||||
|
- **Latest tag:** `v0.35.1`
|
||||||
|
- **Working tree:** clean
|
||||||
|
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||||
|
- **Tests:** 1277 passing / 0 failing across the workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What shipped since the last handoff (v0.23.0 → v0.35.1)
|
||||||
|
|
||||||
|
### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests |
|
||||||
|
| `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets |
|
||||||
|
| `04e99a8` | Correct Android waste fan overlap and resume layout desync |
|
||||||
|
| `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn |
|
||||||
|
| `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards |
|
||||||
|
| `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge |
|
||||||
|
| `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at |
|
||||||
|
| `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) |
|
||||||
|
| `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns |
|
||||||
|
| `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) |
|
||||||
|
| `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) |
|
||||||
|
| `ffed6b2` | Share Tokio runtime across all network tasks (M-16) |
|
||||||
|
| `fa84152` | Correct Android help hint label `→` to `!` (M-17) |
|
||||||
|
| `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) |
|
||||||
|
| `132fea9` | Use saturating_add for move_count increments (M-19) |
|
||||||
|
| `0ecc1a9` | Add missing derives to AchievementContext (M-20) |
|
||||||
|
| `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) |
|
||||||
|
| `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) |
|
||||||
|
| `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) |
|
||||||
|
| `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) |
|
||||||
|
|
||||||
|
### v0.35.0 — Accessibility + sync reliability (2026-05-18)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `eb6c93f` | Silence B0004 by adding Transform to ModalScrim |
|
||||||
|
| `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) |
|
||||||
|
| `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` |
|
||||||
|
|
||||||
|
`reduce_motion_mode` now gates: score pulse, score floater, streak flourish
|
||||||
|
(hud_plugin), card-shake on rejected move, foundation completion flourish
|
||||||
|
(feedback_anim_plugin). Pattern: gate at the trigger/start system, never at
|
||||||
|
the tick system — if the component isn't inserted, the tick path never runs.
|
||||||
|
|
||||||
|
### v0.35.1 — Leaderboard bug fixes (2026-05-18)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |
|
||||||
|
|
||||||
|
Three bugs fixed:
|
||||||
|
|
||||||
|
1. **Wrong toast type on error** — `poll_opt_in_task` / `poll_opt_out_task` error
|
||||||
|
branches now fire `WarningToastEvent` instead of `InfoToastEvent`.
|
||||||
|
|
||||||
|
2. **Display name not pushed to server on change** — `Settings` gains
|
||||||
|
`leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when
|
||||||
|
opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm`
|
||||||
|
now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert
|
||||||
|
endpoint updates only `display_name` without re-opting-in.
|
||||||
|
|
||||||
|
3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker
|
||||||
|
component added to the label node. `update_leaderboard_public_name_label` system
|
||||||
|
rewrites the text each frame the panel is open; O(0) cost when panel is closed.
|
||||||
|
|
||||||
|
5 new regression tests cover all three bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open punch list
|
||||||
|
|
||||||
|
### 1. CHANGELOG documentation debt
|
||||||
|
|
||||||
|
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
||||||
|
are missing. Low priority (git log is authoritative) but worth closing before the
|
||||||
|
next release.
|
||||||
|
|
||||||
|
### 2. Android APK launch verification (Option A)
|
||||||
|
|
||||||
|
Physical device test: install the latest APK on a real Android device (not AVD),
|
||||||
|
confirm:
|
||||||
|
- App launches without crash
|
||||||
|
- Safe area insets arrive and shift HUD correctly after ~3 frames
|
||||||
|
- All modal Done buttons are above the gesture bar
|
||||||
|
- Drag-and-drop works on all pile types
|
||||||
|
- Leaderboard panel opens and the "Public name" label updates correctly after
|
||||||
|
using "Set Name"
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 3. Matomo analytics wiring
|
||||||
|
|
||||||
|
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
|
||||||
|
engine code consumes them — the analytics toggle in Settings is a no-op. If
|
||||||
|
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
|
||||||
|
and wired to `GameStateResource` events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural notes for next session
|
||||||
|
|
||||||
|
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
||||||
|
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
||||||
|
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
||||||
|
`feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern.
|
||||||
|
|
||||||
|
- **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent —
|
||||||
|
calling it when already opted in just updates `display_name`. Safe to call from
|
||||||
|
`handle_display_name_confirm` without tracking a separate "needs update" flag.
|
||||||
|
|
||||||
|
- **`Messages<T>` API (Bevy 0.18.1):** write with
|
||||||
|
`resource_mut::<Messages<T>>().write(value)`; read in tests with
|
||||||
|
`msgs.get_cursor()` + `cursor.read(msgs).next()`.
|
||||||
|
|
||||||
|
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||||
|
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||||
|
with `input.release(key); input.clear()` between updates.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -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: 858012d9
|
newTag: eb6c93fb
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ fn main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cfg = SolverConfig { move_budget, state_budget };
|
let cfg = SolverConfig { move_budget, state_budget };
|
||||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
match try_solve(seed, draw_mode, &cfg) {
|
||||||
SolverResult::Winnable => {
|
SolverResult::Winnable => {
|
||||||
buckets[i].push(seed);
|
buckets[i].push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ fn main() {
|
|||||||
while found.len() < count {
|
while found.len() < count {
|
||||||
tried += 1;
|
tried += 1;
|
||||||
if matches!(
|
if matches!(
|
||||||
try_solve(seed, draw_mode.clone(), &cfg),
|
try_solve(seed, draw_mode, &cfg),
|
||||||
SolverResult::Winnable
|
SolverResult::Winnable
|
||||||
) {
|
) {
|
||||||
found.push(seed);
|
found.push(seed);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
|
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
|
||||||
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
|
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Fields needed by achievement conditions. Constructed by the engine from
|
/// Fields needed by achievement conditions. Constructed by the engine from
|
||||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct AchievementContext {
|
pub struct AchievementContext {
|
||||||
/// Total number of games played (after this win has been recorded).
|
/// Total number of games played (after this win has been recorded).
|
||||||
pub games_played: u32,
|
pub games_played: u32,
|
||||||
|
|||||||
+88
-33
@@ -10,6 +10,9 @@ pub enum Suit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Suit {
|
impl Suit {
|
||||||
|
/// All four suits in declaration order.
|
||||||
|
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
||||||
|
|
||||||
/// Returns `true` for red suits (Diamonds, Hearts).
|
/// Returns `true` for red suits (Diamonds, Hearts).
|
||||||
pub fn is_red(self) -> bool {
|
pub fn is_red(self) -> bool {
|
||||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||||
@@ -24,38 +27,63 @@ impl Suit {
|
|||||||
/// Card rank, Ace through King.
|
/// Card rank, Ace through King.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum Rank {
|
pub enum Rank {
|
||||||
Ace,
|
Ace = 1,
|
||||||
Two,
|
Two = 2,
|
||||||
Three,
|
Three = 3,
|
||||||
Four,
|
Four = 4,
|
||||||
Five,
|
Five = 5,
|
||||||
Six,
|
Six = 6,
|
||||||
Seven,
|
Seven = 7,
|
||||||
Eight,
|
Eight = 8,
|
||||||
Nine,
|
Nine = 9,
|
||||||
Ten,
|
Ten = 10,
|
||||||
Jack,
|
Jack = 11,
|
||||||
Queen,
|
Queen = 12,
|
||||||
King,
|
King = 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rank {
|
impl Rank {
|
||||||
|
/// All thirteen ranks in ascending order.
|
||||||
|
pub const RANKS: [Self; 13] = [
|
||||||
|
Self::Ace, Self::Two, Self::Three, Self::Four, Self::Five,
|
||||||
|
Self::Six, Self::Seven, Self::Eight, Self::Nine, Self::Ten,
|
||||||
|
Self::Jack, Self::Queen, Self::King,
|
||||||
|
];
|
||||||
|
|
||||||
/// Numeric value: Ace = 1, King = 13.
|
/// Numeric value: Ace = 1, King = 13.
|
||||||
pub fn value(self) -> u8 {
|
pub fn value(self) -> u8 {
|
||||||
match self {
|
self as u8
|
||||||
Rank::Ace => 1,
|
}
|
||||||
Rank::Two => 2,
|
|
||||||
Rank::Three => 3,
|
const fn new(n: u8) -> Option<Self> {
|
||||||
Rank::Four => 4,
|
match n {
|
||||||
Rank::Five => 5,
|
1 => Some(Self::Ace),
|
||||||
Rank::Six => 6,
|
2 => Some(Self::Two),
|
||||||
Rank::Seven => 7,
|
3 => Some(Self::Three),
|
||||||
Rank::Eight => 8,
|
4 => Some(Self::Four),
|
||||||
Rank::Nine => 9,
|
5 => Some(Self::Five),
|
||||||
Rank::Ten => 10,
|
6 => Some(Self::Six),
|
||||||
Rank::Jack => 11,
|
7 => Some(Self::Seven),
|
||||||
Rank::Queen => 12,
|
8 => Some(Self::Eight),
|
||||||
Rank::King => 13,
|
9 => Some(Self::Nine),
|
||||||
|
10 => Some(Self::Ten),
|
||||||
|
11 => Some(Self::Jack),
|
||||||
|
12 => Some(Self::Queen),
|
||||||
|
13 => Some(Self::King),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
||||||
|
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
||||||
|
Self::new((self as u8).saturating_add(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
||||||
|
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
||||||
|
match (self as u8).checked_sub(n) {
|
||||||
|
Some(v) => Self::new(v),
|
||||||
|
None => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,16 +107,43 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_values_are_sequential() {
|
fn rank_values_are_sequential() {
|
||||||
let ranks = [
|
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
|
||||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
|
||||||
Rank::Jack, Rank::Queen, Rank::King,
|
|
||||||
];
|
|
||||||
for (i, r) in ranks.iter().enumerate() {
|
|
||||||
assert_eq!(r.value(), (i + 1) as u8);
|
assert_eq!(r.value(), (i + 1) as u8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_as_u8_matches_value() {
|
||||||
|
for r in Rank::RANKS {
|
||||||
|
assert_eq!(r as u8, r.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_checked_add_boundary() {
|
||||||
|
assert_eq!(Rank::King.checked_add(1), None);
|
||||||
|
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
||||||
|
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
||||||
|
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_checked_sub_boundary() {
|
||||||
|
assert_eq!(Rank::Ace.checked_sub(1), None);
|
||||||
|
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
||||||
|
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
||||||
|
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suit_suits_contains_all_four() {
|
||||||
|
assert_eq!(Suit::SUITS.len(), 4);
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_red_and_black_are_complementary() {
|
fn suit_red_and_black_are_complementary() {
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ mod pile_map_serde {
|
|||||||
use crate::pile::{Pile, PileType};
|
use crate::pile::{Pile, PileType};
|
||||||
|
|
||||||
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
||||||
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||||||
|
entries.sort_by_key(|(k, _)| *k);
|
||||||
entries.serialize(s)
|
entries.serialize(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ mod pile_map_serde {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DrawMode {
|
pub enum DrawMode {
|
||||||
/// Draw one card from stock per turn.
|
/// Draw one card from stock per turn.
|
||||||
DrawOne,
|
DrawOne,
|
||||||
@@ -154,6 +155,7 @@ pub struct GameState {
|
|||||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
#[serde(skip)]
|
||||||
undo_stack: VecDeque<StateSnapshot>,
|
undo_stack: VecDeque<StateSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +226,10 @@ impl GameState {
|
|||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stock_len = self.piles[&PileType::Stock].cards.len();
|
let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len();
|
||||||
|
|
||||||
if stock_len == 0 {
|
if stock_len == 0 {
|
||||||
let waste_len = self.piles[&PileType::Waste].cards.len();
|
let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len();
|
||||||
if waste_len == 0 {
|
if waste_len == 0 {
|
||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
@@ -245,7 +247,7 @@ impl GameState {
|
|||||||
stock.cards.push(card);
|
stock.cards.push(card);
|
||||||
}
|
}
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
self.move_count += 1;
|
self.move_count = self.move_count.saturating_add(1);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ impl GameState {
|
|||||||
waste.cards.push(card);
|
waste.cards.push(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.move_count += 1;
|
self.move_count = self.move_count.saturating_add(1);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +381,7 @@ impl GameState {
|
|||||||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||||||
|
|
||||||
self.score = (self.score + score_delta).max(0);
|
self.score = (self.score + score_delta).max(0);
|
||||||
self.move_count += 1;
|
self.move_count = self.move_count.saturating_add(1);
|
||||||
|
|
||||||
self.is_won = self.check_win();
|
self.is_won = self.check_win();
|
||||||
if !self.is_won {
|
if !self.is_won {
|
||||||
@@ -428,17 +430,101 @@ impl GameState {
|
|||||||
pub fn check_auto_complete(&self) -> bool {
|
pub fn check_auto_complete(&self) -> bool {
|
||||||
// Stock must be empty; waste may still have cards (they are resolved
|
// Stock must be empty; waste may still have cards (they are resolved
|
||||||
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
||||||
if !self.piles[&PileType::Stock].cards.is_empty() {
|
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
(0..7).all(|i| {
|
(0..7).all(|i| {
|
||||||
self.piles[&PileType::Tableau(i)]
|
self.piles
|
||||||
.cards
|
.get(&PileType::Tableau(i))
|
||||||
.iter()
|
.is_some_and(|p| p.cards.iter().all(|c| c.face_up))
|
||||||
.all(|c| c.face_up)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
|
||||||
|
///
|
||||||
|
/// Does not include stock draws — callers check `piles[&PileType::Stock]` directly.
|
||||||
|
/// Every returned triple is guaranteed to succeed when passed to `move_cards`.
|
||||||
|
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
|
||||||
|
if self.is_won {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut moves = Vec::new();
|
||||||
|
|
||||||
|
// Waste top card → foundation or tableau
|
||||||
|
if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
|
||||||
|
&& can_place_on_foundation(waste_top, f)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Waste, PileType::Foundation(slot), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dst in 0..7_usize {
|
||||||
|
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||||
|
&& can_place_on_tableau(waste_top, t)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Waste, PileType::Tableau(dst), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau sources
|
||||||
|
for src in 0..7_usize {
|
||||||
|
let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue };
|
||||||
|
if src_pile.cards.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count();
|
||||||
|
if run_len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for count in 1..=run_len {
|
||||||
|
let seq_start = src_pile.cards.len() - count;
|
||||||
|
if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bottom = &src_pile.cards[seq_start];
|
||||||
|
if count == 1 {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
|
||||||
|
&& can_place_on_foundation(bottom, f)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dst in 0..7_usize {
|
||||||
|
if dst == src {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||||
|
&& can_place_on_tableau(bottom, t)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Tableau(src), PileType::Tableau(dst), count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foundation top → tableau (only when house rule is enabled)
|
||||||
|
if self.take_from_foundation {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue };
|
||||||
|
let Some(top) = f.cards.last() else { continue };
|
||||||
|
for dst in 0..7_usize {
|
||||||
|
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||||
|
&& can_place_on_tableau(top, t)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moves
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the next `(from, to)` move that advances auto-complete, or
|
/// Returns the next `(from, to)` move that advances auto-complete, or
|
||||||
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
||||||
///
|
///
|
||||||
@@ -461,7 +547,8 @@ impl GameState {
|
|||||||
// Check waste top first — when stock is exhausted the waste may still
|
// Check waste top first — when stock is exhausted the waste may still
|
||||||
// contain cards that can go directly to a foundation.
|
// contain cards that can go directly to a foundation.
|
||||||
let waste = PileType::Waste;
|
let waste = PileType::Waste;
|
||||||
if let Some((card, slot)) = self.piles[&waste].cards.last()
|
if let Some((card, slot)) = self.piles.get(&waste)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
||||||
{
|
{
|
||||||
let _ = card; // borrow ends here
|
let _ = card; // borrow ends here
|
||||||
@@ -469,7 +556,8 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let tableau = PileType::Tableau(i);
|
let tableau = PileType::Tableau(i);
|
||||||
if let Some(slot) = self.piles[&tableau].cards.last()
|
if let Some(slot) = self.piles.get(&tableau)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
.and_then(|c| self.foundation_slot_for(c))
|
.and_then(|c| self.foundation_slot_for(c))
|
||||||
{
|
{
|
||||||
return Some((tableau, PileType::Foundation(slot)));
|
return Some((tableau, PileType::Foundation(slot)));
|
||||||
@@ -487,7 +575,7 @@ impl GameState {
|
|||||||
let mut candidate: Option<u8> = None;
|
let mut candidate: Option<u8> = None;
|
||||||
let mut empty_slot: Option<u8> = None;
|
let mut empty_slot: Option<u8> = None;
|
||||||
for slot in 0..4_u8 {
|
for slot in 0..4_u8 {
|
||||||
let pile = &self.piles[&PileType::Foundation(slot)];
|
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue };
|
||||||
if pile.cards.is_empty() {
|
if pile.cards.is_empty() {
|
||||||
if empty_slot.is_none() {
|
if empty_slot.is_none() {
|
||||||
empty_slot = Some(slot);
|
empty_slot = Some(slot);
|
||||||
@@ -501,7 +589,8 @@ impl GameState {
|
|||||||
if card.rank.value() == 1 { empty_slot } else { None }
|
if card.rank.value() == 1 { empty_slot } else { None }
|
||||||
});
|
});
|
||||||
target.filter(|&slot| {
|
target.filter(|&slot| {
|
||||||
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
|
self.piles.get(&PileType::Foundation(slot))
|
||||||
|
.is_some_and(|p| can_place_on_foundation(card, p))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,4 +1451,78 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, MoveError::RuleViolation(_)));
|
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- possible_instructions ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_empty_when_won() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.is_won = true;
|
||||||
|
assert!(g.possible_instructions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_includes_ace_to_foundation() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
let moves = g.possible_instructions();
|
||||||
|
assert!(
|
||||||
|
moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)),
|
||||||
|
"Ace must be moveable to empty foundation slot 0; got {moves:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_all_valid_on_fresh_game() {
|
||||||
|
// Every triple returned must actually succeed when applied to a clone of the state.
|
||||||
|
let g = new_game();
|
||||||
|
for (from, to, count) in g.possible_instructions() {
|
||||||
|
let mut clone = g.clone();
|
||||||
|
assert!(
|
||||||
|
clone.move_cards(from.clone(), to.clone(), count).is_ok(),
|
||||||
|
"instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_no_face_down_sources() {
|
||||||
|
let g = new_game();
|
||||||
|
for (from, _, count) in g.possible_instructions() {
|
||||||
|
if let PileType::Tableau(i) = from {
|
||||||
|
let pile = &g.piles[&PileType::Tableau(i)];
|
||||||
|
let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count();
|
||||||
|
assert!(
|
||||||
|
count <= run_len,
|
||||||
|
"count {count} exceeds face-up run {run_len} for Tableau({i})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_waste_top_included() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear.
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||||
|
id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true,
|
||||||
|
});
|
||||||
|
let moves = g.possible_instructions();
|
||||||
|
// King goes on any of the 7 empty tableau piles
|
||||||
|
assert!(
|
||||||
|
(0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))),
|
||||||
|
"King on waste must be moveable to an empty tableau column"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::card::{Card, Suit};
|
use crate::card::{Card, Suit};
|
||||||
|
|
||||||
/// Identifies which pile on the board a set of cards belongs to.
|
/// Identifies which pile on the board a set of cards belongs to.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub enum PileType {
|
pub enum PileType {
|
||||||
/// The face-down draw pile.
|
/// The face-down draw pile.
|
||||||
Stock,
|
Stock,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::card::Card;
|
use crate::card::{Card, Rank};
|
||||||
use crate::pile::Pile;
|
use crate::pile::Pile;
|
||||||
|
|
||||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||||
@@ -9,22 +9,24 @@ use crate::pile::Pile;
|
|||||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||||
/// - When the pile is non-empty, the next card must match the top card's
|
/// - When the pile is non-empty, the next card must match the top card's
|
||||||
/// suit and be exactly one rank higher.
|
/// suit and be exactly one rank higher.
|
||||||
|
#[must_use]
|
||||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||||
match pile.cards.last() {
|
match pile.cards.last() {
|
||||||
None => card.rank.value() == 1,
|
None => card.rank == Rank::Ace,
|
||||||
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
||||||
///
|
///
|
||||||
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
||||||
|
#[must_use]
|
||||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||||
match pile.cards.last() {
|
match pile.cards.last() {
|
||||||
None => card.rank.value() == 13,
|
None => card.rank == Rank::King,
|
||||||
Some(top) => {
|
Some(top) => {
|
||||||
top.face_up
|
top.face_up
|
||||||
&& card.rank.value() + 1 == top.rank.value()
|
&& card.rank.checked_add(1) == Some(top.rank)
|
||||||
&& card.suit.is_red() != top.suit.is_red()
|
&& card.suit.is_red() != top.suit.is_red()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,9 +38,10 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
|||||||
/// only validates the sequence's *internal* structure, which the tableau
|
/// only validates the sequence's *internal* structure, which the tableau
|
||||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||||
/// onto another column when the bottom card happens to land legally.
|
/// onto another column when the bottom card happens to land legally.
|
||||||
|
#[must_use]
|
||||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||||
cards.windows(2).all(|w| {
|
cards.windows(2).all(|w| {
|
||||||
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
|
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ impl SolverState {
|
|||||||
foundation,
|
foundation,
|
||||||
stock,
|
stock,
|
||||||
waste,
|
waste,
|
||||||
draw_mode: game.draw_mode.clone(),
|
draw_mode: game.draw_mode,
|
||||||
just_drew: false,
|
just_drew: false,
|
||||||
consecutive_draws: 0,
|
consecutive_draws: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
pub use solitaire_sync::AchievementRecord;
|
pub use solitaire_sync::AchievementRecord;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
|
||||||
const FILE_NAME: &str = "achievements.json";
|
const FILE_NAME: &str = "achievements.json";
|
||||||
|
|
||||||
/// Platform-specific default path for `achievements.json`.
|
/// Platform-specific default path for `achievements.json`.
|
||||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
||||||
|
|||||||
@@ -295,9 +295,9 @@ fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
|||||||
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||||
let path = token_file_path()
|
let path = token_file_path()
|
||||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||||
let tmp = path.with_extension("tmp");
|
let tmp = path.with_extension("bin.tmp");
|
||||||
std::fs::write(&tmp, data)
|
std::fs::write(&tmp, data)
|
||||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
|
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
|
||||||
std::fs::rename(&tmp, &path)
|
std::fs::rename(&tmp, &path)
|
||||||
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,3 +168,6 @@ pub use matomo_client::MatomoClient;
|
|||||||
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub use platform::data_dir;
|
pub use platform::data_dir;
|
||||||
|
|
||||||
|
/// Application data subdirectory name, shared by all persistence modules.
|
||||||
|
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||||
|
|||||||
@@ -111,12 +111,12 @@ impl MatomoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn url_encode(s: &str) -> String {
|
fn url_encode(s: &str) -> String {
|
||||||
s.chars()
|
s.bytes()
|
||||||
.flat_map(|c| match c {
|
.flat_map(|b| match b {
|
||||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
vec![c]
|
vec![b as char]
|
||||||
}
|
}
|
||||||
c => format!("%{:02X}", c as u32).chars().collect(),
|
b => format!("%{b:02X}").chars().collect(),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use chrono::{Datelike, NaiveDate};
|
|||||||
pub use solitaire_sync::progress::level_for_xp;
|
pub use solitaire_sync::progress::level_for_xp;
|
||||||
pub use solitaire_sync::PlayerProgress;
|
pub use solitaire_sync::PlayerProgress;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
|
||||||
const FILE_NAME: &str = "progress.json";
|
const FILE_NAME: &str = "progress.json";
|
||||||
|
|
||||||
/// Deterministic seed derived from a date, identical for all players globally.
|
/// Deterministic seed derived from a date, identical for all players globally.
|
||||||
@@ -46,7 +45,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
|||||||
|
|
||||||
/// Platform-specific default path for `progress.json`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
pub fn progress_file_path() -> Option<PathBuf> {
|
pub fn progress_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
|
|
||||||
@@ -279,14 +278,14 @@ impl ReplayHistory {
|
|||||||
in migrate_legacy_latest_replay"
|
in migrate_legacy_latest_replay"
|
||||||
)]
|
)]
|
||||||
pub fn latest_replay_path() -> Option<PathBuf> {
|
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `replays.json`, the rolling
|
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||||
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
|
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
|
||||||
/// minimal Linux containers).
|
/// minimal Linux containers).
|
||||||
pub fn replay_history_path() -> Option<PathBuf> {
|
pub fn replay_history_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use std::path::{Path, PathBuf};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
/// Animation playback speed for card transitions.
|
/// Animation playback speed for card transitions.
|
||||||
@@ -239,6 +238,12 @@ pub struct Settings {
|
|||||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub leaderboard_display_name: Option<String>,
|
pub leaderboard_display_name: Option<String>,
|
||||||
|
/// `true` once the player has successfully opted in to the leaderboard on
|
||||||
|
/// the server. Used to decide whether a display-name change should also
|
||||||
|
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
|
||||||
|
/// deserialize cleanly to `false` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub leaderboard_opted_in: bool,
|
||||||
/// When `true`, the player may drag the top card of a foundation pile back
|
/// When `true`, the player may drag the top card of a foundation pile back
|
||||||
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
||||||
/// rules). Older `settings.json` files without this key deserialize to
|
/// rules). Older `settings.json` files without this key deserialize to
|
||||||
@@ -388,6 +393,7 @@ impl Default for Settings {
|
|||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
analytics_enabled: false,
|
analytics_enabled: false,
|
||||||
matomo_url: None,
|
matomo_url: None,
|
||||||
@@ -479,7 +485,7 @@ impl Settings {
|
|||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
/// the platform's data directory is unavailable.
|
/// the platform's data directory is unavailable.
|
||||||
pub fn settings_file_path() -> Option<PathBuf> {
|
pub fn settings_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
|||||||
|
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::StatsSnapshot;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
|
||||||
const STATS_FILE_NAME: &str = "stats.json";
|
const STATS_FILE_NAME: &str = "stats.json";
|
||||||
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||||
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||||
@@ -21,7 +20,7 @@ const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
|||||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||||
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||||
pub fn stats_file_path() -> Option<PathBuf> {
|
pub fn stats_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
||||||
@@ -71,7 +70,7 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
|||||||
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
||||||
/// `crate::data_dir()` is unavailable.
|
/// `crate::data_dir()` is unavailable.
|
||||||
pub fn game_state_file_path() -> Option<PathBuf> {
|
pub fn game_state_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||||
@@ -123,14 +122,14 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove any leftover `*.json.tmp` files in the app data directory.
|
/// Remove any leftover `*.tmp` files in the app data directory.
|
||||||
///
|
///
|
||||||
/// These can be left behind if the process crashes between the write and rename
|
/// These can be left behind if the process crashes between the write and rename
|
||||||
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
||||||
/// are silently skipped.
|
/// are silently skipped.
|
||||||
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||||
let dir = match crate::data_dir() {
|
let dir = match crate::data_dir() {
|
||||||
Some(d) => d.join(APP_DIR_NAME),
|
Some(d) => d.join(crate::APP_DIR_NAME),
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +180,7 @@ pub struct TimeAttackSession {
|
|||||||
/// Returns the platform-specific path to `time_attack_session.json`, or
|
/// Returns the platform-specific path to `time_attack_session.json`, or
|
||||||
/// `None` if `crate::data_dir()` is unavailable.
|
/// `None` if `crate::data_dir()` is unavailable.
|
||||||
pub fn time_attack_session_path() -> Option<PathBuf> {
|
pub fn time_attack_session_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
||||||
@@ -267,7 +266,7 @@ pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||||
///
|
///
|
||||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||||
fn cleanup_tmp_files_in(dir: &Path) {
|
fn cleanup_tmp_files_in(dir: &Path) {
|
||||||
@@ -277,7 +276,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
|||||||
if path
|
if path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.is_some_and(|n| n.ends_with(".json.tmp"))
|
.is_some_and(|n| n.ends_with(".tmp"))
|
||||||
{
|
{
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
||||||
let token = self.access_token()?;
|
let token = self.access_token()?;
|
||||||
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
||||||
|
// Enforce the server's 32-char column limit at the client boundary so
|
||||||
|
// the server never receives an over-length name regardless of caller.
|
||||||
|
let display_name: String = display_name.chars().take(32).collect();
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use solitaire_core::game_state::GameMode;
|
|||||||
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
|
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
|
||||||
|
|
||||||
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
|
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::{GameStateResource, TokioRuntimeResource};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -45,6 +45,7 @@ pub struct AnalyticsPlugin;
|
|||||||
impl Plugin for AnalyticsPlugin {
|
impl Plugin for AnalyticsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AnalyticsResource>()
|
app.init_resource::<AnalyticsResource>()
|
||||||
|
.init_resource::<TokioRuntimeResource>()
|
||||||
.add_systems(Startup, init_analytics)
|
.add_systems(Startup, init_analytics)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -80,28 +81,28 @@ fn react_to_settings_change(
|
|||||||
fn on_game_won(
|
fn on_game_won(
|
||||||
mut wins: MessageReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
analytics: Res<AnalyticsResource>,
|
analytics: Res<AnalyticsResource>,
|
||||||
settings: Res<SettingsResource>,
|
rt: Res<TokioRuntimeResource>,
|
||||||
) {
|
) {
|
||||||
let Some(client) = analytics.client.clone() else {
|
let Some(client) = analytics.client.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
client.event("Game", "Won", None, Some(ev.score as f64));
|
client.event("Game", "Won", None, Some(ev.score as f64));
|
||||||
fire_flush(client.clone(), &settings.0);
|
fire_flush(client.clone(), rt.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_forfeit(
|
fn on_forfeit(
|
||||||
mut forfeits: MessageReader<ForfeitEvent>,
|
mut forfeits: MessageReader<ForfeitEvent>,
|
||||||
analytics: Res<AnalyticsResource>,
|
analytics: Res<AnalyticsResource>,
|
||||||
settings: Res<SettingsResource>,
|
rt: Res<TokioRuntimeResource>,
|
||||||
) {
|
) {
|
||||||
let Some(client) = analytics.client.clone() else {
|
let Some(client) = analytics.client.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for _ev in forfeits.read() {
|
for _ev in forfeits.read() {
|
||||||
client.event("Game", "Forfeit", None, None);
|
client.event("Game", "Forfeit", None, None);
|
||||||
fire_flush(client.clone(), &settings.0);
|
fire_flush(client.clone(), rt.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,14 +138,14 @@ fn on_achievement_unlocked(
|
|||||||
fn tick_flush_timer(
|
fn tick_flush_timer(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut analytics: ResMut<AnalyticsResource>,
|
mut analytics: ResMut<AnalyticsResource>,
|
||||||
settings: Res<SettingsResource>,
|
rt: Res<TokioRuntimeResource>,
|
||||||
) {
|
) {
|
||||||
analytics.flush_timer.tick(time.delta());
|
analytics.flush_timer.tick(time.delta());
|
||||||
if !analytics.flush_timer.just_finished() {
|
if !analytics.flush_timer.just_finished() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(client) = analytics.client.clone() {
|
if let Some(client) = analytics.client.clone() {
|
||||||
fire_flush(client, &settings.0);
|
fire_flush(client, rt.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,15 +165,10 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
|
|||||||
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
|
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
|
fn fire_flush(client: Arc<MatomoClient>, rt: Arc<tokio::runtime::Runtime>) {
|
||||||
AsyncComputeTaskPool::get()
|
AsyncComputeTaskPool::get()
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
rt.block_on(client.flush());
|
rt.block_on(client.flush());
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,8 +454,8 @@ fn handle_settings_toast(
|
|||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
let sfx = ev.0.sfx_volume;
|
let sfx = ev.0.sfx_volume;
|
||||||
let music = ev.0.music_volume;
|
let music = ev.0.music_volume;
|
||||||
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON);
|
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
|
||||||
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON);
|
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
|
||||||
*last_sfx = Some(sfx);
|
*last_sfx = Some(sfx);
|
||||||
*last_music = Some(music);
|
*last_music = Some(music);
|
||||||
if sfx_changed {
|
if sfx_changed {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use bevy::asset::RenderAssetUsages;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
|
||||||
|
use crate::resources::TokioRuntimeResource;
|
||||||
|
|
||||||
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
|
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
|
||||||
/// has been fetched yet (new account, no internet, or fetch in progress).
|
/// has been fetched yet (new account, no internet, or fetch in progress).
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
@@ -46,6 +48,7 @@ pub struct AvatarPlugin;
|
|||||||
impl Plugin for AvatarPlugin {
|
impl Plugin for AvatarPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_message::<AvatarFetchEvent>()
|
app.add_message::<AvatarFetchEvent>()
|
||||||
|
.init_resource::<TokioRuntimeResource>()
|
||||||
.init_resource::<AvatarResource>()
|
.init_resource::<AvatarResource>()
|
||||||
.init_resource::<PendingAvatarTask>()
|
.init_resource::<PendingAvatarTask>()
|
||||||
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
|
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
|
||||||
@@ -54,17 +57,15 @@ impl Plugin for AvatarPlugin {
|
|||||||
|
|
||||||
fn handle_avatar_fetch(
|
fn handle_avatar_fetch(
|
||||||
mut events: MessageReader<AvatarFetchEvent>,
|
mut events: MessageReader<AvatarFetchEvent>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut pending: ResMut<PendingAvatarTask>,
|
mut pending: ResMut<PendingAvatarTask>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
// Cancel any in-flight task and restart with the new URL.
|
// Cancel any in-flight task and restart with the new URL.
|
||||||
let url = ev.url.clone();
|
let url = ev.url.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(async move {
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.ok()?
|
|
||||||
.block_on(async move {
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let bytes = client
|
let bytes = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
|
|||||||
@@ -451,7 +451,9 @@ impl Plugin for CardPlugin {
|
|||||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||||
clear_right_click_highlights_on_pause,
|
clear_right_click_highlights_on_pause,
|
||||||
update_stock_empty_indicator.after(GameMutation),
|
update_stock_empty_indicator.after(GameMutation),
|
||||||
update_stock_count_badge.after(GameMutation),
|
update_stock_count_badge
|
||||||
|
.after(GameMutation)
|
||||||
|
.run_if(resource_changed::<crate::GameStateResource>),
|
||||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||||
snap_cards_on_window_resize.after(collect_resize_events),
|
snap_cards_on_window_resize.after(collect_resize_events),
|
||||||
),
|
),
|
||||||
@@ -462,6 +464,49 @@ impl Plugin for CardPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the relative asset path for a card face PNG.
|
||||||
|
///
|
||||||
|
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
|
||||||
|
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
|
||||||
|
/// this function so the filename formula is tested in isolation from the
|
||||||
|
/// asset-loading machinery.
|
||||||
|
///
|
||||||
|
/// Note: this function verifies only the **code-side mapping**. If the PNG
|
||||||
|
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
|
||||||
|
/// diamond watermark baked in), that is an **asset content bug** and must be
|
||||||
|
/// fixed by replacing the file — no code change can correct it.
|
||||||
|
fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
|
||||||
|
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||||
|
const RANK_STRS: [&str; 13] = [
|
||||||
|
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||||
|
];
|
||||||
|
let suit_idx = match suit {
|
||||||
|
Suit::Clubs => 0,
|
||||||
|
Suit::Diamonds => 1,
|
||||||
|
Suit::Hearts => 2,
|
||||||
|
Suit::Spades => 3,
|
||||||
|
};
|
||||||
|
let rank_idx = match rank {
|
||||||
|
Rank::Ace => 0,
|
||||||
|
Rank::Two => 1,
|
||||||
|
Rank::Three => 2,
|
||||||
|
Rank::Four => 3,
|
||||||
|
Rank::Five => 4,
|
||||||
|
Rank::Six => 5,
|
||||||
|
Rank::Seven => 6,
|
||||||
|
Rank::Eight => 7,
|
||||||
|
Rank::Nine => 8,
|
||||||
|
Rank::Ten => 9,
|
||||||
|
Rank::Jack => 10,
|
||||||
|
Rank::Queen => 11,
|
||||||
|
Rank::King => 12,
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"cards/faces/classic/{}{}.png",
|
||||||
|
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
||||||
/// [`CardImageSet`].
|
/// [`CardImageSet`].
|
||||||
///
|
///
|
||||||
@@ -476,17 +521,15 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suit index: Clubs=0, Diamonds=1, Hearts=2, Spades=3
|
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
const RANKS: [Rank; 13] = [
|
||||||
// Rank index: Ace=0 … King=12
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven,
|
||||||
const RANK_STRS: [&str; 13] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||||
|
];
|
||||||
|
|
||||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||||
std::array::from_fn(|rank| {
|
std::array::from_fn(|ri| {
|
||||||
asset_server.load(format!(
|
asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))
|
||||||
"cards/faces/classic/{}{}.png",
|
|
||||||
RANK_STRS[rank], SUIT_CHARS[suit]
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let backs = std::array::from_fn(|i| {
|
let backs = std::array::from_fn(|i| {
|
||||||
@@ -584,6 +627,7 @@ fn resync_cards_on_settings_change(
|
|||||||
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||||
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||||
/// have already completed.
|
/// have already completed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn sync_cards_startup(
|
fn sync_cards_startup(
|
||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -592,6 +636,7 @@ fn sync_cards_startup(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
@@ -599,7 +644,8 @@ fn sync_cards_startup(
|
|||||||
let back_colour = card_back_colour(selected_back);
|
let back_colour = card_back_colour(selected_back);
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +659,7 @@ fn sync_cards_on_change(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -623,7 +670,8 @@ fn sync_cards_on_change(
|
|||||||
let back_colour = card_back_colour(selected_back);
|
let back_colour = card_back_colour(selected_back);
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,9 +687,26 @@ fn sync_cards(
|
|||||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
|
// The waste buffer card exists only to keep its entity alive while the new
|
||||||
|
// top card's slide animation plays — it must never be visible to the player.
|
||||||
|
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||||
|
// and its rank/suit peek behind the incoming card.
|
||||||
|
let waste_buffer_id: Option<u32> = {
|
||||||
|
let visible = match game.draw_mode {
|
||||||
|
DrawMode::DrawOne => 1_usize,
|
||||||
|
DrawMode::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Waste)
|
||||||
|
.filter(|w| w.cards.len() > visible)
|
||||||
|
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
|
||||||
|
.map(|c| c.id)
|
||||||
|
};
|
||||||
|
|
||||||
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||||
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||||
// skip the snap/slide path on cards that are already being driven by a
|
// skip the snap/slide path on cards that are already being driven by a
|
||||||
@@ -662,17 +727,26 @@ fn sync_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each card in the current state: spawn or update its entity.
|
// For each card in the current state: spawn or update its entity, then
|
||||||
|
// apply visibility. The waste buffer card is hidden so it cannot peek
|
||||||
|
// behind the incoming top card during the draw slide animation.
|
||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
let entity = match existing.get(&card.id) {
|
||||||
Some(&(entity, cur, has_anim)) => {
|
Some(&(entity, cur, has_anim)) => {
|
||||||
update_card_entity(
|
update_card_entity(
|
||||||
&mut commands, entity, card, position, z, layout,
|
&mut commands, entity, card, position, z, layout,
|
||||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back,
|
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
|
||||||
)
|
);
|
||||||
}
|
entity
|
||||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back),
|
|
||||||
}
|
}
|
||||||
|
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
|
||||||
|
};
|
||||||
|
let visibility = if waste_buffer_id == Some(card.id) {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
commands.entity(entity).insert(visibility);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,6 +769,19 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
|||||||
PileType::Tableau(6),
|
PileType::Tableau(6),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Compute the Draw-Three waste fan step proportional to the column spacing
|
||||||
|
// (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of
|
||||||
|
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
|
||||||
|
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
|
||||||
|
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
||||||
|
// the top fanned card's centre within the waste column's own horizontal
|
||||||
|
// footprint instead of spilling into the adjacent gap.
|
||||||
|
let waste_fan_step = {
|
||||||
|
let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default();
|
||||||
|
let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default();
|
||||||
|
(w.x - s.x).abs() * 0.224
|
||||||
|
};
|
||||||
|
|
||||||
for pile_type in piles {
|
for pile_type in piles {
|
||||||
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -736,7 +823,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
|||||||
// normally — no card is hidden, so the shift is 0.
|
// normally — no card is hidden, so the shift is 0.
|
||||||
let visible = 3_usize;
|
let visible = 3_usize;
|
||||||
let hidden = rendered_len.saturating_sub(visible);
|
let hidden = rendered_len.saturating_sub(visible);
|
||||||
slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28
|
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
@@ -768,7 +855,8 @@ fn spawn_card_entity(
|
|||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) -> Entity {
|
||||||
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
||||||
|
|
||||||
let mut entity = commands.spawn((
|
let mut entity = commands.spawn((
|
||||||
@@ -777,6 +865,7 @@ fn spawn_card_entity(
|
|||||||
Transform::from_xyz(pos.x, pos.y, z),
|
Transform::from_xyz(pos.x, pos.y, z),
|
||||||
Visibility::default(),
|
Visibility::default(),
|
||||||
));
|
));
|
||||||
|
let entity_id = entity.id();
|
||||||
// Every card gets a subtle drop-shadow child so the play surface reads
|
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||||
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||||
// system retunes its offset / alpha when this card joins the dragged
|
// system retunes its offset / alpha when this card joins the dragged
|
||||||
@@ -811,9 +900,13 @@ fn spawn_card_entity(
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
if card_images.is_some() {
|
if card_images.is_some() {
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast);
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Suppress unused-variable warning when not building for Android.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let _ = font_handle;
|
||||||
|
entity_id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -832,6 +925,7 @@ fn update_card_entity(
|
|||||||
has_card_animation: bool,
|
has_card_animation: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
) {
|
) {
|
||||||
let target = Vec3::new(pos.x, pos.y, z);
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|
||||||
@@ -894,9 +988,12 @@ fn update_card_entity(
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
if card_images.is_some() {
|
if card_images.is_some() {
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast);
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Suppress unused-variable warning when not building for Android.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let _ = font_handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label_for(card: &Card) -> String {
|
fn label_for(card: &Card) -> String {
|
||||||
@@ -1000,6 +1097,13 @@ fn mobile_label_for(card: &Card) -> String {
|
|||||||
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
/// face-up cards. The background sprite covers the card art's own small
|
/// face-up cards. The background sprite covers the card art's own small
|
||||||
/// corner text so only the large overlay is visible.
|
/// corner text so only the large overlay is visible.
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||||
|
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||||
|
/// font handle Bevy falls back to its built-in face which does not include
|
||||||
|
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||||
|
/// the text colour — the root cause of the "red square on face-down cards"
|
||||||
|
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn add_android_corner_label(
|
fn add_android_corner_label(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
@@ -1007,6 +1111,7 @@ fn add_android_corner_label(
|
|||||||
card_size: Vec2,
|
card_size: Vec2,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
) {
|
) {
|
||||||
if !card.face_up {
|
if !card.face_up {
|
||||||
return;
|
return;
|
||||||
@@ -1034,12 +1139,18 @@ fn add_android_corner_label(
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Large rank+suit text drawn on top of the background.
|
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||||
|
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||||
|
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
AndroidCornerLabel,
|
AndroidCornerLabel,
|
||||||
CardLabel,
|
CardLabel,
|
||||||
Text2d::new(mobile_label_for(card)),
|
Text2d::new(mobile_label_for(card)),
|
||||||
TextFont { font_size, ..default() },
|
TextFont {
|
||||||
|
font: font_handle.cloned().unwrap_or_default(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
Anchor::TOP_LEFT,
|
Anchor::TOP_LEFT,
|
||||||
Transform::from_xyz(
|
Transform::from_xyz(
|
||||||
@@ -1516,6 +1627,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
|||||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||||
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
font: Handle<Font>,
|
||||||
) {
|
) {
|
||||||
let stock_empty = game
|
let stock_empty = game
|
||||||
.piles
|
.piles
|
||||||
@@ -1541,7 +1653,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
|||||||
b.spawn((
|
b.spawn((
|
||||||
StockEmptyLabel,
|
StockEmptyLabel,
|
||||||
Text2d::new("↺"),
|
Text2d::new("↺"),
|
||||||
TextFont { font_size, ..default() },
|
TextFont { font: font.clone(), font_size, ..default() },
|
||||||
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
));
|
));
|
||||||
@@ -1567,16 +1679,19 @@ fn update_stock_empty_indicator_startup(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
) {
|
) {
|
||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&game.0,
|
&game.0,
|
||||||
&mut pile_markers,
|
&mut pile_markers,
|
||||||
&label_children,
|
&label_children,
|
||||||
&layout.0,
|
&layout.0,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1587,6 +1702,7 @@ fn update_stock_empty_indicator(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
) {
|
) {
|
||||||
@@ -1594,12 +1710,14 @@ fn update_stock_empty_indicator(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&game.0,
|
&game.0,
|
||||||
&mut pile_markers,
|
&mut pile_markers,
|
||||||
&label_children,
|
&label_children,
|
||||||
&layout.0,
|
&layout.0,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1810,6 +1928,7 @@ fn snap_cards_on_window_resize(
|
|||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
entities: Query<
|
entities: Query<
|
||||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
||||||
@@ -1858,12 +1977,14 @@ fn snap_cards_on_window_resize(
|
|||||||
frame_query,
|
frame_query,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&game.0,
|
&game.0,
|
||||||
&mut pile_markers,
|
&mut pile_markers,
|
||||||
&label_children,
|
&label_children,
|
||||||
&layout.0,
|
&layout.0,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
|
|
||||||
throttle.last_applied_secs = now;
|
throttle.last_applied_secs = now;
|
||||||
@@ -2263,6 +2384,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The waste buffer card (slot below top) must be at the *same* XY as the
|
||||||
|
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
|
||||||
|
#[test]
|
||||||
|
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let waste_ids: std::collections::HashSet<u32> =
|
||||||
|
g.piles[&PileType::Waste].cards.iter().map(|c| c.id).collect();
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
let waste_rendered: Vec<_> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||||
|
.collect();
|
||||||
|
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
|
||||||
|
assert_eq!(waste_rendered.len(), 2, "Draw-One with 3 waste cards must render exactly 2");
|
||||||
|
// Both must share the same XY so that hiding the buffer leaves no gap.
|
||||||
|
let (_, pos0, _) = waste_rendered[0];
|
||||||
|
let (_, pos1, _) = waste_rendered[1];
|
||||||
|
assert!(
|
||||||
|
(pos0.x - pos1.x).abs() < 1e-3 && (pos0.y - pos1.y).abs() < 1e-3,
|
||||||
|
"buffer and top card must be at the same XY; got buffer={pos0:?} top={pos1:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
@@ -3167,4 +3317,230 @@ mod tests {
|
|||||||
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
||||||
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bug #1 — CardImageSet key lookup (code-side mapping)
|
||||||
|
//
|
||||||
|
// These tests verify that every (Rank, Suit) pair produces the expected
|
||||||
|
// filename via `card_face_asset_path`. They can only detect *code-side*
|
||||||
|
// mapping bugs (e.g. a suit index mismatch). They do NOT inspect pixel
|
||||||
|
// data — if `QS.png` contains a diamond watermark that is an *asset
|
||||||
|
// content* bug that requires replacing the PNG file.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_queen_of_spades_is_qs_png() {
|
||||||
|
assert_eq!(
|
||||||
|
card_face_asset_path(Rank::Queen, Suit::Spades),
|
||||||
|
"cards/faces/classic/QS.png",
|
||||||
|
"Queen of Spades must resolve to QS.png, not QD.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_queen_of_diamonds_is_qd_png() {
|
||||||
|
assert_eq!(
|
||||||
|
card_face_asset_path(Rank::Queen, Suit::Diamonds),
|
||||||
|
"cards/faces/classic/QD.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_ace_of_clubs_is_ac_png() {
|
||||||
|
assert_eq!(card_face_asset_path(Rank::Ace, Suit::Clubs), "cards/faces/classic/AC.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_ten_of_hearts_is_10h_png() {
|
||||||
|
assert_eq!(card_face_asset_path(Rank::Ten, Suit::Hearts), "cards/faces/classic/10H.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_king_of_spades_is_ks_png() {
|
||||||
|
assert_eq!(card_face_asset_path(Rank::King, Suit::Spades), "cards/faces/classic/KS.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_all_52_keys_are_unique() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
let ranks = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||||
|
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||||
|
];
|
||||||
|
let paths: HashSet<String> = suits
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&s| ranks.iter().map(move |&r| card_face_asset_path(r, s)))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(paths.len(), 52, "all 52 card face paths must be distinct");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_suits_produce_correct_suffix() {
|
||||||
|
// Each suit must map to its own letter, not a neighbour's.
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Clubs).ends_with("AC.png"));
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Diamonds).ends_with("AD.png"));
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Hearts).ends_with("AH.png"));
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Spades).ends_with("AS.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bug #3 — Suit → color mapping for the Android corner overlay
|
||||||
|
//
|
||||||
|
// Black suits (♠♣) must use BLACK_SUIT_COLOUR (near-white) so they
|
||||||
|
// contrast against the dark card face. They must NOT share the red or
|
||||||
|
// lime colours assigned to red suits.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_colour_black_suits_are_near_white_not_red() {
|
||||||
|
for suit in [Suit::Clubs, Suit::Spades] {
|
||||||
|
let card = Card { id: 0, suit, rank: Rank::Ace, face_up: true };
|
||||||
|
let colour = text_colour(&card, false, false);
|
||||||
|
assert_eq!(
|
||||||
|
colour, BLACK_SUIT_COLOUR,
|
||||||
|
"{suit:?} must map to BLACK_SUIT_COLOUR (near-white)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
colour, RED_SUIT_COLOUR,
|
||||||
|
"{suit:?} must not use the red suit colour"
|
||||||
|
);
|
||||||
|
// Confirm it's visually light (all channels > 0.85).
|
||||||
|
let srgba = colour.to_srgba();
|
||||||
|
assert!(
|
||||||
|
srgba.red > 0.85 && srgba.green > 0.85 && srgba.blue > 0.85,
|
||||||
|
"{suit:?} colour must be near-white for dark card background contrast, got {srgba:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bug #4 — Waste pile z-ordering
|
||||||
|
//
|
||||||
|
// Every rendered waste card must have a strictly greater z than the one
|
||||||
|
// below it so Bevy's CPU-side sprite sort renders them back-to-front.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_pile_cards_have_strictly_increasing_z() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut waste_zs: Vec<f32> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||||
|
.map(|(_, _, z)| *z)
|
||||||
|
.collect();
|
||||||
|
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
waste_zs.dedup();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
waste_zs.len() >= 2,
|
||||||
|
"expected multiple rendered waste cards, got {}",
|
||||||
|
waste_zs.len()
|
||||||
|
);
|
||||||
|
// All z values must be strictly ordered (no duplicates).
|
||||||
|
for w in waste_zs.windows(2) {
|
||||||
|
assert!(
|
||||||
|
w[1] > w[0],
|
||||||
|
"waste z values must be strictly increasing, got {} ≤ {}",
|
||||||
|
w[1],
|
||||||
|
w[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: on tight layouts (e.g. Android H_GAP_DIVISOR=32) the
|
||||||
|
/// Draw-Three waste fan must be proportional to column spacing so that no
|
||||||
|
/// fanned card ever bleeds left into the stock column.
|
||||||
|
///
|
||||||
|
/// The invariant holds structurally (x_offset ≥ 0), but this test pins
|
||||||
|
/// the formula so a future change that accidentally introduces negative
|
||||||
|
/// offsets or flips the fan direction is caught immediately.
|
||||||
|
#[test]
|
||||||
|
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android-portrait window. In host tests H_GAP_DIVISOR uses the
|
||||||
|
// desktop value (4), but the no-overlap invariant must hold on any
|
||||||
|
// screen size and gap ratio.
|
||||||
|
let window = Vec2::new(900.0, 2000.0);
|
||||||
|
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
|
||||||
|
|
||||||
|
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||||
|
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
for (card, pos, _) in positions.iter().filter(|(c, _, _)| waste_ids.contains(&c.id)) {
|
||||||
|
let left_edge = pos.x - layout.card_size.x / 2.0;
|
||||||
|
assert!(
|
||||||
|
left_edge >= stock_right_edge - 1e-3,
|
||||||
|
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
|
||||||
|
card.id,
|
||||||
|
left_edge,
|
||||||
|
stock_right_edge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_pile_draw_one_cards_have_distinct_z() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut waste_zs: Vec<f32> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||||
|
.map(|(_, _, z)| *z)
|
||||||
|
.collect();
|
||||||
|
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
waste_zs.dedup();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
waste_zs.len() >= 2,
|
||||||
|
"Draw-One must render at least 2 waste cards (visible + buffer)"
|
||||||
|
);
|
||||||
|
// Deduplicated length must equal pre-dedup length → all z distinct.
|
||||||
|
let raw_count = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
waste_zs.len(),
|
||||||
|
raw_count,
|
||||||
|
"all rendered waste card z values must be distinct"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use solitaire_core::game_state::{DrawMode, GameState};
|
|||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
||||||
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_cursor_icon,
|
update_cursor_icon,
|
||||||
update_drop_highlights,
|
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
||||||
update_drop_target_overlays,
|
update_drop_target_overlays,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -387,7 +387,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec
|
|||||||
if matches!(pile, PileType::Tableau(_)) {
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
if card_count > 1 {
|
if card_count > 1 {
|
||||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||||
let top_edge = centre.y + layout.card_size.y / 2.0;
|
let top_edge = centre.y + layout.card_size.y / 2.0;
|
||||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||||
@@ -478,7 +478,7 @@ fn tableau_or_stack_pos(
|
|||||||
if is_tableau {
|
if is_tableau {
|
||||||
Vec2::new(
|
Vec2::new(
|
||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
|
|||||||
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
fn start_shake_anim(
|
fn start_shake_anim(
|
||||||
mut events: MessageReader<MoveRejectedEvent>,
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let dest_pile = &ev.to;
|
let dest_pile = &ev.to;
|
||||||
// Collect the card ids that belong to the destination pile.
|
// Collect the card ids that belong to the destination pile.
|
||||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
||||||
@@ -489,11 +494,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
|||||||
fn start_foundation_flourish(
|
fn start_foundation_flourish(
|
||||||
mut events: MessageReader<FoundationCompletedEvent>,
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let pile_type = PileType::Foundation(ev.slot);
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
// Top card of the completed foundation is the King.
|
// Top card of the completed foundation is the King.
|
||||||
let Some(king_id) = game
|
let Some(king_id) = game
|
||||||
@@ -785,7 +795,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
// 52 cards should produce more than a couple distinct jitter factors;
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
// a constant function would return one value for all ids.
|
// a constant function would return one function for all ids.
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
let unique: HashSet<u64> = (0u32..52)
|
let unique: HashSet<u64> = (0u32..52)
|
||||||
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||||
@@ -796,4 +806,96 @@ mod tests {
|
|||||||
unique.len()
|
unique.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ShakeAnim, FoundationFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
|
||||||
|
/// is on, even when the event targets a pile that has card entities present.
|
||||||
|
#[test]
|
||||||
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||||
|
let dest_pile = PileType::Tableau(0);
|
||||||
|
let card_id = app
|
||||||
|
.world()
|
||||||
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get(&dest_pile)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
|
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||||
|
|
||||||
|
// Spawn a minimal CardEntity matching that id so the system would
|
||||||
|
// find it and insert ShakeAnim if the gate were absent.
|
||||||
|
app.world_mut().spawn((
|
||||||
|
CardEntity { card_id },
|
||||||
|
Transform::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||||
|
.write(MoveRejectedEvent {
|
||||||
|
from: PileType::Stock,
|
||||||
|
to: dest_pile,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let shake_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ShakeAnim>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
|
||||||
|
/// `reduce_motion_mode` is on.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||||
|
.write(FoundationCompletedEvent {
|
||||||
|
slot: 0,
|
||||||
|
suit: solitaire_core::card::Suit::Spades,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let flourish_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&FoundationFlourish>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,11 +380,11 @@ fn poll_pending_new_game_seed(
|
|||||||
|
|
||||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||||
/// engine tests in the same file exercise this path.
|
/// engine tests in the same file exercise this path.
|
||||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
|
||||||
let cfg = SolverConfig::default();
|
let cfg = SolverConfig::default();
|
||||||
let mut seed = initial_seed;
|
let mut seed = initial_seed;
|
||||||
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
||||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
match try_solve(seed, draw_mode, &cfg) {
|
||||||
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
||||||
SolverResult::Unwinnable => {
|
SolverResult::Unwinnable => {
|
||||||
seed = seed.wrapping_add(1);
|
seed = seed.wrapping_add(1);
|
||||||
@@ -451,7 +451,7 @@ fn handle_new_game(
|
|||||||
// where SettingsPlugin is not installed.
|
// where SettingsPlugin is not installed.
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
|
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
|
||||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||||
|
|
||||||
// Solver-backed retry: when the player has opted in to
|
// Solver-backed retry: when the player has opted in to
|
||||||
@@ -473,9 +473,8 @@ fn handle_new_game(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|s| s.0.winnable_deals_only);
|
.is_some_and(|s| s.0.winnable_deals_only);
|
||||||
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
||||||
let dm = draw_mode.clone();
|
|
||||||
let task = AsyncComputeTaskPool::get()
|
let task = AsyncComputeTaskPool::get()
|
||||||
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
|
.spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
|
||||||
pending_seed.inner = Some(PendingSeedTask {
|
pending_seed.inner = Some(PendingSeedTask {
|
||||||
handle: task,
|
handle: task,
|
||||||
mode: ev.mode,
|
mode: ev.mode,
|
||||||
@@ -970,7 +969,7 @@ pub fn record_replay_on_win(
|
|||||||
let win_move_index = recording.moves.len().checked_sub(1);
|
let win_move_index = recording.moves.len().checked_sub(1);
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode.clone(),
|
game.0.draw_mode,
|
||||||
game.0.mode,
|
game.0.mode,
|
||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
@@ -1079,9 +1078,7 @@ fn check_no_moves(
|
|||||||
) {
|
) {
|
||||||
// Reset the debounce flag on every state change so if something changes
|
// Reset the debounce flag on every state change so if something changes
|
||||||
// we re-evaluate on the next state change.
|
// we re-evaluate on the next state change.
|
||||||
let had_event = events.read().next().is_some();
|
let had_event = events.read().count() > 0;
|
||||||
// Drain remaining events to avoid leaking.
|
|
||||||
events.clear();
|
|
||||||
|
|
||||||
if !had_event {
|
if !had_event {
|
||||||
return;
|
return;
|
||||||
@@ -2649,7 +2646,7 @@ mod tests {
|
|||||||
// resolves as Inconclusive — the engine treats Inconclusive
|
// resolves as Inconclusive — the engine treats Inconclusive
|
||||||
// as winnable (see `choose_winnable_seed` doc), so the
|
// as winnable (see `choose_winnable_seed` doc), so the
|
||||||
// helper must return 395 when started at 394.
|
// helper must return 395 when started at 394.
|
||||||
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
|
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
chosen, 395,
|
chosen, 395,
|
||||||
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use bevy::prelude::*;
|
|||||||
|
|
||||||
use crate::events::HelpRequestEvent;
|
use crate::events::HelpRequestEvent;
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ScrimDismissible,
|
ScrimDismissible,
|
||||||
@@ -158,7 +160,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
|||||||
ControlRow { keys: "←", description: "Undo last move" },
|
ControlRow { keys: "←", description: "Undo last move" },
|
||||||
ControlRow { keys: "||", description: "Pause / resume" },
|
ControlRow { keys: "||", description: "Pause / resume" },
|
||||||
ControlRow { keys: "?", description: "This help screen" },
|
ControlRow { keys: "?", description: "This help screen" },
|
||||||
ControlRow { keys: "→", description: "Show a hint" },
|
ControlRow { keys: ANDROID_HINT_LABEL, description: "Show a hint" },
|
||||||
ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" },
|
ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -346,6 +348,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Regression test for M-17: Android help screen showed "→" (right-arrow)
|
||||||
|
/// for the Hint button when the actual HUD button label is "!".
|
||||||
|
/// Verifies that the HUD Buttons section contains exactly one row whose
|
||||||
|
/// `keys` matches `ANDROID_HINT_LABEL`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[test]
|
||||||
|
fn android_hint_row_matches_hud_label() {
|
||||||
|
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||||
|
let hud_section = CONTROL_SECTIONS
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.title == "HUD buttons")
|
||||||
|
.expect("HUD buttons section must exist");
|
||||||
|
let hint_row = hud_section
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.description == "Show a hint")
|
||||||
|
.expect("hint row must exist");
|
||||||
|
assert_eq!(
|
||||||
|
hint_row.keys, ANDROID_HINT_LABEL,
|
||||||
|
"help hint row must match the HUD button label"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ use crate::stats_plugin::StatsResource;
|
|||||||
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ModalButton,
|
||||||
ScrimDismissible,
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -373,6 +374,7 @@ fn toggle_home_screen(
|
|||||||
daily: Option<Res<DailyChallengeResource>>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
diff_expanded: Res<DifficultyExpanded>,
|
diff_expanded: Res<DifficultyExpanded>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::KeyM) {
|
if !keys.just_pressed(KeyCode::KeyM) {
|
||||||
@@ -380,7 +382,7 @@ fn toggle_home_screen(
|
|||||||
}
|
}
|
||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else if other_modal_scrims.is_empty() {
|
||||||
spawn_home_screen(
|
spawn_home_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
build_home_context(
|
build_home_context(
|
||||||
@@ -428,7 +430,7 @@ fn build_home_context<'a>(
|
|||||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
daily_today,
|
daily_today,
|
||||||
draw_mode: settings
|
draw_mode: settings
|
||||||
.map(|s| s.0.draw_mode.clone())
|
.map(|s| s.0.draw_mode)
|
||||||
.unwrap_or(DrawMode::DrawOne),
|
.unwrap_or(DrawMode::DrawOne),
|
||||||
font_res,
|
font_res,
|
||||||
difficulty_expanded,
|
difficulty_expanded,
|
||||||
@@ -589,6 +591,7 @@ fn handle_home_draw_mode_buttons(
|
|||||||
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
||||||
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
mut settings: Option<ResMut<SettingsResource>>,
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
storage_path: Option<Res<SettingsStoragePath>>,
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
@@ -601,6 +604,12 @@ fn handle_home_draw_mode_buttons(
|
|||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Don't respawn while another modal sits on top — the despawn queues
|
||||||
|
// immediately but executes at end of frame, so a respawn in the same
|
||||||
|
// frame would create a second concurrent ModalScrim.
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
if !want_one && !want_three {
|
if !want_one && !want_three {
|
||||||
@@ -658,6 +667,7 @@ fn handle_home_difficulty_toggle(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
stats: Option<Res<StatsResource>>,
|
stats: Option<Res<StatsResource>>,
|
||||||
@@ -668,6 +678,9 @@ fn handle_home_difficulty_toggle(
|
|||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1103,7 +1116,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
|
|||||||
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
||||||
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
||||||
|
|
||||||
let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" };
|
let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
|
||||||
|
|
||||||
// Header row — click to toggle expand/collapse.
|
// Header row — click to toggle expand/collapse.
|
||||||
parent
|
parent
|
||||||
@@ -1337,6 +1350,7 @@ fn spawn_mode_card(
|
|||||||
// bevy::ui — the click handler queries on `&Interaction`
|
// bevy::ui — the click handler queries on `&Interaction`
|
||||||
// which Button drives.
|
// which Button drives.
|
||||||
Button,
|
Button,
|
||||||
|
ModalButton(ButtonVariant::Secondary),
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_2,
|
row_gap: VAL_SPACE_2,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop, SafeAreaInsets};
|
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
|
||||||
use crate::ui_theme::SPACE_2;
|
use crate::ui_theme::SPACE_2;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
@@ -298,6 +298,11 @@ pub struct HelpButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HintButton;
|
pub struct HintButton;
|
||||||
|
|
||||||
|
/// Android HUD label for the Hint button — shared with the help screen's
|
||||||
|
/// controls reference so both always agree.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
|
||||||
|
|
||||||
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
||||||
/// (a small dropdown panel) below the action bar. Each popover row starts
|
/// (a small dropdown panel) below the action bar. Each popover row starts
|
||||||
/// the corresponding game mode.
|
/// the corresponding game mode.
|
||||||
@@ -366,6 +371,9 @@ pub enum MenuOption {
|
|||||||
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
||||||
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
||||||
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
||||||
|
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
|
||||||
|
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
|
||||||
|
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
|
||||||
|
|
||||||
/// Idle / hover / pressed colours shared by every action button. Aliased
|
/// Idle / hover / pressed colours shared by every action button. Aliased
|
||||||
/// to the theme tokens so the HUD picks up palette changes for free.
|
/// to the theme tokens so the HUD picks up palette changes for free.
|
||||||
@@ -417,7 +425,13 @@ impl Plugin for HudPlugin {
|
|||||||
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
||||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
.add_systems(Update, update_selection_hud)
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
update_selection_hud.run_if(
|
||||||
|
resource_exists_and_changed::<SelectionState>
|
||||||
|
.or(resource_exists_and_changed::<GameStateResource>),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add_systems(Update, update_hud_typography)
|
.add_systems(Update, update_hud_typography)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -486,13 +500,12 @@ impl Plugin for HudPlugin {
|
|||||||
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||||
/// A slim grey background is handled by each content section individually
|
/// A slim grey background is handled by each content section individually
|
||||||
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
fn spawn_hud_band(mut commands: Commands) {
|
||||||
const BASE_TOP: f32 = 0.0;
|
const BASE_TOP: f32 = 0.0;
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
top: Val::Px(BASE_TOP + top_inset),
|
top: Val::Px(BASE_TOP),
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
height: Val::Px(HUD_BAND_HEIGHT),
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
@@ -525,10 +538,8 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
|||||||
/// make Score the visual protagonist.
|
/// make Score the visual protagonist.
|
||||||
fn spawn_hud(
|
fn spawn_hud(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
insets: Option<Res<SafeAreaInsets>>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
|
||||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_score = TextFont {
|
let font_score = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
@@ -568,7 +579,7 @@ fn spawn_hud(
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: VAL_SPACE_3,
|
left: VAL_SPACE_3,
|
||||||
top: Val::Px(SPACE_2 + top_inset),
|
top: Val::Px(SPACE_2),
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
// Cap the column at 50% of viewport so on narrow
|
// Cap the column at 50% of viewport so on narrow
|
||||||
// (mobile) widths the inner tier rows have a bounded
|
// (mobile) widths the inner tier rows have a bounded
|
||||||
@@ -701,13 +712,11 @@ fn spawn_hud(
|
|||||||
/// `AvatarResource` or `SettingsResource` later changes.
|
/// `AvatarResource` or `SettingsResource` later changes.
|
||||||
fn spawn_hud_avatar(
|
fn spawn_hud_avatar(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
insets: Option<Res<SafeAreaInsets>>,
|
|
||||||
avatar: Option<Res<AvatarResource>>,
|
avatar: Option<Res<AvatarResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
const SIZE: f32 = 32.0;
|
const SIZE: f32 = 32.0;
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
|
||||||
let id = commands
|
let id = commands
|
||||||
.spawn((
|
.spawn((
|
||||||
HudAvatar,
|
HudAvatar,
|
||||||
@@ -715,7 +724,7 @@ fn spawn_hud_avatar(
|
|||||||
Tooltip::new("Your profile — tap to open."),
|
Tooltip::new("Your profile — tap to open."),
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
top: Val::Px(SPACE_2 + top_inset),
|
top: Val::Px(SPACE_2),
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
width: Val::Px(SIZE),
|
width: Val::Px(SIZE),
|
||||||
height: Val::Px(SIZE),
|
height: Val::Px(SIZE),
|
||||||
@@ -834,10 +843,8 @@ fn handle_avatar_button(
|
|||||||
/// on its own visual edge.
|
/// on its own visual edge.
|
||||||
fn spawn_action_buttons(
|
fn spawn_action_buttons(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
insets: Option<Res<SafeAreaInsets>>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let bottom_inset = insets.as_deref().copied().unwrap_or_default().bottom;
|
|
||||||
let font = TextFont {
|
let font = TextFont {
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -857,7 +864,7 @@ fn spawn_action_buttons(
|
|||||||
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
||||||
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
||||||
/* help */ "?",
|
/* help */ "?",
|
||||||
/* hint */ "!", // ! attention/alert — semantically: "look here"
|
/* hint */ ANDROID_HINT_LABEL,
|
||||||
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
|
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
|
||||||
/* new */ "+",
|
/* new */ "+",
|
||||||
);
|
);
|
||||||
@@ -873,13 +880,13 @@ fn spawn_action_buttons(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||||
// `bottom` is set to `bottom_inset` initially; `SafeAreaAnchoredBottom` keeps
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
// it correct as Android insets arrive in later frames.
|
// Android reports it (frames 1-3); initial value is 0.0.
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
bottom: Val::Px(bottom_inset),
|
bottom: Val::Px(0.0),
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
@@ -1180,7 +1187,7 @@ fn spawn_modes_popover(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
ZIndex(Z_HUD + 5),
|
ZIndex(Z_HUD_POPOVER),
|
||||||
))
|
))
|
||||||
.with_children(|panel| {
|
.with_children(|panel| {
|
||||||
for (option, label, tooltip) in rows {
|
for (option, label, tooltip) in rows {
|
||||||
@@ -1207,8 +1214,8 @@ fn spawn_modes_popover(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
|
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
||||||
// Z_HUD+5) so tapping outside the panel light-dismisses it.
|
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ModesPopoverBackdrop,
|
ModesPopoverBackdrop,
|
||||||
Button,
|
Button,
|
||||||
@@ -1221,7 +1228,7 @@ fn spawn_modes_popover(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::NONE),
|
BackgroundColor(Color::NONE),
|
||||||
ZIndex(Z_HUD + 4),
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1378,7 +1385,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
ZIndex(Z_HUD + 5),
|
ZIndex(Z_HUD_POPOVER),
|
||||||
))
|
))
|
||||||
.with_children(|panel| {
|
.with_children(|panel| {
|
||||||
for (option, label, tooltip) in rows {
|
for (option, label, tooltip) in rows {
|
||||||
@@ -1419,7 +1426,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::NONE),
|
BackgroundColor(Color::NONE),
|
||||||
ZIndex(Z_HUD + 4),
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,6 +1755,11 @@ fn detect_score_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -1793,7 +1805,7 @@ fn detect_score_change(
|
|||||||
top: Val::Px(0.0),
|
top: Val::Px(0.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
ZIndex(Z_HUD + 10),
|
ZIndex(Z_HUD_TOP),
|
||||||
Text::new(format!("+{delta}")),
|
Text::new(format!("+{delta}")),
|
||||||
font,
|
font,
|
||||||
TextColor(ACCENT_PRIMARY),
|
TextColor(ACCENT_PRIMARY),
|
||||||
@@ -1921,6 +1933,9 @@ fn start_streak_flourish(
|
|||||||
let Some(latest) = events.read().last() else {
|
let Some(latest) = events.read().last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -3004,6 +3019,35 @@ mod tests {
|
|||||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||||
|
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||||
|
#[test]
|
||||||
|
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScorePulse must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScoreFloater must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -717,13 +717,12 @@ fn end_drag(
|
|||||||
let ok = match &target {
|
let ok = match &target {
|
||||||
PileType::Foundation(_) => {
|
PileType::Foundation(_) => {
|
||||||
count == 1
|
count == 1
|
||||||
&& can_place_on_foundation(
|
&& game.0.piles.get(&target)
|
||||||
&bottom_card,
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
&game.0.piles[&target],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
game.0.piles.get(&target)
|
||||||
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -941,10 +940,10 @@ fn touch_end_drag(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uncommitted tap — cancel cleanly.
|
// Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing
|
||||||
|
// changed. The mouse path (end_drag) follows the same convention.
|
||||||
if !drag.committed {
|
if !drag.committed {
|
||||||
drag.clear();
|
drag.clear();
|
||||||
changed.write(StateChangedEvent);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,10 +971,12 @@ fn touch_end_drag(
|
|||||||
let ok = match &target {
|
let ok = match &target {
|
||||||
PileType::Foundation(_) => {
|
PileType::Foundation(_) => {
|
||||||
count == 1
|
count == 1
|
||||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
|
&& game.0.piles.get(&target)
|
||||||
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
game.0.piles.get(&target)
|
||||||
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -1161,7 +1162,9 @@ fn find_draggable_at(
|
|||||||
(i, pile_cards.cards.len())
|
(i, pile_cards.cards.len())
|
||||||
} else {
|
} else {
|
||||||
if i != pile_cards.cards.len() - 1 {
|
if i != pile_cards.cards.len() - 1 {
|
||||||
return None;
|
// Non-top card on a non-tableau pile — not draggable; skip
|
||||||
|
// this pile and continue searching remaining piles.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
(i, i + 1)
|
(i, i + 1)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -605,6 +605,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Suspend → resume layout-consistency invariant.
|
||||||
|
///
|
||||||
|
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
|
||||||
|
/// poller re-resolves the same values, `compute_layout` must produce an
|
||||||
|
/// identical result to the fresh-launch layout. This test also verifies
|
||||||
|
/// that a layout computed with `safe_area_top = 0` (the brief window while
|
||||||
|
/// insets haven't re-resolved after resume) differs visibly from the
|
||||||
|
/// correct layout, confirming that the bug would manifest without the fix.
|
||||||
|
#[test]
|
||||||
|
fn suspend_resume_layout_matches_fresh_launch() {
|
||||||
|
let window = Vec2::new(900.0, 2000.0);
|
||||||
|
let safe_top = 27.0_f32;
|
||||||
|
let safe_bottom = 110.0_f32;
|
||||||
|
|
||||||
|
// Fresh-launch layout — insets known from startup.
|
||||||
|
let fresh = compute_layout(window, safe_top, safe_bottom, true);
|
||||||
|
|
||||||
|
// Layout computed during the brief post-resume window before insets
|
||||||
|
// re-resolve (safe_area_top temporarily 0).
|
||||||
|
let wrong = compute_layout(window, 0.0, safe_bottom, true);
|
||||||
|
|
||||||
|
// Verify the "wrong" layout actually differs — the bug would push the
|
||||||
|
// top card row upward by exactly safe_top pixels.
|
||||||
|
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
|
||||||
|
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
|
||||||
|
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
||||||
|
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
||||||
|
assert!(
|
||||||
|
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
|
||||||
|
"wrong layout must displace stock upward by safe_top ({safe_top}): \
|
||||||
|
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
|
||||||
|
wrong_stock_y - fresh_stock_y,
|
||||||
|
);
|
||||||
|
|
||||||
|
// After the poller re-resolves correct insets the layout must be
|
||||||
|
// identical to the fresh-launch layout.
|
||||||
|
let corrected = compute_layout(window, safe_top, safe_bottom, true);
|
||||||
|
assert_eq!(
|
||||||
|
corrected.card_size, fresh.card_size,
|
||||||
|
"card size must be preserved after resume",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||||
|
"stock y must match fresh launch after resume: \
|
||||||
|
corrected={:.2} fresh={fresh_stock_y:.2}",
|
||||||
|
corrected.pile_positions[&PileType::Stock].y,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(corrected.pile_positions[&PileType::Stock].x
|
||||||
|
- fresh.pile_positions[&PileType::Stock].x)
|
||||||
|
.abs()
|
||||||
|
< 1e-3,
|
||||||
|
"stock x must be unchanged after resume",
|
||||||
|
);
|
||||||
|
// The HUD band top clearance (distance from window top to card top)
|
||||||
|
// must match as well — this is the quantity directly visible in Bug 2.
|
||||||
|
let card_top = |layout: &super::Layout| {
|
||||||
|
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
||||||
|
"top-of-card must match fresh launch after resume: \
|
||||||
|
corrected={:.2} fresh={:.2}",
|
||||||
|
card_top(&corrected),
|
||||||
|
card_top(&fresh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// safe_area_bottom must not affect horizontal positions.
|
/// safe_area_bottom must not affect horizontal positions.
|
||||||
#[test]
|
#[test]
|
||||||
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
|||||||
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
@@ -138,6 +138,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<OptOutTask>()
|
.init_resource::<OptOutTask>()
|
||||||
.init_resource::<DisplayNameBuffer>()
|
.init_resource::<DisplayNameBuffer>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||||
@@ -159,6 +160,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
handle_display_name_text_input,
|
handle_display_name_text_input,
|
||||||
handle_display_name_confirm,
|
handle_display_name_confirm,
|
||||||
handle_display_name_cancel,
|
handle_display_name_cancel,
|
||||||
|
update_leaderboard_public_name_label,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
)
|
)
|
||||||
@@ -350,7 +352,7 @@ fn handle_opt_in_button(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(str::to_string)
|
.map(|n| n.chars().take(32).collect::<String>())
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "Player".to_string());
|
.unwrap_or_else(|| "Player".to_string());
|
||||||
|
|
||||||
@@ -361,10 +363,13 @@ fn handle_opt_in_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
/// Polls the opt-in task; fires a toast and persists opted-in state on completion.
|
||||||
fn poll_opt_in_task(
|
fn poll_opt_in_task(
|
||||||
mut task_res: ResMut<OptInTask>,
|
mut task_res: ResMut<OptInTask>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else { return };
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
@@ -372,10 +377,18 @@ fn poll_opt_in_task(
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||||
|
if let Some(mut s) = settings {
|
||||||
|
s.0.leaderboard_opted_in = true;
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings after opt-in: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-in failed: {e}");
|
warn!("leaderboard opt-in failed: {e}");
|
||||||
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
|
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,10 +414,13 @@ fn handle_opt_out_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
/// Polls the opt-out task; fires a toast and clears opted-in state on completion.
|
||||||
fn poll_opt_out_task(
|
fn poll_opt_out_task(
|
||||||
mut task_res: ResMut<OptOutTask>,
|
mut task_res: ResMut<OptOutTask>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else { return };
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
@@ -412,10 +428,18 @@ fn poll_opt_out_task(
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||||
|
if let Some(mut s) = settings {
|
||||||
|
s.0.leaderboard_opted_in = false;
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings after opt-out: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-out failed: {e}");
|
warn!("leaderboard opt-out failed: {e}");
|
||||||
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
|
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,6 +452,12 @@ fn poll_opt_out_task(
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct LeaderboardCloseButton;
|
pub struct LeaderboardCloseButton;
|
||||||
|
|
||||||
|
/// Marker on the "Public name: …" label inside the leaderboard panel so it
|
||||||
|
/// can be updated reactively when the player changes their display name
|
||||||
|
/// without a full panel rebuild.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct LeaderboardPublicNameText;
|
||||||
|
|
||||||
fn spawn_leaderboard_screen(
|
fn spawn_leaderboard_screen(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
data: &LeaderboardResource,
|
data: &LeaderboardResource,
|
||||||
@@ -481,6 +511,7 @@ fn spawn_leaderboard_screen(
|
|||||||
None => "Public name: (same as username)".to_string(),
|
None => "Public name: (same as username)".to_string(),
|
||||||
};
|
};
|
||||||
row.spawn((
|
row.spawn((
|
||||||
|
LeaderboardPublicNameText,
|
||||||
Text::new(label),
|
Text::new(label),
|
||||||
font_caption.clone(),
|
font_caption.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
@@ -733,7 +764,9 @@ fn handle_display_name_text_input(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the typed display name to `SettingsResource` and closes the modal.
|
/// Saves the typed display name to `SettingsResource`, closes the modal, and
|
||||||
|
/// pushes the new name to the server when the player is already opted in.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_display_name_confirm(
|
fn handle_display_name_confirm(
|
||||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||||
screens: Query<Entity, With<DisplayNameModal>>,
|
screens: Query<Entity, With<DisplayNameModal>>,
|
||||||
@@ -741,22 +774,58 @@ fn handle_display_name_confirm(
|
|||||||
buf: Res<DisplayNameBuffer>,
|
buf: Res<DisplayNameBuffer>,
|
||||||
settings: Option<ResMut<SettingsResource>>,
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
settings_path: Option<Res<SettingsStoragePath>>,
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<OptInTask>,
|
||||||
) {
|
) {
|
||||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(mut settings) = settings {
|
if let Some(mut settings) = settings {
|
||||||
let trimmed = buf.0.trim().to_string();
|
let trimmed: String = buf.0.trim().chars().take(32).collect();
|
||||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(trimmed)
|
Some(trimmed.clone())
|
||||||
};
|
};
|
||||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
{
|
{
|
||||||
warn!("failed to save settings: {e}");
|
warn!("failed to save settings: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push updated name to the server when already opted in and no task
|
||||||
|
// is in flight. The server's opt-in endpoint is an upsert, so calling
|
||||||
|
// it a second time only updates the display_name column.
|
||||||
|
let is_remote = provider
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
|
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
|
||||||
|
let display_name = settings
|
||||||
|
.0
|
||||||
|
.leaderboard_display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
||||||
|
ref username,
|
||||||
|
..
|
||||||
|
} = settings.0.sync_backend
|
||||||
|
{
|
||||||
|
username.chars().take(32).collect()
|
||||||
|
} else {
|
||||||
|
"Player".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(p) = provider {
|
||||||
|
let provider = p.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
provider
|
||||||
|
.opt_in_leaderboard(&display_name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -857,6 +926,25 @@ fn spawn_display_name_modal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
|
||||||
|
/// `SettingsResource` after the player saves a new display name. No-op when
|
||||||
|
/// the panel is closed (`labels.is_empty()` exits immediately).
|
||||||
|
fn update_leaderboard_public_name_label(
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
|
||||||
|
) {
|
||||||
|
if labels.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
|
||||||
|
Some(n) => format!("Public name: {n}"),
|
||||||
|
None => "Public name: (same as username)".to_string(),
|
||||||
|
};
|
||||||
|
for mut text in &mut labels {
|
||||||
|
text.0 = new_label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
||||||
fn printable_char_dn(text: &str) -> Option<char> {
|
fn printable_char_dn(text: &str) -> Option<char> {
|
||||||
let ch = text.chars().next()?;
|
let ch = text.chars().next()?;
|
||||||
@@ -1048,4 +1136,171 @@ mod tests {
|
|||||||
// 65 seconds = 1:05, not 1:5
|
// 65 seconds = 1:05, not 1:5
|
||||||
assert_eq!(format_secs(65), "1:05");
|
assert_eq!(format_secs(65), "1:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Bug-fix regression tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn headless_app_with_settings() -> App {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||||
|
#[test]
|
||||||
|
fn opt_in_error_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Inject a pre-resolved failed task directly into OptInTask.
|
||||||
|
let failed_task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
|
// Allow the task to complete and be polled.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"WarningToastEvent must be fired when opt-in fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 1: opt-out errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||||
|
#[test]
|
||||||
|
fn opt_out_error_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
let failed_task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"WarningToastEvent must be fired when opt-out fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 2: successful opt-in must set `leaderboard_opted_in = true` in Settings.
|
||||||
|
#[test]
|
||||||
|
fn opt_in_success_sets_opted_in_flag() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Confirm the flag starts false.
|
||||||
|
assert!(!app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in);
|
||||||
|
|
||||||
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in,
|
||||||
|
"leaderboard_opted_in must be true after successful opt-in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 2: successful opt-out must clear `leaderboard_opted_in`.
|
||||||
|
#[test]
|
||||||
|
fn opt_out_success_clears_opted_in_flag() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Seed as opted in.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in = true;
|
||||||
|
|
||||||
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in,
|
||||||
|
"leaderboard_opted_in must be false after successful opt-out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 3: `LeaderboardPublicNameText` label must reflect a display-name
|
||||||
|
/// change applied to `SettingsResource` without a panel rebuild.
|
||||||
|
#[test]
|
||||||
|
fn public_name_label_updates_reactively() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Open the panel.
|
||||||
|
press(&mut app, KeyCode::KeyL);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Verify the label starts with the default copy.
|
||||||
|
let initial: String = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("LeaderboardPublicNameText must exist while panel is open")
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
initial.contains("same as username"),
|
||||||
|
"initial label should say '(same as username)' when no display name is set"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear just-pressed state so `toggle_leaderboard_screen` doesn't
|
||||||
|
// re-fire in the next frame (MinimalPlugins has no input-tick system).
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyL);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the display name in SettingsResource.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_display_name = Some("TestPlayer".to_string());
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let updated: String = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("LeaderboardPublicNameText must still exist")
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
updated.contains("TestPlayer"),
|
||||||
|
"label must reflect new display name after settings change"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ fn toggle_pause(
|
|||||||
// Snapshot current level and streak at pause time.
|
// Snapshot current level and streak at pause time.
|
||||||
let level = progress.as_deref().map(|p| p.0.level);
|
let level = progress.as_deref().map(|p| p.0.level);
|
||||||
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone());
|
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
|
||||||
spawn_pause_screen(
|
spawn_pause_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
level,
|
level,
|
||||||
@@ -437,10 +437,15 @@ fn close_forfeit_modal(
|
|||||||
/// The player reaches these overlays via the HUD menu while paused, which
|
/// The player reaches these overlays via the HUD menu while paused, which
|
||||||
/// causes both the pause modal and the overlay to be live simultaneously.
|
/// causes both the pause modal and the overlay to be live simultaneously.
|
||||||
/// That is always unintentional — the overlay should own the screen.
|
/// That is always unintentional — the overlay should own the screen.
|
||||||
|
/// Query filter for modals that are not part of the pause flow.
|
||||||
|
/// Excludes both `PauseScreen` (the pause modal itself) and
|
||||||
|
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
|
||||||
|
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
|
||||||
|
|
||||||
fn auto_resume_on_overlay(
|
fn auto_resume_on_overlay(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
pause_screens: Query<Entity, With<PauseScreen>>,
|
pause_screens: Query<Entity, With<PauseScreen>>,
|
||||||
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
|
||||||
mut paused: ResMut<PausedResource>,
|
mut paused: ResMut<PausedResource>,
|
||||||
) {
|
) {
|
||||||
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
||||||
@@ -449,8 +454,10 @@ fn auto_resume_on_overlay(
|
|||||||
for entity in &pause_screens {
|
for entity in &pause_screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
if paused.0 {
|
||||||
paused.0 = false;
|
paused.0 = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
||||||
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
|
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ fn tick_debounce_and_spawn_solver_task(
|
|||||||
|
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
|
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
|
||||||
let cfg = SolverConfig::default();
|
let cfg = SolverConfig::default();
|
||||||
let task = AsyncComputeTaskPool::get()
|
let task = AsyncComputeTaskPool::get()
|
||||||
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ pub fn start_replay_playback(
|
|||||||
) {
|
) {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||||
commands.insert_resource(GameStateResource(fresh));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
// Initial `secs_to_next` uses the constant rather than reading
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Bevy resources owned by the engine crate.
|
//! Bevy resources owned by the engine crate.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -111,3 +113,26 @@ pub struct HintCycleIndex(pub usize);
|
|||||||
/// returns to the same position in the list without re-scrolling.
|
/// returns to the same position in the list without re-scrolling.
|
||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SettingsScrollPos(pub f32);
|
pub struct SettingsScrollPos(pub f32);
|
||||||
|
|
||||||
|
/// Shared Tokio runtime used by all async-task closures that need HTTP I/O.
|
||||||
|
///
|
||||||
|
/// Bevy's `AsyncComputeTaskPool` uses `async-executor` (not Tokio), so spawned
|
||||||
|
/// closures that call `reqwest`/`hyper` need a Tokio reactor. A single
|
||||||
|
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
|
||||||
|
/// into every network task — safe for concurrent `block_on` calls from multiple
|
||||||
|
/// worker threads.
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
||||||
|
|
||||||
|
impl Default for TokioRuntimeResource {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Building the Tokio runtime is startup-time initialization; failure
|
||||||
|
// here means the OS refused to create threads, which is unrecoverable.
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build shared Tokio runtime");
|
||||||
|
Self(Arc::new(rt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
//! changes flow through automatically.
|
//! changes flow through automatically.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{AppLifecycle, WindowResized};
|
||||||
|
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
|
|
||||||
@@ -65,14 +66,25 @@ pub struct SafeAreaInsetsPlugin;
|
|||||||
|
|
||||||
impl Plugin for SafeAreaInsetsPlugin {
|
impl Plugin for SafeAreaInsetsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<SafeAreaInsets>()
|
// Both message types may already be registered by GamePlugin / TablePlugin;
|
||||||
|
// add_message is idempotent.
|
||||||
|
app.add_message::<AppLifecycle>()
|
||||||
|
.add_message::<WindowResized>()
|
||||||
|
.init_resource::<SafeAreaInsets>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(apply_safe_area_anchors, apply_safe_area_bottom_anchors, apply_safe_area_to_modal_scrims),
|
(
|
||||||
|
apply_safe_area_anchors,
|
||||||
|
apply_safe_area_bottom_anchors,
|
||||||
|
apply_safe_area_to_modal_scrims,
|
||||||
|
on_app_resumed,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.add_systems(Update, android::refresh_insets);
|
app.init_resource::<android::SafeAreaPollTries>()
|
||||||
|
.add_systems(Update, android::refresh_insets)
|
||||||
|
.add_systems(Update, android::rearm_on_resumed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,33 +154,73 @@ fn apply_safe_area_to_modal_scrims(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
|
||||||
|
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
|
||||||
|
/// whatever `SafeAreaInsets` are current at that moment.
|
||||||
|
///
|
||||||
|
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||||
|
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
||||||
|
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
||||||
|
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
||||||
|
/// synthetic `WindowResized` and the layout converges to the right position.
|
||||||
|
///
|
||||||
|
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||||
|
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||||
|
/// desktop) even though insets are always zero.
|
||||||
|
fn on_app_resumed(
|
||||||
|
mut lifecycle: MessageReader<AppLifecycle>,
|
||||||
|
windows: Query<(Entity, &Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
) {
|
||||||
|
for event in lifecycle.read() {
|
||||||
|
if !matches!(event, AppLifecycle::WillResume) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((entity, window)) = windows.iter().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: window.resolution.width(),
|
||||||
|
height: window.resolution.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod android {
|
mod android {
|
||||||
use super::SafeAreaInsets;
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
|
/// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
|
||||||
|
/// after a background/foreground cycle.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub(super) struct SafeAreaPollTries(pub u32);
|
||||||
|
|
||||||
/// Polls Android for safe-area insets until we get a non-zero
|
/// Polls Android for safe-area insets until we get a non-zero
|
||||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||||
/// is typically frame 1–3 of a fresh launch.
|
/// is typically frame 1–3 of a fresh launch.
|
||||||
pub(super) fn refresh_insets(
|
pub(super) fn refresh_insets(
|
||||||
mut insets: ResMut<SafeAreaInsets>,
|
mut insets: ResMut<SafeAreaInsets>,
|
||||||
mut tries: Local<u32>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
) {
|
) {
|
||||||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||||||
// devices that genuinely report zero insets.
|
// devices that genuinely report zero insets.
|
||||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||||
|
|
||||||
if *tries >= MAX_TRIES || insets.is_populated() {
|
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*tries += 1;
|
poll.0 += 1;
|
||||||
|
|
||||||
match query_insets() {
|
match query_insets() {
|
||||||
Ok(v) if v.is_populated() => {
|
Ok(v) if v.is_populated() => {
|
||||||
info!(
|
info!(
|
||||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||||
v.top, v.bottom, v.left, v.right, *tries
|
v.top, v.bottom, v.left, v.right, poll.0
|
||||||
);
|
);
|
||||||
*insets = v;
|
*insets = v;
|
||||||
}
|
}
|
||||||
@@ -177,13 +229,35 @@ mod android {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Don't spam — log once and let polling continue silently.
|
// Don't spam — log once and let polling continue silently.
|
||||||
if *tries == 1 {
|
if poll.0 == 1 {
|
||||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the inset poller and clears cached insets on
|
||||||
|
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
||||||
|
/// frames immediately after the app returns to the foreground.
|
||||||
|
///
|
||||||
|
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
||||||
|
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
||||||
|
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
||||||
|
/// once `refresh_insets` resolves the real values a second synthetic
|
||||||
|
/// `WindowResized` fires and the layout converges to the correct position.
|
||||||
|
pub(super) fn rearm_on_resumed(
|
||||||
|
mut lifecycle: MessageReader<AppLifecycle>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
mut insets: ResMut<SafeAreaInsets>,
|
||||||
|
) {
|
||||||
|
for event in lifecycle.read() {
|
||||||
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
poll.0 = 0;
|
||||||
|
*insets = SafeAreaInsets::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use bevy::android::ANDROID_APP;
|
use bevy::android::ANDROID_APP;
|
||||||
use jni::{objects::JObject, JavaVM};
|
use jni::{objects::JObject, JavaVM};
|
||||||
|
|||||||
@@ -156,7 +156,13 @@ impl Plugin for SelectionPlugin {
|
|||||||
.in_set(SelectionKeySet)
|
.in_set(SelectionKeySet)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
clear_selection_on_state_change.after(GameMutation),
|
clear_selection_on_state_change.after(GameMutation),
|
||||||
update_selection_highlight.after(GameMutation),
|
update_selection_highlight
|
||||||
|
.after(GameMutation)
|
||||||
|
.run_if(
|
||||||
|
resource_changed::<SelectionState>
|
||||||
|
.or(resource_changed::<KeyboardDragState>)
|
||||||
|
.or(resource_changed::<crate::GameStateResource>),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,8 +401,10 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_high_contrast_text,
|
update_high_contrast_text,
|
||||||
update_high_contrast_borders,
|
update_high_contrast_borders
|
||||||
update_high_contrast_backgrounds,
|
.run_if(resource_changed::<SettingsResource>),
|
||||||
|
update_high_contrast_backgrounds
|
||||||
|
.run_if(resource_changed::<SettingsResource>),
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
|||||||
|
|
||||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||||
SyncConfigureRequestEvent,
|
WarningToastEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
|
||||||
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -101,6 +101,7 @@ impl SyncPlugin {
|
|||||||
impl Plugin for SyncPlugin {
|
impl Plugin for SyncPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
||||||
|
.init_resource::<TokioRuntimeResource>()
|
||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.init_resource::<PullTaskResult>()
|
.init_resource::<PullTaskResult>()
|
||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
@@ -108,7 +109,7 @@ impl Plugin for SyncPlugin {
|
|||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
.add_message::<SyncCompleteEvent>()
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_systems(Startup, start_pull)
|
.add_systems(Startup, start_pull)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -130,19 +131,14 @@ impl Plugin for SyncPlugin {
|
|||||||
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
||||||
fn start_pull(
|
fn start_pull(
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut task_res: ResMut<PullTask>,
|
mut task_res: ResMut<PullTask>,
|
||||||
mut status: ResMut<SyncStatusResource>,
|
mut status: ResMut<SyncStatusResource>,
|
||||||
) {
|
) {
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
|
rt.block_on(provider.pull())
|
||||||
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
|
|
||||||
// a short-lived single-threaded runtime for this network round-trip.
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.pull())
|
|
||||||
});
|
});
|
||||||
task_res.0 = Some(task);
|
task_res.0 = Some(task);
|
||||||
status.0 = SyncStatus::Syncing;
|
status.0 = SyncStatus::Syncing;
|
||||||
@@ -153,6 +149,7 @@ fn start_pull(
|
|||||||
fn handle_manual_sync_request(
|
fn handle_manual_sync_request(
|
||||||
mut events: MessageReader<ManualSyncRequestEvent>,
|
mut events: MessageReader<ManualSyncRequestEvent>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut task_res: ResMut<PullTask>,
|
mut task_res: ResMut<PullTask>,
|
||||||
mut status: ResMut<SyncStatusResource>,
|
mut status: ResMut<SyncStatusResource>,
|
||||||
) {
|
) {
|
||||||
@@ -164,12 +161,9 @@ fn handle_manual_sync_request(
|
|||||||
return; // Already pulling — ignore.
|
return; // Already pulling — ignore.
|
||||||
}
|
}
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(provider.pull())
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.pull())
|
|
||||||
});
|
});
|
||||||
task_res.0 = Some(task);
|
task_res.0 = Some(task);
|
||||||
status.0 = SyncStatus::Syncing;
|
status.0 = SyncStatus::Syncing;
|
||||||
@@ -197,7 +191,7 @@ fn poll_pull_result(
|
|||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else {
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
return;
|
return;
|
||||||
@@ -251,13 +245,13 @@ fn poll_pull_result(
|
|||||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
|
warning_toast.write(WarningToastEvent(msg.clone()));
|
||||||
// On auth failure, reopen the Connect modal so the player can
|
// On auth failure, reopen the Connect modal so the player can
|
||||||
// re-enter credentials without having to navigate through Settings.
|
// re-enter credentials without having to navigate through Settings.
|
||||||
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||||
// the modal is already on screen, so repeated pull failures don't
|
// the modal is already on screen, so repeated pull failures don't
|
||||||
// stack multiple modals.
|
// stack multiple modals.
|
||||||
if matches!(e, SyncError::Auth(_)) {
|
if matches!(e, SyncError::Auth(_)) {
|
||||||
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
|
||||||
configure_sync.write(SyncConfigureRequestEvent);
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
}
|
}
|
||||||
status.0 = SyncStatus::Error(msg.clone());
|
status.0 = SyncStatus::Error(msg.clone());
|
||||||
@@ -274,6 +268,7 @@ fn poll_pull_result(
|
|||||||
fn push_on_exit(
|
fn push_on_exit(
|
||||||
mut exit_events: MessageReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
achievements: Res<AchievementsResource>,
|
achievements: Res<AchievementsResource>,
|
||||||
progress: Res<ProgressResource>,
|
progress: Res<ProgressResource>,
|
||||||
@@ -284,21 +279,7 @@ fn push_on_exit(
|
|||||||
exit_events.clear();
|
exit_events.clear();
|
||||||
|
|
||||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let provider = provider.0.clone();
|
let result = rt.0.block_on(provider.0.push(&payload));
|
||||||
|
|
||||||
// Prefer an existing tokio runtime; fall back to a temporary one for
|
|
||||||
// environments (e.g. tests, Android's non-Tokio async executor) where
|
|
||||||
// reqwest/hyper would otherwise panic with "no reactor running".
|
|
||||||
let result = match tokio::runtime::Handle::try_current() {
|
|
||||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
|
||||||
Err(_) => match tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(rt) => rt.block_on(provider.push(&payload)),
|
|
||||||
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
// `UnsupportedPlatform` is the expected response of
|
// `UnsupportedPlatform` is the expected response of
|
||||||
@@ -327,6 +308,7 @@ fn push_on_exit(
|
|||||||
fn push_replay_on_win(
|
fn push_replay_on_win(
|
||||||
mut wins: MessageReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
recording: Res<RecordingReplay>,
|
recording: Res<RecordingReplay>,
|
||||||
mut pending: ResMut<PendingReplayUpload>,
|
mut pending: ResMut<PendingReplayUpload>,
|
||||||
@@ -340,7 +322,7 @@ fn push_replay_on_win(
|
|||||||
}
|
}
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode.clone(),
|
game.0.draw_mode,
|
||||||
game.0.mode,
|
game.0.mode,
|
||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
@@ -348,12 +330,9 @@ fn push_replay_on_win(
|
|||||||
recording.moves.clone(),
|
recording.moves.clone(),
|
||||||
);
|
);
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(provider.push_replay(&replay))
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.push_replay(&replay))
|
|
||||||
});
|
});
|
||||||
// If a previous upload is still in flight, drop it — the most
|
// If a previous upload is still in flight, drop it — the most
|
||||||
// recent win is the one whose share link the player will care
|
// recent win is the one whose share link the player will care
|
||||||
@@ -571,6 +550,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_failure_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
let mut app = headless_app_with(FailingProvider);
|
||||||
|
let deadline =
|
||||||
|
std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
if matches!(
|
||||||
|
app.world().resource::<SyncStatusResource>().0,
|
||||||
|
SyncStatus::Error(_)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"a WarningToastEvent must fire when the pull fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_payload_sets_nil_user_id() {
|
fn build_payload_sets_nil_user_id() {
|
||||||
let payload = build_payload(
|
let payload = build_payload(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ use crate::events::{
|
|||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||||
|
use crate::resources::TokioRuntimeResource;
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::spawn_modal;
|
use crate::ui_modal::spawn_modal;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -301,6 +302,7 @@ fn handle_auth_button(
|
|||||||
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut pending: ResMut<PendingAuthTask>,
|
mut pending: ResMut<PendingAuthTask>,
|
||||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||||
@@ -363,13 +365,10 @@ fn handle_auth_button(
|
|||||||
let is_register = register_clicked;
|
let is_register = register_clicked;
|
||||||
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
||||||
let pw = password.clone();
|
let pw = password.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(async {
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(async {
|
|
||||||
let (access_token, refresh_token) = if is_register {
|
let (access_token, refresh_token) = if is_register {
|
||||||
client.register(&pw).await?
|
client.register(&pw).await?
|
||||||
} else {
|
} else {
|
||||||
@@ -575,6 +574,7 @@ fn handle_delete_cancel(
|
|||||||
fn handle_delete_confirm(
|
fn handle_delete_confirm(
|
||||||
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut pending: ResMut<PendingDeleteTask>,
|
mut pending: ResMut<PendingDeleteTask>,
|
||||||
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -587,12 +587,9 @@ fn handle_delete_confirm(
|
|||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(provider.delete_account())
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.delete_account())
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fn load_initial_theme(
|
|||||||
let id = settings
|
let id = settings
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|s| s.0.selected_theme_id.as_str())
|
.map(|s| s.0.selected_theme_id.as_str())
|
||||||
.unwrap_or("dark");
|
.unwrap_or("classic");
|
||||||
let url = bundled_theme_url(id)
|
let url = bundled_theme_url(id)
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ where
|
|||||||
// modal at `Z_PAUSE` (220) in some scenes.
|
// modal at `Z_PAUSE` (220) in some scenes.
|
||||||
GlobalZIndex(z_panel),
|
GlobalZIndex(z_panel),
|
||||||
ZIndex(z_panel),
|
ZIndex(z_panel),
|
||||||
|
// B0004: ModalCard carries Transform (for the scale animation).
|
||||||
|
// Bevy's GlobalTransform hook fires B0004 when a child has
|
||||||
|
// GlobalTransform but the parent does not. Adding Identity
|
||||||
|
// Transform here gives the scrim GlobalTransform so the check
|
||||||
|
// passes. UI layout still uses UiTransform; this has no layout
|
||||||
|
// effect.
|
||||||
|
Transform::default(),
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|root| {
|
||||||
root.spawn((
|
root.spawn((
|
||||||
@@ -603,7 +610,7 @@ pub fn dismiss_modal_on_scrim_click(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
windows: Query<&Window, With<PrimaryWindow>>,
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
|
scrims: Query<(Entity, &Children), (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||||
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
||||||
) {
|
) {
|
||||||
let Some(mouse) = mouse else { return };
|
let Some(mouse) = mouse else { return };
|
||||||
@@ -620,15 +627,19 @@ pub fn dismiss_modal_on_scrim_click(
|
|||||||
// Topmost-only: bail after the first dismissible scrim. Stacked
|
// Topmost-only: bail after the first dismissible scrim. Stacked
|
||||||
// dismissible modals are not currently a real case, but this guard
|
// dismissible modals are not currently a real case, but this guard
|
||||||
// keeps the behaviour predictable if they ever arise.
|
// keeps the behaviour predictable if they ever arise.
|
||||||
let Some(scrim_entity) = scrims.iter().next() else {
|
let Some((scrim_entity, scrim_children)) = scrims.iter().next() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cursor_over_card = cards.iter().any(|(transform, computed)| {
|
// Only test the ModalCard(s) that belong to THIS scrim, not cards
|
||||||
|
// from any other concurrently-open modal.
|
||||||
|
let cursor_over_card = scrim_children.iter().any(|child| {
|
||||||
|
cards.get(child).is_ok_and(|(transform, computed)| {
|
||||||
let inv = computed.inverse_scale_factor;
|
let inv = computed.inverse_scale_factor;
|
||||||
let size_logical = computed.size() * inv;
|
let size_logical = computed.size() * inv;
|
||||||
let centre_logical = transform.translation * inv;
|
let centre_logical = transform.translation * inv;
|
||||||
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if !cursor_over_card {
|
if !cursor_over_card {
|
||||||
|
|||||||
@@ -313,10 +313,10 @@ impl HighContrastBackground {
|
|||||||
/// `outline` from the design system. `#505050`.
|
/// `outline` from the design system. `#505050`.
|
||||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||||
|
|
||||||
/// 2 px ring drawn around the focused interactive element. Cyan
|
/// 2 px ring drawn around the focused interactive element. Brick-red
|
||||||
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
|
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
|
||||||
/// against both elevated surfaces and the modal scrim backdrop.
|
/// against both elevated surfaces and the modal scrim backdrop.
|
||||||
/// `rgba(111, 194, 239, 0.85)`.
|
/// `rgba(165, 66, 66, 0.85)`.
|
||||||
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
|
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -401,8 +401,13 @@ pub const Z_BACKGROUND: i32 = -10;
|
|||||||
pub const Z_PILE_MARKER: i32 = -1;
|
pub const Z_PILE_MARKER: i32 = -1;
|
||||||
/// Base layer for HUD readouts (top-left).
|
/// Base layer for HUD readouts (top-left).
|
||||||
pub const Z_HUD: i32 = 50;
|
pub const Z_HUD: i32 = 50;
|
||||||
/// Action bar + popovers — above HUD readouts so dropdowns can overlap.
|
/// Fullscreen transparent dismiss-backdrop spawned behind a HUD popover so
|
||||||
pub const Z_HUD_TOP: i32 = 60;
|
/// tapping outside it light-dismisses the panel without blocking other input.
|
||||||
|
pub const Z_HUD_POPOVER_BACKDROP: i32 = Z_HUD + 4;
|
||||||
|
/// HUD popovers (Modes dropdown, etc.) — above the dismiss backdrop.
|
||||||
|
pub const Z_HUD_POPOVER: i32 = Z_HUD + 5;
|
||||||
|
/// Transient HUD annotations (score-delta floaters) — above popovers.
|
||||||
|
pub const Z_HUD_TOP: i32 = Z_HUD + 10;
|
||||||
pub const Z_MODAL_SCRIM: i32 = 200;
|
pub const Z_MODAL_SCRIM: i32 = 200;
|
||||||
pub const Z_MODAL_PANEL: i32 = 210;
|
pub const Z_MODAL_PANEL: i32 = 210;
|
||||||
/// Pause overlay outranks normal modals — pausing should always be on top.
|
/// Pause overlay outranks normal modals — pausing should always be on top.
|
||||||
@@ -648,6 +653,8 @@ mod tests {
|
|||||||
Z_BACKGROUND,
|
Z_BACKGROUND,
|
||||||
Z_PILE_MARKER,
|
Z_PILE_MARKER,
|
||||||
Z_HUD,
|
Z_HUD,
|
||||||
|
Z_HUD_POPOVER_BACKDROP,
|
||||||
|
Z_HUD_POPOVER,
|
||||||
Z_HUD_TOP,
|
Z_HUD_TOP,
|
||||||
Z_MODAL_SCRIM,
|
Z_MODAL_SCRIM,
|
||||||
Z_MODAL_PANEL,
|
Z_MODAL_PANEL,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
|
|||||||
let ctx = WeeklyGoalContext {
|
let ctx = WeeklyGoalContext {
|
||||||
time_seconds: ev.time_seconds,
|
time_seconds: ev.time_seconds,
|
||||||
used_undo: game.0.undo_count > 0,
|
used_undo: game.0.undo_count > 0,
|
||||||
draw_mode: game.0.draw_mode.clone(),
|
draw_mode: game.0.draw_mode,
|
||||||
};
|
};
|
||||||
for def in WEEKLY_GOALS {
|
for def in WEEKLY_GOALS {
|
||||||
if !def.matches(&ctx) {
|
if !def.matches(&ctx) {
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ pub async fn get_me(
|
|||||||
|
|
||||||
Ok(Json(MeResponse {
|
Ok(Json(MeResponse {
|
||||||
id: user.user_id,
|
id: user.user_id,
|
||||||
username: row.username.unwrap_or_default(),
|
username: row.username.ok_or(AppError::Unauthorized)?,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -386,13 +386,19 @@ pub async fn upload_avatar(
|
|||||||
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
|
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
let filename = format!("{}.{}", user.user_id, ext);
|
let filename = format!("{}.{}", user.user_id, ext);
|
||||||
let path = std::path::Path::new("avatars").join(&filename);
|
let path = std::path::Path::new("avatars").join(&filename);
|
||||||
// Remove stale files with other extensions first.
|
let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
|
||||||
|
// Write to a temp file then atomically rename so concurrent readers never
|
||||||
|
// see a partially-written avatar.
|
||||||
|
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
std::fs::rename(&tmp_path, &path).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
// Remove stale files with other extensions after the atomic rename.
|
||||||
for old_ext in &["jpg", "png", "webp", "gif"] {
|
for old_ext in &["jpg", "png", "webp", "gif"] {
|
||||||
|
if *old_ext != ext {
|
||||||
let _ = std::fs::remove_file(
|
let _ = std::fs::remove_file(
|
||||||
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
}
|
||||||
|
|
||||||
let avatar_url = format!("/avatars/{filename}");
|
let avatar_url = format!("/avatars/{filename}");
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -412,7 +418,7 @@ pub async fn upload_avatar(
|
|||||||
|
|
||||||
Ok(Json(MeResponse {
|
Ok(Json(MeResponse {
|
||||||
id: user.user_id,
|
id: user.user_id,
|
||||||
username: username.unwrap_or_default(),
|
username: username.ok_or(AppError::Unauthorized)?,
|
||||||
avatar_url: Some(avatar_url),
|
avatar_url: Some(avatar_url),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
.route("/api/account", delete(auth::delete_account))
|
.route("/api/account", delete(auth::delete_account))
|
||||||
.route("/api/me", get(auth::get_me))
|
.route("/api/me", get(auth::get_me))
|
||||||
.route("/api/me/avatar", put(auth::upload_avatar))
|
.route("/api/me/avatar", put(auth::upload_avatar))
|
||||||
|
.nest_service("/avatars", ServeDir::new("avatars"))
|
||||||
.layer(axum_middleware::from_fn_with_state(
|
.layer(axum_middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
middleware::require_auth,
|
middleware::require_auth,
|
||||||
@@ -231,7 +232,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
)
|
)
|
||||||
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
.nest_service("/avatars", ServeDir::new("avatars"))
|
|
||||||
.layer(axum_middleware::from_fn(security_headers));
|
.layer(axum_middleware::from_fn(security_headers));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub mod progress;
|
|||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|
||||||
pub use achievements::AchievementRecord;
|
pub use achievements::AchievementRecord;
|
||||||
pub use merge::merge;
|
pub use merge::{merge, merge_at};
|
||||||
pub use progress::{level_for_xp, PlayerProgress};
|
pub use progress::{level_for_xp, PlayerProgress};
|
||||||
pub use stats::StatsSnapshot;
|
pub use stats::StatsSnapshot;
|
||||||
|
|
||||||
|
|||||||
+43
-18
@@ -3,13 +3,18 @@
|
|||||||
//! All functions are free of I/O and side effects — safe to call from any
|
//! All functions are free of I/O and side effects — safe to call from any
|
||||||
//! context including unit tests and the Bevy main thread.
|
//! context including unit tests and the Bevy main thread.
|
||||||
|
|
||||||
use chrono::{NaiveDate, Utc};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
|
|
||||||
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||||
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
||||||
|
|
||||||
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
||||||
///
|
///
|
||||||
|
/// `resolved_at` is recorded as `last_modified` on the merged payload and all
|
||||||
|
/// sub-structs. Pass an explicit timestamp when the caller needs deterministic
|
||||||
|
/// output (e.g. server handlers); use [`merge`] as a convenience wrapper that
|
||||||
|
/// captures the current time automatically.
|
||||||
|
///
|
||||||
/// The merge strategy is additive and conflict-free for most fields:
|
/// The merge strategy is additive and conflict-free for most fields:
|
||||||
/// - Counters: take the maximum (games_played, games_won, etc.)
|
/// - Counters: take the maximum (games_played, games_won, etc.)
|
||||||
/// - Best records: take the minimum for times, maximum for scores/xp
|
/// - Best records: take the minimum for times, maximum for scores/xp
|
||||||
@@ -20,6 +25,38 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
|||||||
/// Fields that cannot be merged deterministically (e.g. diverged streak
|
/// Fields that cannot be merged deterministically (e.g. diverged streak
|
||||||
/// counts) are recorded in [`ConflictReport`] entries returned alongside
|
/// counts) are recorded in [`ConflictReport`] entries returned alongside
|
||||||
/// the merged payload. Data is never silently discarded.
|
/// the merged payload. Data is never silently discarded.
|
||||||
|
pub fn merge_at(
|
||||||
|
local: &SyncPayload,
|
||||||
|
remote: &SyncPayload,
|
||||||
|
resolved_at: DateTime<Utc>,
|
||||||
|
) -> (SyncPayload, Vec<ConflictReport>) {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
if local.user_id != remote.user_id {
|
||||||
|
conflicts.push(ConflictReport {
|
||||||
|
field: "user_id".to_string(),
|
||||||
|
local_value: local.user_id.to_string(),
|
||||||
|
remote_value: remote.user_id.to_string(),
|
||||||
|
});
|
||||||
|
return (local.clone(), conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = merge_stats(&local.stats, &remote.stats, resolved_at, &mut conflicts);
|
||||||
|
let achievements = merge_achievements(&local.achievements, &remote.achievements);
|
||||||
|
let progress = merge_progress(&local.progress, &remote.progress, resolved_at, &mut conflicts);
|
||||||
|
|
||||||
|
let merged = SyncPayload {
|
||||||
|
user_id: local.user_id,
|
||||||
|
stats,
|
||||||
|
achievements,
|
||||||
|
progress,
|
||||||
|
last_modified: resolved_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
(merged, conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper around [`merge_at`] that captures the current UTC time.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```
|
/// ```
|
||||||
@@ -45,21 +82,7 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
|||||||
/// assert!(conflicts.is_empty());
|
/// assert!(conflicts.is_empty());
|
||||||
/// ```
|
/// ```
|
||||||
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
|
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
|
||||||
let mut conflicts = Vec::new();
|
merge_at(local, remote, Utc::now())
|
||||||
|
|
||||||
let stats = merge_stats(&local.stats, &remote.stats, &mut conflicts);
|
|
||||||
let achievements = merge_achievements(&local.achievements, &remote.achievements);
|
|
||||||
let progress = merge_progress(&local.progress, &remote.progress, &mut conflicts);
|
|
||||||
|
|
||||||
let merged = SyncPayload {
|
|
||||||
user_id: local.user_id,
|
|
||||||
stats,
|
|
||||||
achievements,
|
|
||||||
progress,
|
|
||||||
last_modified: Utc::now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(merged, conflicts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -69,6 +92,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<Con
|
|||||||
fn merge_stats(
|
fn merge_stats(
|
||||||
local: &StatsSnapshot,
|
local: &StatsSnapshot,
|
||||||
remote: &StatsSnapshot,
|
remote: &StatsSnapshot,
|
||||||
|
resolved_at: DateTime<Utc>,
|
||||||
conflicts: &mut Vec<ConflictReport>,
|
conflicts: &mut Vec<ConflictReport>,
|
||||||
) -> StatsSnapshot {
|
) -> StatsSnapshot {
|
||||||
// win_streak_current cannot be merged deterministically — record conflict
|
// win_streak_current cannot be merged deterministically — record conflict
|
||||||
@@ -128,7 +152,7 @@ fn merge_stats(
|
|||||||
local.challenge_fastest_win_seconds,
|
local.challenge_fastest_win_seconds,
|
||||||
remote.challenge_fastest_win_seconds,
|
remote.challenge_fastest_win_seconds,
|
||||||
),
|
),
|
||||||
last_modified: Utc::now(),
|
last_modified: resolved_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +237,7 @@ fn merge_achievements(
|
|||||||
fn merge_progress(
|
fn merge_progress(
|
||||||
local: &PlayerProgress,
|
local: &PlayerProgress,
|
||||||
remote: &PlayerProgress,
|
remote: &PlayerProgress,
|
||||||
|
resolved_at: DateTime<Utc>,
|
||||||
conflicts: &mut Vec<ConflictReport>,
|
conflicts: &mut Vec<ConflictReport>,
|
||||||
) -> PlayerProgress {
|
) -> PlayerProgress {
|
||||||
// daily_challenge_streak cannot be merged deterministically.
|
// daily_challenge_streak cannot be merged deterministically.
|
||||||
@@ -303,7 +328,7 @@ fn merge_progress(
|
|||||||
challenge_index,
|
challenge_index,
|
||||||
daily_challenge_history,
|
daily_challenge_history,
|
||||||
daily_challenge_longest_streak,
|
daily_challenge_longest_streak,
|
||||||
last_modified: Utc::now(),
|
last_modified: resolved_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ impl ReplayPlayer {
|
|||||||
let replay: Replay =
|
let replay: Replay =
|
||||||
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
||||||
let game =
|
let game =
|
||||||
GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
game,
|
game,
|
||||||
moves: replay.moves,
|
moves: replay.moves,
|
||||||
@@ -193,16 +193,24 @@ impl ReplayPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||||
pub fn state(&self) -> JsValue {
|
///
|
||||||
serde_wasm_bindgen::to_value(&self.snapshot()).unwrap_or(JsValue::NULL)
|
/// Throws a JS string exception on serialisation failure (should never
|
||||||
|
/// occur in practice — `StateSnapshot` contains only primitive types).
|
||||||
|
pub fn state(&self) -> Result<JsValue, JsValue> {
|
||||||
|
serde_wasm_bindgen::to_value(&self.snapshot())
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the next move; returns the post-step snapshot, or `null`
|
/// Apply the next move; returns the post-step snapshot, or `null`
|
||||||
/// once the move list is exhausted.
|
/// once the move list is exhausted.
|
||||||
pub fn step(&mut self) -> JsValue {
|
///
|
||||||
|
/// Returns `null` (not an exception) when the replay is finished.
|
||||||
|
/// Throws a JS string exception on serialisation failure.
|
||||||
|
pub fn step(&mut self) -> Result<JsValue, JsValue> {
|
||||||
match self.step_native() {
|
match self.step_native() {
|
||||||
Some(snap) => serde_wasm_bindgen::to_value(&snap).unwrap_or(JsValue::NULL),
|
Some(snap) => serde_wasm_bindgen::to_value(&snap)
|
||||||
None => JsValue::NULL,
|
.map_err(|e| JsValue::from_str(&e.to_string())),
|
||||||
|
None => Ok(JsValue::NULL),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,8 +372,11 @@ impl SolitaireGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Full pile snapshot as a JS object.
|
/// Full pile snapshot as a JS object.
|
||||||
pub fn state(&self) -> JsValue {
|
///
|
||||||
serde_wasm_bindgen::to_value(&self.snap()).unwrap_or(JsValue::NULL)
|
/// Throws a JS string exception on serialisation failure.
|
||||||
|
pub fn state(&self) -> Result<JsValue, JsValue> {
|
||||||
|
serde_wasm_bindgen::to_value(&self.snap())
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The seed used to deal this game.
|
/// The seed used to deal this game.
|
||||||
|
|||||||
Reference in New Issue
Block a user