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:
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
|||||||
use bevy::winit::WinitWindows;
|
use bevy::winit::WinitWindows;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use bevy::winit::{UpdateMode, WinitSettings};
|
use bevy::winit::{UpdateMode, WinitSettings};
|
||||||
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
|
use solitaire_data::{
|
||||||
|
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
|
||||||
|
settings_file_path,
|
||||||
|
};
|
||||||
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
||||||
|
|
||||||
fn load_settings() -> Settings {
|
fn load_settings() -> Settings {
|
||||||
@@ -49,6 +52,12 @@ pub fn run() {
|
|||||||
// and any debugger attached still sees the panic).
|
// and any debugger attached still sees the panic).
|
||||||
install_crash_log_hook();
|
install_crash_log_hook();
|
||||||
|
|
||||||
|
// Remove any *.tmp files left behind by a crash between an atomic write
|
||||||
|
// and its rename. Safe to call unconditionally — missing data dir is a
|
||||||
|
// no-op. Must run before GamePlugin loads saved state so orphaned files
|
||||||
|
// don't accumulate across launches.
|
||||||
|
let _ = cleanup_orphaned_tmp_files();
|
||||||
|
|
||||||
// Initialise the platform keyring store before any token operations.
|
// Initialise the platform keyring store before any token operations.
|
||||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! # Current scope (integration steps 1–4)
|
//! # Current scope (integration steps 1–4)
|
||||||
//!
|
//!
|
||||||
//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes
|
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||||
//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard
|
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||||
//! values). [`GameState`] delegates scoring here so that klondike remains the
|
//! - translating between local and upstream types
|
||||||
//! single source of truth for scoring constants.
|
//! - applying Ferrous-specific scoring policy on top of upstream defaults
|
||||||
//!
|
//!
|
||||||
//! # Not yet implemented
|
//! # 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.
|
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||||
///
|
///
|
||||||
/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes
|
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||||
/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values).
|
/// state, and exists only as a namespace for configuration, conversion, and
|
||||||
/// [`GameState`] uses this instead of calling `scoring.rs` functions directly.
|
/// scoring helpers.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct KlondikeAdapter {
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KlondikeAdapter {
|
impl KlondikeAdapter {
|
||||||
/// Create an adapter from the game's draw mode and foundation house-rule setting.
|
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||||
///
|
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
||||||
/// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`];
|
KlondikeConfig {
|
||||||
/// `false` maps to [`MoveFromFoundationConfig::Disallowed`].
|
|
||||||
pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self {
|
|
||||||
let config = KlondikeConfig {
|
|
||||||
draw_stock: match draw_mode {
|
draw_stock: match draw_mode {
|
||||||
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
||||||
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
||||||
@@ -67,24 +45,7 @@ impl KlondikeAdapter {
|
|||||||
MoveFromFoundationConfig::Disallowed
|
MoveFromFoundationConfig::Disallowed
|
||||||
},
|
},
|
||||||
scoring: ScoringConfig::DEFAULT,
|
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 ───────────────────────────────────────────────────
|
// ── Scoring helpers ───────────────────────────────────────────────────
|
||||||
@@ -96,8 +57,8 @@ impl KlondikeAdapter {
|
|||||||
/// - Waste → Tableau: +5
|
/// - Waste → Tableau: +5
|
||||||
/// - Foundation → Tableau: −15
|
/// - Foundation → Tableau: −15
|
||||||
/// - All other moves: 0
|
/// - All other moves: 0
|
||||||
pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 {
|
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
||||||
let sc = &self.config.scoring;
|
let sc = ScoringConfig::DEFAULT;
|
||||||
match (from, to) {
|
match (from, to) {
|
||||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
||||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
||||||
@@ -107,8 +68,8 @@ impl KlondikeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Score delta for exposing a face-down tableau card: +5.
|
/// Score delta for exposing a face-down tableau card: +5.
|
||||||
pub fn score_for_flip(&self) -> i32 {
|
pub fn score_for_flip() -> i32 {
|
||||||
self.config.scoring.flip_up_bonus
|
ScoringConfig::DEFAULT.flip_up_bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Score delta for undo: −15.
|
/// Score delta for undo: −15.
|
||||||
@@ -131,6 +92,12 @@ impl KlondikeAdapter {
|
|||||||
/// | Draw-1 | 1 | −100 |
|
/// | Draw-1 | 1 | −100 |
|
||||||
/// | Draw-3 | 3 | −20 |
|
/// | 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.
|
/// `recycle_count` must be the new total **after** this recycle.
|
||||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||||
if is_draw_three {
|
if is_draw_three {
|
||||||
@@ -145,20 +112,23 @@ impl KlondikeAdapter {
|
|||||||
/// Score delta for a card move, accounting for game mode.
|
/// Score delta for a card move, accounting for game mode.
|
||||||
///
|
///
|
||||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
||||||
pub fn score_for_move_with_mode(
|
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
||||||
&self,
|
if mode == GameMode::Zen {
|
||||||
from: &KlondikePile,
|
0
|
||||||
to: &KlondikePile,
|
} else {
|
||||||
mode: GameMode,
|
Self::score_for_move(from, to)
|
||||||
) -> 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.
|
/// Score delta for exposing a face-down card, accounting for game mode.
|
||||||
///
|
///
|
||||||
/// Returns 0 in [`GameMode::Zen`].
|
/// Returns 0 in [`GameMode::Zen`].
|
||||||
pub fn score_for_flip_with_mode(&self, mode: GameMode) -> i32 {
|
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
||||||
if mode == GameMode::Zen { 0 } else { self.score_for_flip() }
|
if mode == GameMode::Zen {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
Self::score_for_flip()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the new score after an undo, accounting for game mode.
|
/// Compute the new score after an undo, accounting for game mode.
|
||||||
@@ -191,13 +161,58 @@ impl KlondikeAdapter {
|
|||||||
|
|
||||||
// ── Type-conversion utilities ─────────────────────────────────────────────
|
// ── 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`].
|
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
|
||||||
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
|
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
|
||||||
match suit {
|
match suit {
|
||||||
KlSuit::Clubs => crate::card::Suit::Clubs,
|
KlSuit::Clubs => crate::card::Suit::Clubs,
|
||||||
KlSuit::Diamonds => crate::card::Suit::Diamonds,
|
KlSuit::Diamonds => crate::card::Suit::Diamonds,
|
||||||
KlSuit::Hearts => crate::card::Suit::Hearts,
|
KlSuit::Hearts => crate::card::Suit::Hearts,
|
||||||
KlSuit::Spades => crate::card::Suit::Spades,
|
KlSuit::Spades => crate::card::Suit::Spades,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,12 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
|
|||||||
.position(|s| *s == suit)
|
.position(|s| *s == suit)
|
||||||
.expect("suit always in SUITS") as u32;
|
.expect("suit always in SUITS") as u32;
|
||||||
let id = suit_index * 13 + (rank.value() as u32 - 1);
|
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) ──────────────────────────
|
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
|
||||||
@@ -343,7 +363,10 @@ impl From<KlondikePile> for SavedKlondikePile {
|
|||||||
|
|
||||||
impl From<TableauStack> for SavedTableauStack {
|
impl From<TableauStack> for SavedTableauStack {
|
||||||
fn from(ts: TableauStack) -> Self {
|
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 {
|
impl From<DstFoundation> for SavedDstFoundation {
|
||||||
fn from(df: DstFoundation) -> Self {
|
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 {
|
impl From<DstTableau> for SavedDstTableau {
|
||||||
fn from(dt: DstTableau) -> Self {
|
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 {
|
impl TryFrom<SavedTableau> for Tableau {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
||||||
match s.0 {
|
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(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)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<SavedFoundation> for Foundation {
|
impl TryFrom<SavedFoundation> for Foundation {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
||||||
match s.0 {
|
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
||||||
0 => Ok(Foundation::Foundation1),
|
|
||||||
1 => Ok(Foundation::Foundation2),
|
|
||||||
2 => Ok(Foundation::Foundation3),
|
|
||||||
3 => Ok(Foundation::Foundation4),
|
|
||||||
n => Err(InvalidSavedInstruction::Foundation(n)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<SavedSkipCards> for SkipCards {
|
impl TryFrom<SavedSkipCards> for SkipCards {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
||||||
match s.0 {
|
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(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)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,9 +458,7 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
|||||||
Ok(match s {
|
Ok(match s {
|
||||||
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
||||||
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
||||||
SavedKlondikePileStack::Foundation(f) => {
|
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
|
||||||
KlondikePileStack::Foundation(f.try_into()?)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,14 +466,20 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
|||||||
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
|
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 {
|
impl TryFrom<SavedDstTableau> for DstTableau {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
|
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()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
detect_auto_complete,
|
detect_auto_complete,
|
||||||
on_auto_complete_start,
|
on_auto_complete_start,
|
||||||
drive_auto_complete,
|
drive_auto_complete,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,14 +83,21 @@ fn detect_auto_complete(
|
|||||||
if game.0.is_auto_completable && !state.active {
|
if game.0.is_auto_completable && !state.active {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
||||||
|
} else if !game.0.is_auto_completable && state.active {
|
||||||
|
// `is_auto_completable` only becomes false after an explicit undo
|
||||||
|
// (which puts a card back on the tableau or re-fills the stock/waste)
|
||||||
|
// or a new-game reset — never as a transient gap during a normal
|
||||||
|
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
||||||
|
// does not keep retrying indefinitely after the player undoes out of
|
||||||
|
// the sequence.
|
||||||
|
//
|
||||||
|
// Note: the transient-`None` case mentioned in older versions of this
|
||||||
|
// comment referred to `next_auto_complete_move()` returning `None`, not
|
||||||
|
// to `is_auto_completable` being false. Those are independent fields;
|
||||||
|
// `drive_auto_complete` still retries on a transient `None` return from
|
||||||
|
// `next_auto_complete_move` because that check happens there, not here.
|
||||||
|
state.active = false;
|
||||||
}
|
}
|
||||||
// Intentionally no `else if !is_auto_completable` branch here.
|
|
||||||
// Deactivating on every frame where `is_auto_completable` is false
|
|
||||||
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
|
||||||
// transiently returns `None` (e.g. while the previous move is still
|
|
||||||
// in-flight). The `is_won` check above already handles the definitive
|
|
||||||
// end-of-game case; `drive_auto_complete` simply retries next tick
|
|
||||||
// when no move is available yet.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays a distinct chime the moment auto-complete first activates.
|
/// Plays a distinct chime the moment auto-complete first activates.
|
||||||
@@ -244,9 +251,7 @@ mod tests {
|
|||||||
|
|
||||||
// Zero out the cooldown so drive fires on the next update regardless
|
// Zero out the cooldown so drive fires on the next update regardless
|
||||||
// of the initial delay constant.
|
// of the initial delay constant.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
||||||
.resource_mut::<AutoCompleteState>()
|
|
||||||
.cooldown = 0.0;
|
|
||||||
app.update(); // drive fires the move
|
app.update(); // drive fires the move
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
|||||||
platform: InputPlatform::Mouse,
|
platform: InputPlatform::Mouse,
|
||||||
duration_scale: 1.0,
|
duration_scale: 1.0,
|
||||||
overshoot_scale: 1.0,
|
overshoot_scale: 1.0,
|
||||||
drag_threshold_px: 4.0,
|
drag_threshold_px: 6.0,
|
||||||
drag_scale: 1.08,
|
drag_scale: 1.08,
|
||||||
hover_scale: 1.04,
|
hover_scale: 1.04,
|
||||||
hover_lerp_speed: 14.0,
|
hover_lerp_speed: 14.0,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
|||||||
use bevy::math::{Vec2, Vec3};
|
use bevy::math::{Vec2, Vec3};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use klondike::{Foundation, KlondikePile, Tableau};
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
|
use klondike::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::card::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -789,8 +789,9 @@ fn end_drag(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, transform)) =
|
if let Some((entity, _, transform)) = card_entities
|
||||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
.iter()
|
||||||
|
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
let drag_pos = transform.translation.truncate();
|
let drag_pos = transform.translation.truncate();
|
||||||
let drag_z = transform.translation.z;
|
let drag_z = transform.translation.z;
|
||||||
@@ -1027,8 +1028,9 @@ fn touch_end_drag(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, transform)) =
|
if let Some((entity, _, transform)) = card_entities
|
||||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
.iter()
|
||||||
|
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
let drag_pos = transform.translation.truncate();
|
let drag_pos = transform.translation.truncate();
|
||||||
let drag_z = transform.translation.z;
|
let drag_z = transform.translation.z;
|
||||||
@@ -1060,6 +1062,13 @@ fn touch_end_drag(
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Converts the mouse cursor position to world-space 2-D coordinates.
|
||||||
|
///
|
||||||
|
/// **Invariant:** assumes a single un-zoomed 2-D camera whose viewport exactly
|
||||||
|
/// covers the primary window (centre at world origin, 1 logical pixel = 1 world
|
||||||
|
/// unit). Hit-testing in `find_draggable_at` / `find_drop_target` relies on
|
||||||
|
/// this 1:1 mapping. Do not add camera zoom or offset this without auditing
|
||||||
|
/// every call site of `cursor_world` and `touch_to_world`.
|
||||||
fn cursor_world(
|
fn cursor_world(
|
||||||
windows: &Query<&Window, With<PrimaryWindow>>,
|
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
@@ -1073,6 +1082,9 @@ fn cursor_world(
|
|||||||
/// Converts a touch screen position (logical pixels, top-left origin) to
|
/// Converts a touch screen position (logical pixels, top-left origin) to
|
||||||
/// world-space 2-D coordinates using the primary camera.
|
/// world-space 2-D coordinates using the primary camera.
|
||||||
///
|
///
|
||||||
|
/// Shares the same 1:1 viewport invariant as [`cursor_world`] — see that
|
||||||
|
/// function's doc for the constraints.
|
||||||
|
///
|
||||||
/// Returns `None` if no camera is present or the projection fails.
|
/// Returns `None` if no camera is present or the projection fails.
|
||||||
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
|
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
|
||||||
let (camera, camera_transform) = cameras.single().ok()?;
|
let (camera, camera_transform) = cameras.single().ok()?;
|
||||||
@@ -1097,7 +1109,12 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
|||||||
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
|
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
|
||||||
/// exactly; any drift creates an offset between the visible card face and
|
/// exactly; any drift creates an offset between the visible card face and
|
||||||
/// where clicks land.
|
/// where clicks land.
|
||||||
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
|
fn card_position(
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
pile: &KlondikePile,
|
||||||
|
stack_index: usize,
|
||||||
|
) -> Vec2 {
|
||||||
let base = layout.pile_positions[pile];
|
let base = layout.pile_positions[pile];
|
||||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||||
let mut y_offset = 0.0_f32;
|
let mut y_offset = 0.0_f32;
|
||||||
@@ -1436,6 +1453,7 @@ fn handle_double_tap(
|
|||||||
mut touch_selection: Option<ResMut<TouchSelectionState>>,
|
mut touch_selection: Option<ResMut<TouchSelectionState>>,
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
) {
|
) {
|
||||||
@@ -1509,8 +1527,9 @@ fn handle_double_tap(
|
|||||||
sel.clear();
|
sel.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// First tap: select the source.
|
// First tap: select the source, then nudge the player.
|
||||||
sel.set(*tapped_pile, drag.cards.clone());
|
sel.set(*tapped_pile, drag.cards.clone());
|
||||||
|
toast.write(InfoToastEvent("Tap a pile to move".into()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1540,8 +1559,12 @@ fn handle_double_tap(
|
|||||||
if drag.cards.len() > 1 {
|
if drag.cards.len() > 1 {
|
||||||
let stack_index = pile_cards.len() - drag.cards.len();
|
let stack_index = pile_cards.len() - drag.cards.len();
|
||||||
if let Some(bottom_card) = pile_cards.get(stack_index)
|
if let Some(bottom_card) = pile_cards.get(stack_index)
|
||||||
&& let Some((dest, count)) =
|
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||||
best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len())
|
bottom_card,
|
||||||
|
tapped_pile,
|
||||||
|
&game.0,
|
||||||
|
drag.cards.len(),
|
||||||
|
)
|
||||||
{
|
{
|
||||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||||
if drag.cards.contains(&ce.card_id) {
|
if drag.cards.contains(&ce.card_id) {
|
||||||
@@ -1573,9 +1596,7 @@ fn handle_double_tap(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Build the complete list of legal moves available in `game`, ordered so that
|
/// Build the complete list of legal moves available in `game`, ordered so that
|
||||||
/// foundation moves come first, then tableau-to-tableau moves, with "draw from
|
/// upstream `klondike` priorities are preserved.
|
||||||
/// stock" appended last when the stock is non-empty and nothing else is
|
|
||||||
/// available.
|
|
||||||
///
|
///
|
||||||
/// Each entry is `(from, to, count)` — the same triple used by
|
/// Each entry is `(from, to, count)` — the same triple used by
|
||||||
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
|
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
|
||||||
@@ -1584,6 +1605,23 @@ fn handle_double_tap(
|
|||||||
/// This is the backing data for the cycling hint system: the H key steps
|
/// This is the backing data for the cycling hint system: the H key steps
|
||||||
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
||||||
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
||||||
|
if game.has_test_pile_overrides() {
|
||||||
|
return legacy_all_hints(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
game.possible_instructions()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, _, count)| *count == 1)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy hint enumeration used only when test pile overrides are active.
|
||||||
|
///
|
||||||
|
/// `possible_instructions()` reflects the internal upstream `Session` state.
|
||||||
|
/// In test fixtures that inject synthetic piles via `set_test_*`, these
|
||||||
|
/// synthetic piles can diverge from the session state; this fallback preserves
|
||||||
|
/// deterministic test semantics in those fixtures.
|
||||||
|
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
||||||
let sources: Vec<KlondikePile> = {
|
let sources: Vec<KlondikePile> = {
|
||||||
let mut s = vec![KlondikePile::Stock];
|
let mut s = vec![KlondikePile::Stock];
|
||||||
for tableau in tableaus() {
|
for tableau in tableaus() {
|
||||||
@@ -1818,7 +1856,8 @@ mod tests {
|
|||||||
// face-up card, but the iterator should skip face-down cards and
|
// face-up card, but the iterator should skip face-down cards and
|
||||||
// the cursor sits above the face-up card's AABB, so the result
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
// is None.
|
// is None.
|
||||||
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
let face_down_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||||
}
|
}
|
||||||
@@ -1836,7 +1875,8 @@ mod tests {
|
|||||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||||
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
let face_up_pos =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
@@ -1878,7 +1918,8 @@ mod tests {
|
|||||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
let queen_center =
|
||||||
|
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
@@ -1923,7 +1964,12 @@ mod tests {
|
|||||||
let mut game = game;
|
let mut game = game;
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau7));
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
|
);
|
||||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1932,7 +1978,12 @@ mod tests {
|
|||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||||
let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau4));
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
|
);
|
||||||
assert_eq!(target, None);
|
assert_eq!(target, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2012,7 +2063,10 @@ mod tests {
|
|||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
for pile in [KlondikePile::Stock, KlondikePile::Foundation(Foundation::Foundation3)] {
|
for pile in [
|
||||||
|
KlondikePile::Stock,
|
||||||
|
KlondikePile::Foundation(Foundation::Foundation3),
|
||||||
|
] {
|
||||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||||
assert_eq!(size, layout.card_size);
|
assert_eq!(size, layout.card_size);
|
||||||
}
|
}
|
||||||
@@ -2022,7 +2076,7 @@ mod tests {
|
|||||||
// Task #27 — best_destination pure-function tests
|
// Task #27 — best_destination pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_destination_returns_none_when_no_legal_move() {
|
fn best_destination_returns_none_when_no_legal_move() {
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
@@ -2044,7 +2098,7 @@ mod tests {
|
|||||||
// best_tableau_destination_for_stack pure-function tests
|
// best_tableau_destination_for_stack pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
@@ -2070,8 +2124,12 @@ mod tests {
|
|||||||
rank: Rank::King,
|
rank: Rank::King,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
let result =
|
let result = best_tableau_destination_for_stack(
|
||||||
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
|
&bottom_card,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
// Result must be some other empty tableau column, never the source.
|
// Result must be some other empty tableau column, never the source.
|
||||||
if let Some((dest, _)) = result {
|
if let Some((dest, _)) = result {
|
||||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
@@ -2103,8 +2161,12 @@ mod tests {
|
|||||||
rank: Rank::Two,
|
rank: Rank::Two,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
let result =
|
let result = best_tableau_destination_for_stack(
|
||||||
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
|
&bottom_card,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_none(),
|
result.is_none(),
|
||||||
"Two of Clubs has no legal tableau destination on empty piles"
|
"Two of Clubs has no legal tableau destination on empty piles"
|
||||||
@@ -2140,7 +2202,7 @@ mod tests {
|
|||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -2176,11 +2238,11 @@ mod tests {
|
|||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||||
game.set_test_stock_cards(vec![Card {
|
game.set_test_stock_cards(vec![Card {
|
||||||
id: 1,
|
id: 1,
|
||||||
suit: Suit::Clubs,
|
suit: Suit::Clubs,
|
||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: false,
|
face_up: false,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
let hints = all_hints(&game);
|
let hints = all_hints(&game);
|
||||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||||
@@ -2192,7 +2254,7 @@ mod tests {
|
|||||||
|
|
||||||
/// `all_hints` must be empty when both stock and waste are empty and no
|
/// `all_hints` must be empty when both stock and waste are empty and no
|
||||||
/// pile-to-pile move exists — the game is truly stuck.
|
/// pile-to-pile move exists — the game is truly stuck.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
// `ShakeAnim` on the dragged cards. The audio cue
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
|||||||
@@ -329,7 +329,12 @@ pub fn find_top_face_up_card_at(
|
|||||||
/// Mirror of `input_plugin::card_position` — kept private to this
|
/// Mirror of `input_plugin::card_position` — kept private to this
|
||||||
/// module so the radial's hit-test geometry tracks renderer geometry
|
/// module so the radial's hit-test geometry tracks renderer geometry
|
||||||
/// without depending on `input_plugin` internals.
|
/// without depending on `input_plugin` internals.
|
||||||
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
|
fn card_position(
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
pile: &KlondikePile,
|
||||||
|
stack_index: usize,
|
||||||
|
) -> Vec2 {
|
||||||
let base = layout.pile_positions[pile];
|
let base = layout.pile_positions[pile];
|
||||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||||
let mut y_offset = 0.0_f32;
|
let mut y_offset = 0.0_f32;
|
||||||
@@ -376,16 +381,27 @@ const fn tableaus() -> [Tableau; 7] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||||
fn build_radial_destinations(centre: Vec2, dests: Vec<KlondikePile>) -> Vec<(KlondikePile, Vec2)> {
|
///
|
||||||
|
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||||
|
/// so that their edges stay within the viewport, preventing them from appearing
|
||||||
|
/// off-screen on small or narrow devices.
|
||||||
|
fn build_radial_destinations(
|
||||||
|
centre: Vec2,
|
||||||
|
dests: Vec<KlondikePile>,
|
||||||
|
half_extents: Vec2,
|
||||||
|
) -> Vec<(KlondikePile, Vec2)> {
|
||||||
let count = dests.len();
|
let count = dests.len();
|
||||||
|
let margin = RADIAL_ICON_SIZE_PX / 2.0;
|
||||||
dests
|
dests
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, d)| {
|
.map(|(i, d)| {
|
||||||
(
|
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||||
d,
|
let clamped = Vec2::new(
|
||||||
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
|
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||||
)
|
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||||
|
);
|
||||||
|
(d, clamped)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -472,7 +488,12 @@ fn radial_open_on_right_click(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let legal_destinations = build_radial_destinations(world, dests);
|
let half_extents = windows
|
||||||
|
.single()
|
||||||
|
.ok()
|
||||||
|
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
|
||||||
|
.unwrap_or(Vec2::splat(f32::MAX));
|
||||||
|
let legal_destinations = build_radial_destinations(world, dests, half_extents);
|
||||||
|
|
||||||
*state = RightClickRadialState::Active {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
@@ -498,6 +519,7 @@ fn radial_open_on_long_press(
|
|||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
touches: Option<Res<Touches>>,
|
touches: Option<Res<Touches>>,
|
||||||
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
@@ -540,7 +562,12 @@ fn radial_open_on_long_press(
|
|||||||
if dests.is_empty() {
|
if dests.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let legal_destinations = build_radial_destinations(world, dests);
|
let half_extents = windows
|
||||||
|
.single()
|
||||||
|
.ok()
|
||||||
|
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
|
||||||
|
.unwrap_or(Vec2::splat(f32::MAX));
|
||||||
|
let legal_destinations = build_radial_destinations(world, dests, half_extents);
|
||||||
*state = RightClickRadialState::Active {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -958,7 +985,8 @@ mod tests {
|
|||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
|
let dests =
|
||||||
|
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
|
||||||
// Ace can be placed on every empty foundation. We only need
|
// Ace can be placed on every empty foundation. We only need
|
||||||
// the count to be ≥ 1 and the source pile to be excluded.
|
// the count to be ≥ 1 and the source pile to be excluded.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -977,7 +1005,11 @@ mod tests {
|
|||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
let dests = legal_destinations_for_card(&card, &KlondikePile::Foundation(Foundation::Foundation1), &g);
|
let dests = legal_destinations_for_card(
|
||||||
|
&card,
|
||||||
|
&KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
|
&g,
|
||||||
|
);
|
||||||
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
|
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +1020,7 @@ mod tests {
|
|||||||
/// Pressing right-click on a face-up card with at least one legal
|
/// Pressing right-click on a face-up card with at least one legal
|
||||||
/// destination must transition the state to `Active` carrying the
|
/// destination must transition the state to `Active` carrying the
|
||||||
/// expected source / count / legal-destination set.
|
/// expected source / count / legal-destination set.
|
||||||
/// Releasing the right button while the cursor is over a destination
|
/// Releasing the right button while the cursor is over a destination
|
||||||
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||||
#[test]
|
#[test]
|
||||||
fn right_click_release_over_destination_fires_move_request() {
|
fn right_click_release_over_destination_fires_move_request() {
|
||||||
|
|||||||
@@ -253,24 +253,24 @@ mod android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the inset poller and clears cached insets on
|
/// Resets the inset poller on `AppLifecycle::WillResume` so that
|
||||||
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
/// `refresh_insets` re-queries JNI in the frames immediately after the app
|
||||||
/// frames immediately after the app returns to the foreground.
|
/// returns to the foreground.
|
||||||
///
|
///
|
||||||
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
|
||||||
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
/// Zeroing them would cause two layout recomputes on every resume:
|
||||||
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
/// once with zero insets (wrong position) and again when JNI resolves the
|
||||||
/// once `refresh_insets` resolves the real values a second synthetic
|
/// real values — visible as a flash. By preserving the last-known values
|
||||||
/// `WindowResized` fires and the layout converges to the correct position.
|
/// the layout remains stable; if JNI returns a different value (e.g. after
|
||||||
|
/// a rotation) the single update that fires when `SafeAreaInsets` actually
|
||||||
|
/// changes is enough.
|
||||||
pub(super) fn rearm_on_resumed(
|
pub(super) fn rearm_on_resumed(
|
||||||
mut lifecycle: MessageReader<AppLifecycle>,
|
mut lifecycle: MessageReader<AppLifecycle>,
|
||||||
mut poll: ResMut<SafeAreaPollTries>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
mut insets: ResMut<SafeAreaInsets>,
|
|
||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
*insets = SafeAreaInsets::default();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ impl TouchSelectionState {
|
|||||||
|
|
||||||
/// Marker component placed on the highlight sprite child of a selected source card.
|
/// Marker component placed on the highlight sprite child of a selected source card.
|
||||||
///
|
///
|
||||||
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
|
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever
|
||||||
/// stale highlights never linger after a game-state change.
|
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it
|
||||||
|
/// is a no-op every frame that the selection is stable.
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct TouchSelectionHighlight;
|
pub struct TouchSelectionHighlight;
|
||||||
|
|
||||||
@@ -91,16 +92,15 @@ pub struct TouchSelectionPlugin;
|
|||||||
|
|
||||||
impl Plugin for TouchSelectionPlugin {
|
impl Plugin for TouchSelectionPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<TouchSelectionState>()
|
app.init_resource::<TouchSelectionState>().add_systems(
|
||||||
.add_systems(
|
Update,
|
||||||
Update,
|
(
|
||||||
(
|
clear_touch_selection_on_state_change,
|
||||||
clear_touch_selection_on_state_change,
|
update_touch_selection_highlight,
|
||||||
update_touch_selection_highlight,
|
)
|
||||||
)
|
.chain()
|
||||||
.chain()
|
.after(GameMutation),
|
||||||
.after(GameMutation),
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +121,9 @@ pub(crate) fn clear_touch_selection_on_state_change(
|
|||||||
|
|
||||||
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
|
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
|
||||||
///
|
///
|
||||||
/// All existing `TouchSelectionHighlight` entities are despawned each frame and
|
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout
|
||||||
/// a new one is spawned on the top card of the selected pile (if any). This
|
/// actually changes — not every frame. Existing highlights are despawned first,
|
||||||
/// matches the pattern used by `selection_plugin::update_selection_highlight`.
|
/// then a fresh highlight is spawned on every card in the selected stack.
|
||||||
pub(crate) fn update_touch_selection_highlight(
|
pub(crate) fn update_touch_selection_highlight(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
selection: Res<TouchSelectionState>,
|
selection: Res<TouchSelectionState>,
|
||||||
@@ -131,6 +131,12 @@ pub(crate) fn update_touch_selection_highlight(
|
|||||||
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
) {
|
) {
|
||||||
|
// Skip when neither the selection nor the layout changed this frame.
|
||||||
|
let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false);
|
||||||
|
if !selection.is_changed() && !layout_changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Despawn stale highlights first.
|
// Despawn stale highlights first.
|
||||||
for entity in &highlights {
|
for entity in &highlights {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
|
|||||||
+256
-18
@@ -176,9 +176,12 @@ async function bootstrap() {
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
showResumeDialog(saved);
|
showResumeDialog(saved);
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
const rawSeed = Number(params.get("seed"));
|
||||||
drawThree = params.has("draw3");
|
const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0
|
||||||
|
? Math.floor(rawSeed)
|
||||||
|
: randomSeed();
|
||||||
|
drawThree = params.has("draw3");
|
||||||
chkDraw3.checked = drawThree;
|
chkDraw3.checked = drawThree;
|
||||||
startGame(urlSeed);
|
startGame(urlSeed);
|
||||||
}
|
}
|
||||||
@@ -393,8 +396,16 @@ function render(s) {
|
|||||||
stopTimer();
|
stopTimer();
|
||||||
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||||
if (noMovesBanner) noMovesBanner.classList.add("hidden");
|
if (noMovesBanner) noMovesBanner.classList.add("hidden");
|
||||||
showWin(s);
|
// Delay slightly so the last card's CSS transition finishes before
|
||||||
|
// the win overlay covers the board. Card transitions are ~260 ms.
|
||||||
|
setTimeout(() => showWin(s), 320);
|
||||||
} else {
|
} else {
|
||||||
|
// If the player undid out of auto-complete, restart the timer —
|
||||||
|
// stopTimer() was called when auto-complete began, but no code path
|
||||||
|
// before here restarts it after an undo.
|
||||||
|
if (!s.is_auto_completable && !timerInterval) {
|
||||||
|
startTimer();
|
||||||
|
}
|
||||||
saveState();
|
saveState();
|
||||||
const noMoves = !s.has_moves && !s.is_auto_completable;
|
const noMoves = !s.has_moves && !s.is_auto_completable;
|
||||||
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
|
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
|
||||||
@@ -429,20 +440,34 @@ function showWin(s) {
|
|||||||
submitReplay(s);
|
submitReplay(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReplay(s) {
|
function buildReplayPayload(s) {
|
||||||
const token = localStorage.getItem('fs_token');
|
if (!game || !s) return null;
|
||||||
if (!token) return;
|
let moves;
|
||||||
const payload = {
|
try {
|
||||||
schema_version: 1,
|
moves = game.replay_moves();
|
||||||
|
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("fs: replay export failed", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schema_version: 2,
|
||||||
seed: Math.round(game.seed()),
|
seed: Math.round(game.seed()),
|
||||||
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
||||||
mode: "Classic",
|
mode: "Classic",
|
||||||
time_seconds: elapsedSecs,
|
time_seconds: Math.max(1, elapsedSecs),
|
||||||
final_score: s.score,
|
final_score: s.score,
|
||||||
move_count: s.move_count,
|
|
||||||
recorded_at: new Date().toISOString().slice(0, 10),
|
recorded_at: new Date().toISOString().slice(0, 10),
|
||||||
moves: [],
|
moves,
|
||||||
|
win_move_index: moves.length - 1,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReplay(s) {
|
||||||
|
const token = localStorage.getItem('fs_token');
|
||||||
|
if (!token || !game) return;
|
||||||
|
const payload = buildReplayPayload(s);
|
||||||
|
if (!payload) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/replays', {
|
await fetch('/api/replays', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -467,7 +492,12 @@ function flashIllegal(cardIds) {
|
|||||||
for (const id of cardIds) {
|
for (const id of cardIds) {
|
||||||
const el = cardEls.get(id);
|
const el = cardEls.get(id);
|
||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
// Store current translate so the shake keyframe can reference it.
|
// Remove any in-progress shake before restarting. Reading offsetWidth
|
||||||
|
// forces a synchronous layout flush so the browser sees the removal
|
||||||
|
// before we re-add the class, restarting the animation from frame 0.
|
||||||
|
el.classList.remove("illegal");
|
||||||
|
el.style.removeProperty("--card-tx");
|
||||||
|
void el.offsetWidth; // flush layout — do not remove
|
||||||
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
|
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
|
||||||
el.classList.add("illegal");
|
el.classList.add("illegal");
|
||||||
el.addEventListener("animationend", () => {
|
el.addEventListener("animationend", () => {
|
||||||
@@ -496,11 +526,34 @@ function attachHandlers() {
|
|||||||
syncThemeButton();
|
syncThemeButton();
|
||||||
if (game) render(game.state());
|
if (game) render(game.state());
|
||||||
});
|
});
|
||||||
|
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.target.tagName === "INPUT") return;
|
const tag = e.target?.tagName;
|
||||||
if (e.key === "z" || e.key === "Z") doUndo();
|
if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||||
if (e.key === "n" || e.key === "N") startGame(randomSeed());
|
if (e.key === "z" || e.key === "Z" || e.key === "u" || e.key === "U") {
|
||||||
|
e.preventDefault();
|
||||||
|
doUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "n" || e.key === "N") {
|
||||||
|
startGame(randomSeed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.repeat && (e.code === "Space" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
doDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause the game timer while the tab is hidden so background time doesn't
|
||||||
|
// inflate the player's recorded game duration.
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopTimer();
|
||||||
|
} else if (snap && !snap.is_won && !snap.is_auto_completable) {
|
||||||
|
startTimer();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
board.addEventListener("pointerdown", onPointerDown);
|
board.addEventListener("pointerdown", onPointerDown);
|
||||||
@@ -706,7 +759,7 @@ function onPointerCancel() {
|
|||||||
|
|
||||||
// ── Click / dblclick ──────────────────────────────────────────────────────────
|
// ── Click / dblclick ──────────────────────────────────────────────────────────
|
||||||
function onBoardClick(e) {
|
function onBoardClick(e) {
|
||||||
if (drag) return;
|
if (drag || snap?.is_won) return;
|
||||||
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
const stock = PILE_ORIGIN.stock;
|
const stock = PILE_ORIGIN.stock;
|
||||||
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
|
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
|
||||||
@@ -741,7 +794,7 @@ function smartMove(pileName, fromIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBoardDblClick(e) {
|
function onBoardDblClick(e) {
|
||||||
if (drag) return;
|
if (drag || snap?.is_won) return;
|
||||||
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
const hit = hitTestCard(bx, by);
|
const hit = hitTestCard(bx, by);
|
||||||
if (!hit || !hit.card.face_up) return;
|
if (!hit || !hit.card.face_up) return;
|
||||||
@@ -782,6 +835,191 @@ async function loadAvatar() {
|
|||||||
} catch { /* not signed in — avatar stays hidden */ }
|
} catch { /* not signed in — avatar stays hidden */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function debugStateKey(state) {
|
||||||
|
if (!state) return "missing";
|
||||||
|
if (Array.isArray(state.stock) || Array.isArray(state.tableaus)) {
|
||||||
|
const out = [];
|
||||||
|
const push = cards => {
|
||||||
|
for (const c of cards || []) out.push(`${c.id}:${c.face_up ? 1 : 0}`);
|
||||||
|
out.push("|");
|
||||||
|
};
|
||||||
|
push(state.stock);
|
||||||
|
push(state.waste);
|
||||||
|
for (const pile of state.foundations || []) push(pile);
|
||||||
|
for (const pile of state.tableaus || []) push(pile);
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderBaselineDebugMoves(legalMoves) {
|
||||||
|
const foundationSingles = [];
|
||||||
|
const moveKind = [];
|
||||||
|
const rest = [];
|
||||||
|
for (let i = 0; i < legalMoves.length; i++) {
|
||||||
|
const move = legalMoves[i];
|
||||||
|
if (
|
||||||
|
move?.kind === "move" &&
|
||||||
|
typeof move.to === "string" &&
|
||||||
|
move.to.startsWith("foundation-") &&
|
||||||
|
move.count === 1
|
||||||
|
) {
|
||||||
|
foundationSingles.push(i);
|
||||||
|
} else if (move?.kind === "move") {
|
||||||
|
moveKind.push(i);
|
||||||
|
} else {
|
||||||
|
rest.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...foundationSingles, ...moveKind, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDebugAutoplay(options = {}) {
|
||||||
|
if (!game) return { ok: false, reason: "game_not_ready", step: 0 };
|
||||||
|
|
||||||
|
const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0 ? options.maxSteps : 220;
|
||||||
|
const maxVisitsPerState =
|
||||||
|
Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0
|
||||||
|
? options.maxVisitsPerState
|
||||||
|
: 2;
|
||||||
|
const policy = options.policy === "baseline" ? "baseline" : "loop_aware";
|
||||||
|
const seen = new Map();
|
||||||
|
|
||||||
|
function simulatedVisitCount(legalMoveIndex) {
|
||||||
|
let saved = null;
|
||||||
|
try {
|
||||||
|
saved = game.serialize();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof saved !== "string" || saved.length === 0) return null;
|
||||||
|
|
||||||
|
const applied = game.debug_apply_legal_move(legalMoveIndex);
|
||||||
|
if (!applied?.ok) {
|
||||||
|
try { game = SolitaireGame.from_saved(saved); } catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nextKey = debugStateKey(applied.snapshot);
|
||||||
|
try {
|
||||||
|
game = SolitaireGame.from_saved(saved);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return seen.get(nextKey) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let step = 0; step < maxSteps; step++) {
|
||||||
|
const snap = game.debug_snapshot();
|
||||||
|
if (!snap?.state || !snap?.invariants) {
|
||||||
|
return { ok: false, reason: "missing_snapshot", step };
|
||||||
|
}
|
||||||
|
if (!snap.invariants.state_ok) {
|
||||||
|
return { ok: false, reason: "invariant_failed", step, snapshot: snap };
|
||||||
|
}
|
||||||
|
if (snap.state.is_won) {
|
||||||
|
return { ok: true, terminal: "won", step, snapshot: snap };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = debugStateKey(snap.state);
|
||||||
|
const visits = (seen.get(key) || 0) + 1;
|
||||||
|
seen.set(key, visits);
|
||||||
|
if (visits > maxVisitsPerState) {
|
||||||
|
return { ok: true, terminal: "cycle", step, snapshot: snap };
|
||||||
|
}
|
||||||
|
|
||||||
|
const legalMoves = game.debug_legal_moves();
|
||||||
|
if (!Array.isArray(legalMoves) || legalMoves.length === 0) {
|
||||||
|
return { ok: true, terminal: "no_moves", step, snapshot: snap };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = orderBaselineDebugMoves(legalMoves);
|
||||||
|
let idx = ordered[0];
|
||||||
|
if (policy === "loop_aware" && ordered.length > 1) {
|
||||||
|
let bestIdx = ordered[0];
|
||||||
|
let bestVisitCount = Number.MAX_SAFE_INTEGER;
|
||||||
|
for (const candidate of ordered) {
|
||||||
|
const visitCount = simulatedVisitCount(candidate);
|
||||||
|
if (visitCount === null) continue;
|
||||||
|
if (visitCount < bestVisitCount) {
|
||||||
|
bestVisitCount = visitCount;
|
||||||
|
bestIdx = candidate;
|
||||||
|
if (visitCount === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = bestIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = game.debug_apply_legal_move(idx);
|
||||||
|
if (!result?.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "apply_failed",
|
||||||
|
step,
|
||||||
|
idx,
|
||||||
|
error: result?.error ?? "unknown_error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.snapshot) render(result.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSnap = game.debug_snapshot();
|
||||||
|
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget", snapshot: finalSnap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debug API (engine-first automation surface) ───────────────────────────────
|
||||||
|
// Playwright and other automation harnesses use this object instead of pixel
|
||||||
|
// analysis or hardcoded coordinates. Every operation delegates to the Rust
|
||||||
|
// rules engine exported by `solitaire_wasm`.
|
||||||
|
window.__FERROUS_DEBUG__ = {
|
||||||
|
seed() {
|
||||||
|
return game ? Math.round(game.seed()) : null;
|
||||||
|
},
|
||||||
|
state() {
|
||||||
|
return game ? game.state() : null;
|
||||||
|
},
|
||||||
|
legalMoves() {
|
||||||
|
return game ? game.debug_legal_moves() : [];
|
||||||
|
},
|
||||||
|
moveHistory() {
|
||||||
|
return game ? game.debug_move_history() : [];
|
||||||
|
},
|
||||||
|
snapshot() {
|
||||||
|
return game ? game.debug_snapshot() : null;
|
||||||
|
},
|
||||||
|
applyLegalMove(index) {
|
||||||
|
if (!game) return { ok: false, error: "game_not_ready" };
|
||||||
|
const result = game.debug_apply_legal_move(index);
|
||||||
|
if (result?.ok && result.snapshot) render(result.snapshot);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
applyMove(move) {
|
||||||
|
if (!game) return { ok: false, error: "game_not_ready" };
|
||||||
|
const payload = typeof move === "string" ? move : JSON.stringify(move);
|
||||||
|
const result = game.debug_apply_move_json(payload);
|
||||||
|
if (result?.ok && result.snapshot) render(result.snapshot);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
failureReport() {
|
||||||
|
if (!game) return null;
|
||||||
|
const debug = game.debug_snapshot();
|
||||||
|
return {
|
||||||
|
seed: Math.round(game.seed()),
|
||||||
|
moveHistory: debug?.move_history ?? [],
|
||||||
|
currentState: debug?.state ?? game.state(),
|
||||||
|
stateJson: debug?.state_json ?? null,
|
||||||
|
legalMoves: debug?.legal_moves ?? [],
|
||||||
|
invariants: debug?.invariants ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
replayPayload() {
|
||||||
|
if (!game) return null;
|
||||||
|
return buildReplayPayload(snap ?? game.state());
|
||||||
|
},
|
||||||
|
runAutoplay(options) {
|
||||||
|
return runDebugAutoplay(options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
bootstrap().catch(console.error);
|
bootstrap().catch(console.error);
|
||||||
loadAvatar();
|
loadAvatar();
|
||||||
|
|||||||
Reference in New Issue
Block a user