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:
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! # Current scope (integration steps 1–4)
|
||||
//!
|
||||
//! [`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()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user