fix(ux): 14 cross-platform UX/UI fixes from 500-game audit

Web client (game.js):
- Restart game timer after undo exits auto-complete sequence
- Pause timer while browser tab is hidden (visibilitychange)
- Validate URL seed — NaN / negative falls back to randomSeed()
- Guard onBoardClick/onBoardDblClick during win (snap.is_won)
- Delay win overlay 320 ms so last card CSS transition finishes
- Force reflow in flashIllegal() to restart shake on rapid re-trigger

Android (safe_area.rs):
- Preserve last-known insets on app resume instead of zeroing them;
  eliminates double layout flash on every foreground cycle

All clients — Bevy engine:
- Radial menu: clamp icon anchors to viewport bounds so icons are
  never placed off-screen on narrow phones
- Auto-complete: deactivate state.active when is_auto_completable
  goes false (undo mid-sequence) to stop perpetual background retry
- Touch selection: gate highlight rebuild on is_changed() — was
  despawning/respawning entities every frame unnecessarily
- Input: fire "Tap a pile to move" InfoToast on first tap in
  TapToSelect mode; document cursor_world 1:1 viewport invariant
- Drag threshold: raise desktop from 4 → 6 px to prevent accidental
  drags from cursor jitter on HiDPI displays

Desktop / Android (solitaire_app):
- Call cleanup_orphaned_tmp_files() at startup to remove .tmp files
  left by crashes between atomic write and rename

Design clarification (klondike_adapter.rs):
- Doc comment: Draw-1 recycling is penalty-only by design (never
  blocked) to avoid creating unwinnable positions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 21:23:52 -07:00
parent 20e5222148
commit 64f975ed6d
9 changed files with 571 additions and 216 deletions
+113 -110
View File
@@ -2,10 +2,10 @@
//!
//! # Current scope (integration steps 14)
//!
//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes
//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard
//! values). [`GameState`] delegates scoring here so that klondike remains the
//! single source of truth for scoring constants.
//! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults
//!
//! # Not yet implemented
//!
@@ -25,38 +25,16 @@ use crate::game_state::{DrawMode, GameMode};
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes
/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values).
/// [`GameState`] uses this instead of calling `scoring.rs` functions directly.
#[derive(Clone, Debug)]
pub struct KlondikeAdapter {
config: KlondikeConfig,
}
impl PartialEq for KlondikeAdapter {
fn eq(&self, other: &Self) -> bool {
self.config.draw_stock == other.config.draw_stock
&& self.config.move_from_foundation == other.config.move_from_foundation
}
}
impl Eq for KlondikeAdapter {}
impl Default for KlondikeAdapter {
/// Returns an adapter with Draw-1 and `take_from_foundation = true`,
/// matching `GameState`'s own defaults. Used by `#[serde(skip)]`
/// field initialisation on deserialisation.
fn default() -> Self {
Self::new(DrawMode::DrawOne, true)
}
}
/// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and
/// scoring helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter;
impl KlondikeAdapter {
/// Create an adapter from the game's draw mode and foundation house-rule setting.
///
/// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`];
/// `false` maps to [`MoveFromFoundationConfig::Disallowed`].
pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self {
let config = KlondikeConfig {
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
KlondikeConfig {
draw_stock: match draw_mode {
DrawMode::DrawOne => DrawStockConfig::DrawOne,
DrawMode::DrawThree => DrawStockConfig::DrawThree,
@@ -67,24 +45,7 @@ impl KlondikeAdapter {
MoveFromFoundationConfig::Disallowed
},
scoring: ScoringConfig::DEFAULT,
};
Self { config }
}
/// Returns a reference to the underlying [`KlondikeConfig`].
///
/// Used by the solver and pile-mapping code added in later integration steps.
pub fn klondike_config(&self) -> &KlondikeConfig {
&self.config
}
/// Update the foundation house-rule flag, keeping [`KlondikeConfig`] in sync.
pub fn set_take_from_foundation(&mut self, allowed: bool) {
self.config.move_from_foundation = if allowed {
MoveFromFoundationConfig::Allowed
} else {
MoveFromFoundationConfig::Disallowed
};
}
}
// ── Scoring helpers ───────────────────────────────────────────────────
@@ -96,8 +57,8 @@ impl KlondikeAdapter {
/// - Waste → Tableau: +5
/// - Foundation → Tableau: 15
/// - All other moves: 0
pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = &self.config.scoring;
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = ScoringConfig::DEFAULT;
match (from, to) {
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
@@ -107,8 +68,8 @@ impl KlondikeAdapter {
}
/// Score delta for exposing a face-down tableau card: +5.
pub fn score_for_flip(&self) -> i32 {
self.config.scoring.flip_up_bonus
pub fn score_for_flip() -> i32 {
ScoringConfig::DEFAULT.flip_up_bonus
}
/// Score delta for undo: 15.
@@ -131,6 +92,12 @@ impl KlondikeAdapter {
/// | Draw-1 | 1 | 100 |
/// | Draw-3 | 3 | 20 |
///
/// **Design note:** recycling is *never* blocked — only penalised.
/// This is intentional: Draw-1 can be played indefinitely with the score
/// dropping toward zero after the first free recycle. A hard cap would
/// create unwinnable positions when the solver cannot find a path without
/// additional recycling. Zen mode suppresses the penalty entirely.
///
/// `recycle_count` must be the new total **after** this recycle.
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
if is_draw_three {
@@ -145,20 +112,23 @@ impl KlondikeAdapter {
/// Score delta for a card move, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
pub fn score_for_move_with_mode(
&self,
from: &KlondikePile,
to: &KlondikePile,
mode: GameMode,
) -> i32 {
if mode == GameMode::Zen { 0 } else { self.score_for_move(from, to) }
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_move(from, to)
}
}
/// Score delta for exposing a face-down card, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_flip_with_mode(&self, mode: GameMode) -> i32 {
if mode == GameMode::Zen { 0 } else { self.score_for_flip() }
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_flip()
}
}
/// Compute the new score after an undo, accounting for game mode.
@@ -191,13 +161,58 @@ impl KlondikeAdapter {
// ── Type-conversion utilities ─────────────────────────────────────────────
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
match index {
0 => Some(Tableau::Tableau1),
1 => Some(Tableau::Tableau2),
2 => Some(Tableau::Tableau3),
3 => Some(Tableau::Tableau4),
4 => Some(Tableau::Tableau5),
5 => Some(Tableau::Tableau6),
6 => Some(Tableau::Tableau7),
_ => None,
}
}
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
match skip {
0 => Some(SkipCards::Skip0),
1 => Some(SkipCards::Skip1),
2 => Some(SkipCards::Skip2),
3 => Some(SkipCards::Skip3),
4 => Some(SkipCards::Skip4),
5 => Some(SkipCards::Skip5),
6 => Some(SkipCards::Skip6),
7 => Some(SkipCards::Skip7),
8 => Some(SkipCards::Skip8),
9 => Some(SkipCards::Skip9),
10 => Some(SkipCards::Skip10),
11 => Some(SkipCards::Skip11),
12 => Some(SkipCards::Skip12),
_ => None,
}
}
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
match suit {
KlSuit::Clubs => crate::card::Suit::Clubs,
KlSuit::Clubs => crate::card::Suit::Clubs,
KlSuit::Diamonds => crate::card::Suit::Diamonds,
KlSuit::Hearts => crate::card::Suit::Hearts,
KlSuit::Spades => crate::card::Suit::Spades,
KlSuit::Hearts => crate::card::Suit::Hearts,
KlSuit::Spades => crate::card::Suit::Spades,
}
}
@@ -221,7 +236,12 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
.position(|s| *s == suit)
.expect("suit always in SUITS") as u32;
let id = suit_index * 13 + (rank.value() as u32 - 1);
crate::card::Card { id, suit, rank, face_up: false }
crate::card::Card {
id,
suit,
rank,
face_up: false,
}
}
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
@@ -343,7 +363,10 @@ impl From<KlondikePile> for SavedKlondikePile {
impl From<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into() }
Self {
tableau: ts.tableau.into(),
skip_cards: ts.skip_cards.into(),
}
}
}
@@ -359,13 +382,19 @@ impl From<KlondikePileStack> for SavedKlondikePileStack {
impl From<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self { src: df.src.into(), foundation: df.foundation.into() }
Self {
src: df.src.into(),
foundation: df.foundation.into(),
}
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self { src: dt.src.into(), tableau: dt.tableau.into() }
Self {
src: dt.src.into(),
tableau: dt.tableau.into(),
}
}
}
@@ -384,51 +413,21 @@ impl From<KlondikeInstruction> for SavedInstruction {
impl TryFrom<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Tableau::Tableau1),
1 => Ok(Tableau::Tableau2),
2 => Ok(Tableau::Tableau3),
3 => Ok(Tableau::Tableau4),
4 => Ok(Tableau::Tableau5),
5 => Ok(Tableau::Tableau6),
6 => Ok(Tableau::Tableau7),
n => Err(InvalidSavedInstruction::Tableau(n)),
}
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Foundation::Foundation1),
1 => Ok(Foundation::Foundation2),
2 => Ok(Foundation::Foundation3),
3 => Ok(Foundation::Foundation4),
n => Err(InvalidSavedInstruction::Foundation(n)),
}
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(SkipCards::Skip0),
1 => Ok(SkipCards::Skip1),
2 => Ok(SkipCards::Skip2),
3 => Ok(SkipCards::Skip3),
4 => Ok(SkipCards::Skip4),
5 => Ok(SkipCards::Skip5),
6 => Ok(SkipCards::Skip6),
7 => Ok(SkipCards::Skip7),
8 => Ok(SkipCards::Skip8),
9 => Ok(SkipCards::Skip9),
10 => Ok(SkipCards::Skip10),
11 => Ok(SkipCards::Skip11),
12 => Ok(SkipCards::Skip12),
n => Err(InvalidSavedInstruction::SkipCards(n)),
}
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
}
}
@@ -459,9 +458,7 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
Ok(match s {
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
SavedKlondikePileStack::Foundation(f) => {
KlondikePileStack::Foundation(f.try_into()?)
}
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
})
}
}
@@ -469,14 +466,20 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
impl TryFrom<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()? })
Ok(DstFoundation {
src: s.src.try_into()?,
foundation: s.foundation.try_into()?,
})
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()? })
Ok(DstTableau {
src: s.src.try_into()?,
tableau: s.tableau.try_into()?,
})
}
}