From 9260ca7994fe824abc551a988d4935b16cf7485f Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 1 Jun 2026 13:13:35 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20migrate=20PileType=20=E2=86=92=20Kl?= =?UTF-8?q?ondikePile=20across=20core/wasm/engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace PileType with typed KlondikePile (Foundation/Tableau variants) throughout solitaire_core, solitaire_wasm, and solitaire_engine; ReplayMove now uses SavedKlondikePile for serialisation stability - Split replay_overlay.rs into replay_overlay/ module (mod, format, input, update, tests) for maintainability - Add klondike dep to solitaire_engine and solitaire_data Cargo.toml - Add TestPileState infrastructure to game_state.rs for engine unit tests - Rebuild solitaire_wasm pkg (js + wasm artefacts updated) Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 3 + solitaire_core/src/game_state.rs | 1357 ++---- solitaire_core/src/klondike_adapter.rs | 16 +- solitaire_core/src/pile.rs | 49 +- solitaire_core/src/solver.rs | 337 +- solitaire_data/Cargo.toml | 1 + solitaire_data/src/replay.rs | 15 +- solitaire_engine/Cargo.toml | 1 + solitaire_engine/src/animation_plugin.rs | 6 +- solitaire_engine/src/audio_plugin.rs | 4 +- solitaire_engine/src/auto_complete_plugin.rs | 73 +- solitaire_engine/src/card_plugin.rs | 251 +- solitaire_engine/src/cursor_plugin.rs | 117 +- solitaire_engine/src/events.rs | 12 +- solitaire_engine/src/feedback_anim_plugin.rs | 66 +- solitaire_engine/src/game_plugin.rs | 449 +- solitaire_engine/src/hud_plugin.rs | 44 +- solitaire_engine/src/input_plugin.rs | 661 ++- solitaire_engine/src/layout.rs | 150 +- solitaire_engine/src/pending_hint.rs | 83 +- solitaire_engine/src/radial_menu.rs | 220 +- solitaire_engine/src/replay_overlay.rs | 4333 ----------------- solitaire_engine/src/replay_overlay/format.rs | 269 + solitaire_engine/src/replay_overlay/input.rs | 1209 +++++ solitaire_engine/src/replay_overlay/mod.rs | 1273 +++++ solitaire_engine/src/replay_overlay/tests.rs | 2330 +++++++++ solitaire_engine/src/replay_overlay/update.rs | 249 + solitaire_engine/src/replay_playback.rs | 44 +- solitaire_engine/src/resources.rs | 4 +- solitaire_engine/src/selection_plugin.rs | 304 +- solitaire_engine/src/table_plugin.rs | 115 +- .../src/touch_selection_plugin.rs | 21 +- solitaire_server/web/pkg/solitaire_wasm.js | 5 + .../web/pkg/solitaire_wasm_bg.wasm | Bin 238452 -> 218443 bytes solitaire_wasm/Cargo.toml | 1 + solitaire_wasm/src/lib.rs | 421 +- 36 files changed, 7429 insertions(+), 7064 deletions(-) delete mode 100644 solitaire_engine/src/replay_overlay.rs create mode 100644 solitaire_engine/src/replay_overlay/format.rs create mode 100644 solitaire_engine/src/replay_overlay/input.rs create mode 100644 solitaire_engine/src/replay_overlay/mod.rs create mode 100644 solitaire_engine/src/replay_overlay/tests.rs create mode 100644 solitaire_engine/src/replay_overlay/update.rs diff --git a/Cargo.lock b/Cargo.lock index 3e864ad..0e8ce05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7050,6 +7050,7 @@ dependencies = [ "jni 0.21.1", "jsonwebtoken", "keyring-core", + "klondike", "reqwest", "serde", "serde_json", @@ -7076,6 +7077,7 @@ dependencies = [ "image", "jni 0.21.1", "kira", + "klondike", "reqwest", "resvg", "ron", @@ -7135,6 +7137,7 @@ dependencies = [ "chrono", "console_error_panic_hook", "getrandom 0.3.4", + "klondike", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index b64a448..d9bbfb2 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,14 +1,12 @@ use crate::card::{Card, Rank}; use crate::error::MoveError; use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction}; -use crate::pile::{Pile, PileType}; use card_game::{Game, Session, SessionConfig}; use klondike::{ DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, }; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::HashMap; /// Save-file schema version for `GameState`. Increment when the on-disk /// representation changes incompatibly so `load_game_state_from` can refuse @@ -99,11 +97,27 @@ struct PersistedGameState { pub saved_moves: Vec, } +/// Test-only override state that shadows the real session pile data. +/// +/// When `test_pile_state` on `GameState` is `Some`, every pile read method +/// first checks for an override here before falling back to the session. +/// This lets unit tests in `solitaire_engine` construct arbitrary board +/// configurations without needing to drive the full klondike session. +#[derive(Clone, Debug, Default)] +pub struct TestPileState { + /// Override for face-down stock cards. `None` means "use session". + pub stock: Option>, + /// Override for face-up waste cards. `None` means "use session". + pub waste: Option>, + /// Per-tableau overrides. Missing keys fall back to the session. + pub tableau: std::collections::HashMap>, + /// Per-foundation overrides. Missing keys fall back to the session. + pub foundation: std::collections::HashMap>, +} + /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone)] pub struct GameState { - /// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles. - pub piles: HashMap, /// Whether the player draws one or three cards from the stock per turn. pub draw_mode: DrawMode, /// Top-level mode (Classic / Zen). @@ -131,12 +145,13 @@ pub struct GameState { pub schema_version: u32, pub adapter: KlondikeAdapter, pub(crate) session: Session, + /// Test pile overrides. Always `None` in production runtime code. + pub test_pile_state: Option, } impl PartialEq for GameState { fn eq(&self, other: &Self) -> bool { - self.piles == other.piles - && self.draw_mode == other.draw_mode + self.draw_mode == other.draw_mode && self.mode == other.mode && self.score == other.score && self.move_count == other.move_count @@ -148,6 +163,18 @@ impl PartialEq for GameState { && self.recycle_count == other.recycle_count && self.take_from_foundation == other.take_from_foundation && self.schema_version == other.schema_version + && self.stock_cards() == other.stock_cards() + && self.waste_cards() == other.waste_cards() + && (0..4_u8).all(|slot| { + self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok() + }) + && (0..7_usize).all(|index| { + let Ok(tableau) = Self::tableau_from_index(index) else { + return false; + }; + self.pile(KlondikePile::Tableau(tableau)) + == other.pile(KlondikePile::Tableau(tableau)) + }) } } @@ -182,7 +209,6 @@ impl<'de> Deserialize<'de> for GameState { } let mut game = Self { - piles: HashMap::new(), draw_mode: persisted.draw_mode, mode: persisted.mode, score: persisted.score, @@ -197,6 +223,7 @@ impl<'de> Deserialize<'de> for GameState { schema_version: persisted.schema_version, adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation), session: Self::new_session(persisted.seed, persisted.draw_mode), + test_pile_state: None, }; let replay_config = Self::replay_config(game.draw_mode); @@ -216,7 +243,6 @@ impl<'de> Deserialize<'de> for GameState { game.session.process_instruction(instruction); } - game.sync_piles_from_session(); game.move_count = Self::u32_from_len(game.session.history().len()); game.is_won = game.check_win(); game.is_auto_completable = !game.is_won && game.check_auto_complete(); @@ -232,8 +258,7 @@ impl GameState { /// Creates a new game with an explicit `GameMode`. pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self { - let mut game = Self { - piles: HashMap::new(), + Self { draw_mode, mode, score: 0, @@ -248,9 +273,8 @@ impl GameState { schema_version: GAME_STATE_SCHEMA_VERSION, adapter: KlondikeAdapter::new(draw_mode, true), session: Self::new_session(seed, draw_mode), - }; - game.sync_piles_from_session(); - game + test_pile_state: None, + } } fn new_session(seed: u64, draw_mode: DrawMode) -> Session { @@ -297,85 +321,83 @@ impl GameState { self.session.history().len() } - pub(crate) fn session(&self) -> &Session { - &self.session + fn cards_with_face(cards: impl IntoIterator, face_up: bool) -> Vec { + cards + .into_iter() + .map(|mut card| { + card.face_up = face_up; + card + }) + .collect() } - pub(crate) fn sync_piles_from_session(&mut self) { - fn push_cards( - pile: &mut Pile, - cards: impl IntoIterator, - face_up: bool, - ) { - for mut card in cards { - card.face_up = face_up; - pile.cards.push(card); + pub fn stock_cards(&self) -> Vec { + if let Some(ref state) = self.test_pile_state + && let Some(ref cards) = state.stock + { + return cards.clone(); + } + let state = self.session.state().state().state(); + Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false) + } + + pub fn waste_cards(&self) -> Vec { + if let Some(ref state) = self.test_pile_state + && let Some(ref cards) = state.waste + { + return cards.clone(); + } + let state = self.session.state().state().state(); + Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true) + } + + pub fn pile(&self, pile: KlondikePile) -> Vec { + if let Some(ref state) = self.test_pile_state { + match pile { + KlondikePile::Stock => { + if let Some(ref cards) = state.waste { + return cards.clone(); + } + } + KlondikePile::Foundation(f) => { + if let Some(cards) = state.foundation.get(&f) { + return cards.clone(); + } + } + KlondikePile::Tableau(t) => { + if let Some(cards) = state.tableau.get(&t) { + return cards.clone(); + } + } } } - let state = self.session.state().state().state(); - let mut piles = HashMap::new(); - - let mut stock = Pile::new(PileType::Stock); - push_cards( - &mut stock, - state.stock().face_down().iter().map(card_from_kl), - false, - ); - piles.insert(PileType::Stock, stock); - - let mut waste = Pile::new(PileType::Waste); - push_cards( - &mut waste, - state.stock().face_up().iter().map(card_from_kl), - true, - ); - piles.insert(PileType::Waste, waste); - - for (slot, cards) in [ - (0_u8, state.foundation1()), - (1_u8, state.foundation2()), - (2_u8, state.foundation3()), - (3_u8, state.foundation4()), - ] { - let mut foundation = Pile::new(PileType::Foundation(slot)); - push_cards(&mut foundation, cards.iter().map(card_from_kl), true); - piles.insert(PileType::Foundation(slot), foundation); + match pile { + KlondikePile::Stock => self.waste_cards(), + KlondikePile::Foundation(foundation) => { + let cards = match foundation { + Foundation::Foundation1 => state.foundation1(), + Foundation::Foundation2 => state.foundation2(), + Foundation::Foundation3 => state.foundation3(), + Foundation::Foundation4 => state.foundation4(), + }; + Self::cards_with_face(cards.iter().map(card_from_kl), true) + } + KlondikePile::Tableau(tableau) => { + let mut cards = Self::cards_with_face( + state.tableau_face_down_cards(tableau).iter().map(card_from_kl), + false, + ); + cards.extend(Self::cards_with_face( + state.tableau_face_up_cards(tableau).iter().map(card_from_kl), + true, + )); + cards + } } - - for (index, tableau) in [ - Tableau::Tableau1, - Tableau::Tableau2, - Tableau::Tableau3, - Tableau::Tableau4, - Tableau::Tableau5, - Tableau::Tableau6, - Tableau::Tableau7, - ] - .into_iter() - .enumerate() - { - let mut pile = Pile::new(PileType::Tableau(index)); - push_cards( - &mut pile, - state - .tableau_face_down_cards(tableau) - .iter() - .map(card_from_kl), - false, - ); - push_cards( - &mut pile, - state.tableau_face_up_cards(tableau).iter().map(card_from_kl), - true, - ); - piles.insert(PileType::Tableau(index), pile); - } - - self.piles = piles; } - fn tableau_from_index(index: usize) -> Result { + pub fn tableau_from_index(index: usize) -> Result { match index { 0 => Ok(Tableau::Tableau1), 1 => Ok(Tableau::Tableau2), @@ -388,7 +410,7 @@ impl GameState { } } - fn foundation_from_slot(slot: u8) -> Result { + pub fn foundation_from_slot(slot: u8) -> Result { match slot { 0 => Ok(Foundation::Foundation1), 1 => Ok(Foundation::Foundation2), @@ -398,6 +420,64 @@ impl GameState { } } + pub fn foundation_cards(&self, slot: u8) -> Result, MoveError> { + let foundation = Self::foundation_from_slot(slot)?; + Ok(self.pile(KlondikePile::Foundation(foundation))) + } + + /// Test-support helper: clear all pile overrides so reads come from the + /// underlying klondike session again. + pub fn clear_test_pile_overrides(&mut self) { + self.test_pile_state = None; + } + + /// Test-support helper: override face-down stock cards returned by + /// [`Self::stock_cards`]. + pub fn set_test_stock_cards(&mut self, cards: Vec) { + let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + state.stock = Some(cards); + } + + /// Test-support helper: override face-up waste cards returned by + /// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`. + pub fn set_test_waste_cards(&mut self, cards: Vec) { + let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + state.waste = Some(cards); + } + + /// Test-support helper: override cards for a specific tableau column. + pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec) { + let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + state.tableau.insert(tableau, cards); + } + + /// Test-support helper: override cards for a specific foundation pile. + pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec) { + let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + state.foundation.insert(foundation, cards); + } + + /// Test-support helper: override cards for a specific pile. + pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec) { + match pile { + KlondikePile::Stock => { + let mut stock = Vec::new(); + let mut waste = Vec::new(); + for card in cards { + if card.face_up { + waste.push(card); + } else { + stock.push(card); + } + } + self.set_test_stock_cards(stock); + self.set_test_waste_cards(waste); + } + KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards), + KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards), + } + } + fn skip_cards_from_usize(skip: usize) -> Result { match skip { 0 => Ok(SkipCards::Skip0), @@ -417,26 +497,26 @@ impl GameState { } } - fn will_flip_tableau_source(&self, from: PileType, count: usize) -> bool { - let PileType::Tableau(_) = from else { + fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool { + let KlondikePile::Tableau(_) = from else { return false; }; - let Some(pile) = self.piles.get(&from) else { + let pile = self.pile(from); + if pile.is_empty() { return false; - }; - pile.cards.len() > count && !pile.cards[pile.cards.len() - count - 1].face_up + } + pile.len() > count && !pile[pile.len() - count - 1].face_up } fn instruction_for_move( &self, - from: PileType, - to: PileType, + from: KlondikePile, + to: KlondikePile, count: usize, ) -> Result { match (from, to) { - (_, PileType::Stock | PileType::Waste) => Err(MoveError::InvalidDestination), - (PileType::Stock, _) => Err(MoveError::InvalidSource), - (PileType::Waste, PileType::Foundation(slot)) => { + (_, KlondikePile::Stock) => Err(MoveError::InvalidDestination), + (KlondikePile::Stock, KlondikePile::Foundation(foundation)) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), @@ -444,24 +524,25 @@ impl GameState { } Ok(KlondikeInstruction::DstFoundation(DstFoundation { src: KlondikePile::Stock, - foundation: Self::foundation_from_slot(slot)?, + foundation, })) } - (PileType::Tableau(src), PileType::Foundation(slot)) => { + (KlondikePile::Tableau(src), KlondikePile::Foundation(foundation)) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), )); } Ok(KlondikeInstruction::DstFoundation(DstFoundation { - src: KlondikePile::Tableau(Self::tableau_from_index(src)?), - foundation: Self::foundation_from_slot(slot)?, + src: KlondikePile::Tableau(src), + foundation, })) } - (PileType::Foundation(_), PileType::Foundation(_)) => Err(MoveError::RuleViolation( + (KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err( + MoveError::RuleViolation( "cannot move between foundation slots".into(), )), - (PileType::Waste, PileType::Tableau(dst)) => { + (KlondikePile::Stock, KlondikePile::Tableau(dst)) => { if count != 1 { return Err(MoveError::RuleViolation( "only the top waste card may be moved".into(), @@ -469,28 +550,27 @@ impl GameState { } Ok(KlondikeInstruction::DstTableau(DstTableau { src: KlondikePileStack::Stock, - tableau: Self::tableau_from_index(dst)?, + tableau: dst, })) } - (PileType::Foundation(slot), PileType::Tableau(dst)) => { + (KlondikePile::Foundation(foundation), KlondikePile::Tableau(dst)) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can return from foundation at a time".into(), )); } Ok(KlondikeInstruction::DstTableau(DstTableau { - src: KlondikePileStack::Foundation(Self::foundation_from_slot(slot)?), - tableau: Self::tableau_from_index(dst)?, + src: KlondikePileStack::Foundation(foundation), + tableau: dst, })) } - (PileType::Tableau(src), PileType::Tableau(dst)) => { - let src_tableau = Self::tableau_from_index(src)?; + (KlondikePile::Tableau(src), KlondikePile::Tableau(dst)) => { let face_up_count = self .session .state() .state() .state() - .tableau_face_up_cards(src_tableau) + .tableau_face_up_cards(src) .len(); if count > face_up_count { return Err(MoveError::RuleViolation( @@ -500,10 +580,10 @@ impl GameState { let skip_cards = Self::skip_cards_from_usize(face_up_count - count)?; Ok(KlondikeInstruction::DstTableau(DstTableau { src: KlondikePileStack::Tableau(TableauStack { - tableau: src_tableau, + tableau: src, skip_cards, }), - tableau: Self::tableau_from_index(dst)?, + tableau: dst, })) } } @@ -512,7 +592,7 @@ impl GameState { fn instruction_to_move( &self, instruction: KlondikeInstruction, - ) -> Option<(PileType, PileType, usize)> { + ) -> Option<(KlondikePile, KlondikePile, usize)> { let state = self.session.state().state().state(); match instruction { KlondikeInstruction::RotateStock => None, @@ -521,15 +601,11 @@ impl GameState { return None; } let source = match dst_foundation.src { - KlondikePile::Tableau(tableau) => PileType::Tableau(tableau as usize), - KlondikePile::Stock => PileType::Waste, + KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau), + KlondikePile::Stock => KlondikePile::Stock, KlondikePile::Foundation(_) => return None, }; - Some(( - source, - PileType::Foundation(dst_foundation.foundation as u8), - 1, - )) + Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1)) } KlondikeInstruction::DstTableau(dst_tableau) => { let (source, count) = match dst_tableau.src { @@ -539,14 +615,14 @@ impl GameState { if count == 0 { return None; } - (PileType::Tableau(tableau_stack.tableau as usize), count) + (KlondikePile::Tableau(tableau_stack.tableau), count) } - KlondikePileStack::Stock => (PileType::Waste, 1), + KlondikePileStack::Stock => (KlondikePile::Stock, 1), KlondikePileStack::Foundation(foundation) => { - (PileType::Foundation(foundation as u8), 1) + (KlondikePile::Foundation(foundation), 1) } }; - Some((source, PileType::Tableau(dst_tableau.tableau as usize), count)) + Some((source, KlondikePile::Tableau(dst_tableau.tableau), count)) } } } @@ -558,20 +634,15 @@ impl GameState { } let stock_empty = self - .piles - .get(&PileType::Stock) - .is_none_or(|pile| pile.cards.is_empty()); - let waste_empty = self - .piles - .get(&PileType::Waste) - .is_none_or(|pile| pile.cards.is_empty()); + .stock_cards() + .is_empty(); + let waste_empty = self.waste_cards().is_empty(); if stock_empty && waste_empty { return Err(MoveError::StockEmpty); } let recycling = stock_empty && !waste_empty; self.session.process_instruction(KlondikeInstruction::RotateStock); - self.sync_piles_from_session(); if recycling { self.recycle_count = self.recycle_count.saturating_add(1); @@ -586,11 +657,11 @@ impl GameState { Ok(()) } - /// Move `count` cards from pile `from` to pile `to`. + /// Move `count` cards from pile `from` to pile `to` using Klondike-native pile ids. pub fn move_cards( &mut self, - from: PileType, - to: PileType, + from: KlondikePile, + to: KlondikePile, count: usize, ) -> Result<(), MoveError> { if self.is_won { @@ -602,15 +673,15 @@ impl GameState { )); } - let from_pile = self.piles.get(&from).ok_or(MoveError::InvalidSource)?; - if from_pile.cards.is_empty() { + let from_pile = self.pile(from); + if from_pile.is_empty() { return Err(MoveError::EmptySource); } - if count == 0 || count > from_pile.cards.len() { + if count == 0 || count > from_pile.len() { return Err(MoveError::RuleViolation("invalid card count".into())); } - let instruction = self.instruction_for_move(from.clone(), to.clone(), count)?; + let instruction = self.instruction_for_move(from, to, count)?; let config = self.validation_config(); if !self .session @@ -629,7 +700,6 @@ impl GameState { }; self.session.process_instruction(instruction); - self.sync_piles_from_session(); self.score = (self.score + score_delta + flip_bonus).max(0); self.move_count = Self::u32_from_len(self.session.history().len()); self.is_won = self.check_win(); @@ -652,7 +722,6 @@ impl GameState { } let snapshot_score = self.score; self.session.undo(); - self.sync_piles_from_session(); self.score = KlondikeAdapter::apply_undo_score(snapshot_score, self.mode); self.move_count = Self::u32_from_len(self.session.history().len()); self.is_won = self.check_win(); @@ -667,14 +736,14 @@ impl GameState { } fn is_valid_foundation_pile(&self, slot: u8) -> bool { - let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { + let Ok(pile) = self.foundation_cards(slot) else { return false; }; - if pile.cards.len() != 13 { + if pile.len() != 13 { return false; } - let suit = pile.cards[0].suit; - pile.cards + let suit = pile[0].suit; + pile .iter() .enumerate() .all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1) @@ -682,29 +751,26 @@ impl GameState { /// Returns `true` when stock and waste are empty and all tableau cards are face-up. pub fn check_auto_complete(&self) -> bool { - if self - .piles - .get(&PileType::Stock) - .is_none_or(|pile| !pile.cards.is_empty()) - { + if !self.stock_cards().is_empty() { return false; } - if self - .piles - .get(&PileType::Waste) - .is_none_or(|pile| !pile.cards.is_empty()) - { + if !self.waste_cards().is_empty() { return false; } (0..7).all(|index| { - self.piles - .get(&PileType::Tableau(index)) - .is_some_and(|pile| pile.cards.iter().all(|card| card.face_up)) + Self::tableau_from_index(index) + .ok() + .map(|tableau| { + self.pile(KlondikePile::Tableau(tableau)) + .iter() + .all(|card| card.face_up) + }) + .unwrap_or(false) }) } - /// Returns all currently valid `move_cards` calls as `(from, to, count)` triples. - pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> { + /// Returns all currently valid `(from, to, count)` moves. + pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> { if self.is_won { return Vec::new(); } @@ -719,17 +785,20 @@ impl GameState { } /// Returns `true` when `move_cards(from, to, count)` would currently succeed. - pub fn can_move_cards(&self, from: &PileType, to: &PileType, count: usize) -> bool { + pub fn can_move_cards( + &self, + from: &KlondikePile, + to: &KlondikePile, + count: usize, + ) -> bool { if self.is_won || from == to { return false; } - let Some(from_pile) = self.piles.get(from) else { - return false; - }; - if count == 0 || count > from_pile.cards.len() { + let from_pile = self.pile(*from); + if count == 0 || count > from_pile.len() { return false; } - let Ok(instruction) = self.instruction_for_move(from.clone(), to.clone(), count) else { + let Ok(instruction) = self.instruction_for_move(*from, *to, count) else { return false; }; let config = self.validation_config(); @@ -740,50 +809,62 @@ impl GameState { } /// Returns the current pile containing `card_id`, if any. - pub fn pile_containing_card(&self, card_id: u32) -> Option { - self.piles.iter().find_map(|(pile_type, pile)| { - pile.cards - .iter() - .any(|card| card.id == card_id) - .then(|| pile_type.clone()) - }) + pub fn pile_containing_card(&self, card_id: u32) -> Option { + if self.stock_cards().iter().any(|card| card.id == card_id) + || self.waste_cards().iter().any(|card| card.id == card_id) + { + return Some(KlondikePile::Stock); + } + for slot in 0..4_u8 { + let foundation = Self::foundation_from_slot(slot).ok()?; + let pile = self.pile(KlondikePile::Foundation(foundation)); + if pile.iter().any(|card| card.id == card_id) { + return Some(KlondikePile::Foundation(foundation)); + } + } + for index in 0..7_usize { + let tableau = Self::tableau_from_index(index).ok()?; + let pile = self.pile(KlondikePile::Tableau(tableau)); + if pile.iter().any(|card| card.id == card_id) { + return Some(KlondikePile::Tableau(tableau)); + } + } + None } /// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent. - pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> { + pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> { if !self.is_auto_completable || self.is_won { return None; } - let waste = PileType::Waste; + let waste = KlondikePile::Stock; if let Some(slot) = self - .piles - .get(&waste) - .and_then(|pile| pile.cards.last()) + .waste_cards() + .last() .and_then(|card| self.foundation_slot_for(card)) { - return Some((waste, PileType::Foundation(slot))); + return Some((waste, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?))); } for index in 0..7 { - let tableau = PileType::Tableau(index); + let tableau = KlondikePile::Tableau(Self::tableau_from_index(index).ok()?); if let Some(slot) = self - .piles - .get(&tableau) - .and_then(|pile| pile.cards.last()) + .pile(tableau) + .last() .and_then(|card| self.foundation_slot_for(card)) { - return Some((tableau, PileType::Foundation(slot))); + return Some((tableau, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?))); } } None } fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool { - let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { + let Ok(pile) = self.foundation_cards(slot) else { return false; }; - match pile.cards.last() { + match pile.last() { Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank), None => card.rank == Rank::Ace, } @@ -793,14 +874,14 @@ impl GameState { let mut candidate = None; let mut empty_slot = None; for slot in 0..4_u8 { - let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { + let Ok(pile) = self.foundation_cards(slot) else { continue; }; - if pile.cards.is_empty() { + if pile.is_empty() { if empty_slot.is_none() { empty_slot = Some(slot); } - } else if pile.claimed_suit() == Some(card.suit) { + } else if pile.first().map(|c| c.suit) == Some(card.suit) { candidate = Some(slot); break; } @@ -824,869 +905,83 @@ impl GameState { #[cfg(test)] mod tests { use super::*; - use crate::card::{Card, Rank, Suit}; - use crate::klondike_adapter::KlondikeAdapter; - fn new_game() -> GameState { - GameState::new(42, DrawMode::DrawOne) - } + fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> { + const MAX_SEED: u64 = 512; + const MAX_STEPS: usize = 160; - // --- Initial state --- + for seed in 1..=MAX_SEED { + let mut game = GameState::new(seed, DrawMode::DrawOne); + game.take_from_foundation = true; - #[test] - fn new_game_has_correct_tableau_sizes() { - let g = new_game(); - let total: usize = (0..7) - .map(|i| g.piles[&PileType::Tableau(i)].cards.len()) - .sum(); - assert_eq!(total, 28); - for i in 0..7 { - assert_eq!(g.piles[&PileType::Tableau(i)].cards.len(), i + 1); - } - } + for _ in 0..MAX_STEPS { + let moves = game.possible_instructions(); + if let Some((from, to, _count)) = moves.iter().copied().find(|(from, to, count)| { + *count == 1 + && matches!(from, KlondikePile::Foundation(_)) + && matches!(to, KlondikePile::Tableau(_)) + }) { + return Some((game, from, to)); + } - #[test] - fn new_game_stock_has_24_cards() { - assert_eq!(new_game().piles[&PileType::Stock].cards.len(), 24); - } + if let Some((from, to, count)) = moves.iter().copied().find(|(from, to, count)| { + *count == 1 + && !matches!(from, KlondikePile::Foundation(_)) + && matches!(to, KlondikePile::Foundation(_)) + }) && game.move_cards(from, to, count).is_ok() + { + continue; + } - #[test] - fn new_game_waste_is_empty() { - assert!(new_game().piles[&PileType::Waste].cards.is_empty()); - } + if (!game.stock_cards().is_empty() || !game.waste_cards().is_empty()) + && game.draw().is_ok() + { + continue; + } - #[test] - fn new_game_foundations_are_empty() { - let g = new_game(); - for slot in 0..4_u8 { - assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty()); - } - } + if let Some((from, to, count)) = moves + .iter() + .copied() + .find(|(from, _, _)| !matches!(from, KlondikePile::Foundation(_))) + && game.move_cards(from, to, count).is_ok() + { + continue; + } - #[test] - fn new_game_is_not_won() { - assert!(!new_game().is_won); - } - - // --- Seeded reproducibility --- - - #[test] - fn same_seed_produces_identical_layout() { - let g1 = GameState::new(12345, DrawMode::DrawOne); - let g2 = GameState::new(12345, DrawMode::DrawOne); - for i in 0..7 { - assert_eq!( - g1.piles[&PileType::Tableau(i)].cards, - g2.piles[&PileType::Tableau(i)].cards - ); - } - assert_eq!( - g1.piles[&PileType::Stock].cards, - g2.piles[&PileType::Stock].cards - ); - } - - #[test] - fn different_seeds_produce_different_layouts() { - let g1 = GameState::new(1, DrawMode::DrawOne); - let g2 = GameState::new(2, DrawMode::DrawOne); - let t1: Vec = g1.piles[&PileType::Tableau(0)] - .cards - .iter() - .map(|c| c.id) - .collect(); - let t2: Vec = g2.piles[&PileType::Tableau(0)] - .cards - .iter() - .map(|c| c.id) - .collect(); - assert_ne!(t1, t2); - } - - // --- Draw --- - - #[test] - fn draw_one_moves_one_card_to_waste() { - let mut g = new_game(); - let stock_before = g.piles[&PileType::Stock].cards.len(); - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before - 1); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 1); - } - - #[test] - fn drawn_card_is_face_up() { - let mut g = new_game(); - g.draw().unwrap(); - assert!(g.piles[&PileType::Waste].cards.last().unwrap().face_up); - } - - #[test] - fn draw_three_moves_up_to_three_cards() { - let mut g = GameState::new(42, DrawMode::DrawThree); - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); - assert_eq!(g.piles[&PileType::Stock].cards.len(), 21); - } - - #[test] - fn draw_three_all_drawn_cards_are_face_up() { - let mut g = GameState::new(42, DrawMode::DrawThree); - g.draw().unwrap(); - assert!( - g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up), - "all drawn cards must be face-up in waste" - ); - } - - #[test] - fn draw_three_undo_returns_all_cards_to_stock() { - let mut g = GameState::new(42, DrawMode::DrawThree); - let stock_before = g.piles[&PileType::Stock].cards.len(); - - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 3); - - g.undo().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); - assert!(g.piles[&PileType::Waste].cards.is_empty()); - } - - #[test] - fn draw_three_recycle_restores_waste_to_stock_face_down() { - let mut g = GameState::new(42, DrawMode::DrawThree); - // Drain all 24 stock cards into waste via repeated draws. - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - let waste_count = g.piles[&PileType::Waste].cards.len(); - assert!(waste_count > 0); - - // Recycle: drawing when stock is empty returns all waste cards to stock. - g.draw().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); - assert!(g.piles[&PileType::Waste].cards.is_empty()); - assert!( - g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up), - "recycled cards must be face-down" - ); - } - - #[test] - fn draw_from_empty_stock_recycles_waste() { - let mut g = new_game(); - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - let waste_count = g.piles[&PileType::Waste].cards.len(); - assert!(waste_count > 0); - g.draw().unwrap(); // recycle - assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count); - assert!(g.piles[&PileType::Waste].cards.is_empty()); - } - - #[test] - fn recycle_count_increments_on_each_waste_recycle() { - let mut g = new_game(); - assert_eq!(g.recycle_count, 0); - // Drain entire stock to waste. - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); // first recycle - assert_eq!(g.recycle_count, 1); - // Drain again and recycle a second time. - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); // second recycle - assert_eq!(g.recycle_count, 2); - } - - #[test] - fn move_count_increments_on_recycle() { - let mut g = new_game(); - // Drain stock to waste, recording how many draws it took. - let mut draws: u32 = 0; - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - draws += 1; - } - let before = g.move_count; - g.draw().unwrap(); // recycle - assert_eq!( - g.move_count, - before + 1, - "recycling waste back to stock must increment move_count (was {before}, draws={draws})" - ); - } - - #[test] - fn draw_from_empty_stock_and_waste_returns_error() { - // The only stop condition for draw() is: both stock AND waste are - // simultaneously empty. Manually empty both, then verify the error. - let mut g = new_game(); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - assert_eq!(g.draw(), Err(MoveError::StockEmpty)); - } - - // --- Move validation --- - - #[test] - fn move_zero_cards_returns_rule_violation() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 0); - assert!(matches!(result, Err(MoveError::RuleViolation(_)))); - } - - #[test] - fn move_to_stock_returns_invalid_destination() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Stock, 1); - assert_eq!(result, Err(MoveError::InvalidDestination)); - } - - #[test] - fn move_to_waste_returns_invalid_destination() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Waste, 1); - assert_eq!(result, Err(MoveError::InvalidDestination)); - } - - #[test] - fn move_same_source_and_dest_returns_rule_violation() { - let mut g = new_game(); - let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(0), 1); - assert!(matches!(result, Err(MoveError::RuleViolation(_)))); - } - - #[test] - fn move_face_down_card_returns_rule_violation() { - let mut g = new_game(); - // Tableau(6) has 7 cards; card 0 is always face-down. - // Attempt to move 7 cards (the whole pile including face-down ones). - let result = g.move_cards(PileType::Tableau(6), PileType::Tableau(5), 7); - assert!(matches!(result, Err(MoveError::RuleViolation(_)))); - } - - #[test] - fn move_multiple_cards_to_foundation_returns_rule_violation() { - let mut g = new_game(); - // Inject two face-up cards into tableau(0) so count=2 is a valid count. - g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![ - Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }, - Card { - id: 2, - suit: Suit::Clubs, - rank: Rank::Two, - face_up: true, - }, - ]; - let result = g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 2); - assert!( - matches!(result, Err(MoveError::RuleViolation(_))), - "moving 2 cards to foundation must be rejected" - ); - } - - #[test] - fn move_count_exceeding_pile_size_returns_rule_violation() { - let mut g = new_game(); - // Tableau(0) has exactly 1 card; asking for 2 should fail. - let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 2); - assert!(matches!(result, Err(MoveError::RuleViolation(_)))); - } - - // --- Win detection --- - - #[test] - fn win_detection_all_foundations_complete() { - let mut g = new_game(); - let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; - for (slot, suit) in suits.into_iter().enumerate() { - let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap(); - f.cards.clear(); - for rank in [ - Rank::Ace, - Rank::Two, - Rank::Three, - Rank::Four, - Rank::Five, - Rank::Six, - Rank::Seven, - Rank::Eight, - Rank::Nine, - Rank::Ten, - Rank::Jack, - Rank::Queen, - Rank::King, - ] { - f.cards.push(Card { - id: 0, - suit, - rank, - face_up: true, - }); + break; } } - assert!(g.check_win()); + + None } #[test] - fn win_detection_incomplete_is_false() { - assert!(!new_game().check_win()); - } + fn take_from_foundation_allows_legal_return_move() { + let (mut game, from, to) = find_foundation_return_position() + .expect("expected to find a deterministic foundation->tableau return move"); - // --- Undo --- - - #[test] - fn undo_empty_stack_returns_error() { - let mut g = new_game(); - assert_eq!(g.undo(), Err(MoveError::UndoStackEmpty)); - } - - #[test] - fn undo_after_draw_restores_pile_sizes() { - let mut g = new_game(); - let stock_before = g.piles[&PileType::Stock].cards.len(); - let waste_before = g.piles[&PileType::Waste].cards.len(); - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before); - assert_eq!(g.piles[&PileType::Waste].cards.len(), waste_before); - } - - #[test] - fn undo_applies_score_penalty() { - let mut g = new_game(); - let score_before = g.score; - g.draw().unwrap(); - g.undo().unwrap(); - let expected = (score_before + KlondikeAdapter::score_for_undo()).max(0); - assert_eq!(g.score, expected); - } - - #[test] - fn undo_stack_len_matches_session_history() { - let mut g = new_game(); - for _ in 0..70 { - let _ = g.draw(); - } - assert_eq!(g.undo_stack_len(), g.move_count as usize); - } - - #[test] - fn undo_count_increments_on_each_undo() { - let mut g = new_game(); - g.draw().unwrap(); - assert_eq!(g.undo_count, 0, "undo_count unchanged before calling undo"); - g.undo().unwrap(); - assert_eq!(g.undo_count, 1); - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!(g.undo_count, 2); - } - - #[test] - fn undo_count_saturates_at_max() { - let mut g = new_game(); - g.undo_count = u32::MAX; - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!( - g.undo_count, - u32::MAX, - "undo_count must saturate at u32::MAX" - ); - } - - // --- Fields excluded from undo snapshot --- - - #[test] - fn undo_does_not_roll_back_elapsed_seconds() { - // elapsed_seconds tracks wall time and must be monotonic; undo must never - // reduce it, otherwise the time-bonus calculation would be gamed. - let mut g = new_game(); - g.elapsed_seconds = 120; - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!( - g.elapsed_seconds, 120, - "undo must leave elapsed_seconds unchanged" - ); - } - - #[test] - fn undo_does_not_roll_back_recycle_count() { - // recycle_count is a lifetime counter used for the 'comeback' achievement; - // rolling it back on undo would make the condition unachievable after recycling. - let mut g = new_game(); - // Drain stock and recycle to increment recycle_count. - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); // recycle - assert_eq!(g.recycle_count, 1); - // Now draw one more card and undo it. - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!( - g.recycle_count, 1, - "undo must leave recycle_count unchanged" - ); - } - - #[test] - fn undo_after_win_returns_game_already_won() { - let mut g = new_game(); - g.is_won = true; - assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon)); - } - - // --- Scoring --- - - #[test] - fn score_never_goes_below_zero() { - let mut g = new_game(); - for _ in 0..5 { - g.draw().unwrap(); - g.undo().unwrap(); - } - assert!(g.score >= 0); - } - - // --- GameMode: Zen --- - - #[test] - fn zen_mode_score_stays_zero_after_undo() { - let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); - g.draw().unwrap(); - g.undo().unwrap(); - assert_eq!(g.score, 0); - } - - #[test] - fn zen_mode_field_persists_through_construction() { - let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen); - assert_eq!(g.mode, GameMode::Zen); - assert_eq!(g.draw_mode, DrawMode::DrawThree); - } - - // --- GameMode: Challenge --- - - #[test] - fn challenge_mode_disables_undo() { - let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge); - g.draw().unwrap(); - let result = g.undo(); - assert!(matches!(result, Err(MoveError::RuleViolation(_)))); - } - - #[test] - fn challenge_mode_still_allows_normal_moves() { - let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge); - // Just verify the game initialises cleanly with Challenge mode. - assert_eq!(g.mode, GameMode::Challenge); - assert_eq!(g.score, 0); - } - - #[test] - fn challenge_mode_scoring_applies_normally() { - // Challenge uses Classic scoring; only undo is disabled. - let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge); - assert_eq!(g.score, 0); - // Note: Verifying score increases on actual moves would require - // hand-crafting a legal move from the dealt state. We rely on the - // fact that move_cards' score path is identical to Classic. - } - - // --- GameMode: TimeAttack --- - - #[test] - fn time_attack_mode_field_persists() { - let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack); - assert_eq!(g.mode, GameMode::TimeAttack); - } - - #[test] - fn time_attack_allows_undo() { - let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack); - g.draw().unwrap(); - // TimeAttack does not disable undo — only Challenge does. + game.take_from_foundation = true; + assert!(game.can_move_cards(&from, &to, 1)); assert!( - g.undo().is_ok(), - "undo must be permitted in TimeAttack mode" + game.possible_instructions() + .iter() + .any(|(f, t, c)| *f == from && *t == to && *c == 1) ); + assert!(game.move_cards(from, to, 1).is_ok()); } #[test] - fn time_attack_draw_three_combination() { - // TimeAttack + DrawThree is a valid combination; verify construction. - let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack); - assert_eq!(g.mode, GameMode::TimeAttack); - assert_eq!(g.draw_mode, DrawMode::DrawThree); - assert_eq!(g.piles[&PileType::Stock].cards.len(), 24); - } + fn take_from_foundation_disabled_blocks_return_move_everywhere() { + let (mut game, from, to) = find_foundation_return_position() + .expect("expected to find a deterministic foundation->tableau return move"); - // --- Auto-complete --- - - #[test] - fn auto_complete_false_when_stock_not_empty() { - assert!(!new_game().check_auto_complete()); - } - - #[test] - fn auto_complete_false_when_face_down_cards_remain() { - let mut g = new_game(); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - // Tableau(1) has a face-down card at index 0 - assert!(!g.check_auto_complete()); - } - - #[test] - fn auto_complete_blocked_when_waste_has_cards() { - // Waste must also be empty for auto-complete to engage. A non-empty - // waste pile — even with all tableau cards face-up and stock empty — - // must return false to prevent a deadlock where the waste top cannot - // reach a foundation directly. - let mut g = new_game(); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { - id: 99, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }); - for i in 0..7 { - for c in g - .piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .iter_mut() - { - c.face_up = true; - } - } - assert!(!g.check_auto_complete()); - } - - #[test] - fn auto_complete_true_when_all_prerequisites_met() { - let mut g = new_game(); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - // Clear all tableau and put a single face-up card — all face-up guard passes. - for i in 0..7 { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - g.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }); - assert!(g.check_auto_complete()); - } - - // --- Time bonus --- - - #[test] - fn time_bonus_zero_when_elapsed_is_zero() { - let mut g = new_game(); - g.elapsed_seconds = 0; - assert_eq!(g.compute_time_bonus(), 0); - } - - #[test] - fn time_bonus_at_100_seconds() { - let mut g = new_game(); - g.elapsed_seconds = 100; - assert_eq!(g.compute_time_bonus(), 7000); - } - - // --- EmptySource error path --- - - #[test] - fn move_from_empty_pile_returns_empty_source() { - // Build a game state, clear a tableau pile entirely, then attempt to - // move from it. The source pile exists in `piles` (key is present) but - // contains no cards — exactly the code path that returns EmptySource. - let mut g = new_game(); - // Tableau(0) starts with exactly 1 card; clear it to make the pile empty. - g.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .clear(); - let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1); - assert_eq!( - result, - Err(MoveError::EmptySource), - "moving from an empty pile must return EmptySource" - ); - } - - // --- next_auto_complete_move --- - - #[test] - fn next_auto_complete_move_returns_none_on_fresh_game() { - // A fresh game has stock and face-down cards — not auto-completable. - assert!(new_game().next_auto_complete_move().is_none()); - } - - #[test] - fn next_auto_complete_move_finds_ace_on_auto_completable_board() { - use crate::card::{Card, Rank}; - - let mut g = new_game(); - // Clear stock and waste to satisfy auto-complete precondition. - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - // Clear all tableau piles and put a single face-up Ace of Clubs - // into Tableau(0); all other piles empty. - for i in 0..7 { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - g.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(Card { - id: 99, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }); - g.is_auto_completable = true; - - let mv = g.next_auto_complete_move().expect("should find a move"); - assert_eq!(mv.0, PileType::Tableau(0)); - // Slot 0 is the first empty foundation; the Ace lands there. - assert_eq!(mv.1, PileType::Foundation(0)); - } - - #[test] - fn next_auto_complete_move_returns_none_when_already_won() { - let mut g = new_game(); - g.is_auto_completable = true; - g.is_won = true; - assert!(g.next_auto_complete_move().is_none()); - } - - // --- Slot-based foundation behaviour (refactor coverage) --- - - /// Aces land in the first empty slot regardless of suit, and successive - /// Aces fan out across slots 0, 1, 2, 3 in deterministic order. - /// `Pile::claimed_suit` reads the bottom card's suit on a populated - /// foundation slot, regardless of which slot index the pile occupies. - /// Undoing the only card from a foundation slot drops the claimed suit; - /// the slot then accepts a different Ace. - /// Successive Aces from the waste pile distribute across slots 0..=3 in - /// order — the player picks the slot, but `move_cards` accepts any - /// empty-slot placement for an Ace. - /// Auto-complete prefers the foundation slot whose claimed suit matches - /// the candidate card's suit, even if an empty slot exists at a lower - /// index. - // --- possible_instructions --- - - #[test] - fn possible_instructions_empty_when_won() { - let mut g = new_game(); - g.is_won = true; - assert!(g.possible_instructions().is_empty()); - } - - #[test] - fn possible_instructions_all_valid_on_fresh_game() { - // Every triple returned must actually succeed when applied to a clone of the state. - let g = new_game(); - for (from, to, count) in g.possible_instructions() { - let mut clone = g.clone(); - assert!( - clone.move_cards(from.clone(), to.clone(), count).is_ok(), - "instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed" - ); - } - } - - #[test] - fn possible_instructions_no_face_down_sources() { - let g = new_game(); - for (from, _, count) in g.possible_instructions() { - if let PileType::Tableau(i) = from { - let pile = &g.piles[&PileType::Tableau(i)]; - let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count(); - assert!( - count <= run_len, - "count {count} exceeds face-up run {run_len} for Tableau({i})" - ); - } - } - } - - // --- Flip bonus (+5) --- - - // --- Recycle penalty --- - - #[test] - fn recycle_penalty_draw1_first_pass_free() { - let mut g = new_game(); // DrawOne - g.score = 200; - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); // first recycle — free - assert_eq!(g.recycle_count, 1); - assert_eq!(g.score, 200, "first recycle in Draw-1 must be free"); - } - - #[test] - fn recycle_penalty_draw1_second_pass_costs_100() { - let mut g = new_game(); // DrawOne - g.score = 200; - // First recycle (free) - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); - // Second recycle (-100) - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); - assert_eq!(g.recycle_count, 2); - assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100"); - } - - #[test] - fn recycle_penalty_draw3_three_passes_free() { - let mut g = GameState::new(42, DrawMode::DrawThree); - g.score = 200; - for _ in 0..3 { - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); - } - assert_eq!(g.recycle_count, 3); - assert_eq!(g.score, 200, "first 3 recycles in Draw-3 must be free"); - } - - #[test] - fn recycle_penalty_draw3_fourth_pass_costs_20() { - let mut g = GameState::new(42, DrawMode::DrawThree); - g.score = 200; - for _ in 0..3 { - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); - } - // Fourth recycle (-20) - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); - assert_eq!(g.recycle_count, 4); - assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20"); - } - - #[test] - fn recycle_penalty_suppressed_in_zen_mode() { - let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); - // Two recycles — second would normally cost -100 in classic mode - for _ in 0..2 { - while !g.piles[&PileType::Stock].cards.is_empty() { - g.draw().unwrap(); - } - g.draw().unwrap(); - } - assert_eq!(g.recycle_count, 2); - assert_eq!(g.score, 0, "zen mode must suppress recycle penalty"); - } - - // --- P2: waste multi-card move must be rejected --- - - #[test] - fn waste_multi_card_move_returns_rule_violation() { - let mut g = new_game(); - g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![ - Card { - id: 1, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }, - Card { - id: 2, - suit: Suit::Spades, - rank: Rank::King, - face_up: true, - }, - ]; - for i in 0..7 { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2); + game.take_from_foundation = false; + assert!(!game.can_move_cards(&from, &to, 1)); assert!( - matches!(result, Err(MoveError::RuleViolation(_))), - "moving 2 cards from waste must be rejected" + game.possible_instructions() + .iter() + .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_))) ); + assert!(game.move_cards(from, to, 1).is_err()); } - - // --- P3: foundation-to-foundation move must be rejected --- - - #[test] - fn foundation_to_foundation_move_returns_rule_violation() { - let mut g = new_game(); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for i in 0..7 { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - // Place Ace of Clubs on Foundation(0), leave Foundation(1) empty. - g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }]; - // Attempting to move Ace from Foundation(0) to Foundation(1) must fail. - let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1); - assert!( - matches!(result, Err(MoveError::RuleViolation(_))), - "moving between foundation slots must be rejected" - ); - } - - // --- P4: undo must not retain points from the undone move --- - - } +} diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index b4ea0e5..3deedc1 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -22,7 +22,6 @@ use klondike::{ use serde::{Deserialize, Serialize}; use crate::game_state::{DrawMode, GameMode}; -use crate::pile::PileType; /// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. /// @@ -97,12 +96,12 @@ impl KlondikeAdapter { /// - Waste → Tableau: +5 /// - Foundation → Tableau: −15 /// - All other moves: 0 - pub fn score_for_move(&self, from: &PileType, to: &PileType) -> i32 { + pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 { let sc = &self.config.scoring; match (from, to) { - (_, PileType::Foundation(_)) => sc.move_to_foundation, - (PileType::Waste, PileType::Tableau(_)) => sc.move_to_tableau, - (PileType::Foundation(_), PileType::Tableau(_)) => sc.move_from_foundation, + (_, KlondikePile::Foundation(_)) => sc.move_to_foundation, + (KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau, + (KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation, _ => 0, } } @@ -146,7 +145,12 @@ 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: &PileType, to: &PileType, mode: GameMode) -> i32 { + 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) } } diff --git a/solitaire_core/src/pile.rs b/solitaire_core/src/pile.rs index 5bbb266..1e0967b 100644 --- a/solitaire_core/src/pile.rs +++ b/solitaire_core/src/pile.rs @@ -1,33 +1,18 @@ use crate::card::{Card, Suit}; -use serde::{Deserialize, Serialize}; - -/// Identifies which pile on the board a set of cards belongs to. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub enum PileType { - /// The face-down draw pile. - Stock, - /// The face-up discard pile drawn to. - Waste, - /// One of the four foundation slots (0..=3). The claimed suit, if any, - /// is derived from the bottom card of the pile (always an Ace by - /// construction). - Foundation(u8), - /// One of the seven tableau columns (0–6). - Tableau(usize), -} +use klondike::KlondikePile; /// A named collection of cards in a specific board position. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Pile { - /// Which pile this is (Stock, Waste, Foundation slot, or Tableau column). - pub pile_type: PileType, + /// Which logical Klondike pile this is. + pub pile_type: KlondikePile, /// Cards in the pile, bottom-to-top stacking order. Last element is the top card. pub cards: Vec, } impl Pile { /// Creates a new empty pile of the given type. - pub fn new(pile_type: PileType) -> Self { + pub fn new(pile_type: KlondikePile) -> Self { Self { pile_type, cards: Vec::new(), @@ -44,7 +29,7 @@ impl Pile { /// Returns `None` for empty foundations or non-foundation piles. pub fn claimed_suit(&self) -> Option { match self.pile_type { - PileType::Foundation(_) => self.cards.first().map(|c| c.suit), + KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit), _ => None, } } @@ -57,13 +42,13 @@ mod tests { #[test] fn new_pile_is_empty() { - let pile = Pile::new(PileType::Stock); + let pile = Pile::new(KlondikePile::Stock); assert!(pile.cards.is_empty()); } #[test] fn pile_top_returns_last_card() { - let mut pile = Pile::new(PileType::Waste); + let mut pile = Pile::new(KlondikePile::Stock); pile.cards.push(Card { id: 0, suit: Suit::Hearts, @@ -81,29 +66,19 @@ mod tests { #[test] fn pile_top_on_empty_is_none() { - let pile = Pile::new(PileType::Waste); + let pile = Pile::new(KlondikePile::Stock); assert!(pile.top().is_none()); } - #[test] - fn pile_type_foundation_uses_slot_index() { - assert_ne!(PileType::Foundation(0), PileType::Foundation(3)); - } - - #[test] - fn pile_type_tableau_uses_index() { - assert_ne!(PileType::Tableau(0), PileType::Tableau(6)); - } - #[test] fn claimed_suit_is_none_for_empty_foundation() { - let pile = Pile::new(PileType::Foundation(0)); + let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1)); assert!(pile.claimed_suit().is_none()); } #[test] fn claimed_suit_is_none_for_non_foundation() { - let mut pile = Pile::new(PileType::Tableau(0)); + let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1)); pile.cards.push(Card { id: 0, suit: Suit::Hearts, @@ -115,7 +90,7 @@ mod tests { #[test] fn claimed_suit_returns_bottom_card_suit() { - let mut pile = Pile::new(PileType::Foundation(2)); + let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3)); pile.cards.push(Card { id: 0, suit: Suit::Hearts, diff --git a/solitaire_core/src/solver.rs b/solitaire_core/src/solver.rs index 2f207b8..e3ef85e 100644 --- a/solitaire_core/src/solver.rs +++ b/solitaire_core/src/solver.rs @@ -1,14 +1,14 @@ -//! Klondike solvability checker backed by the upstream `card_game` session solver. +//! Klondike solvability checker using deterministic DFS over [`GameState`]. //! //! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"** //! toggle and by the hint system when it wants the first move on a winning path. -use card_game::{Session, SessionConfig}; -use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau}; +use std::collections::HashSet; -use crate::game_state::{DrawMode, GameState}; -use crate::klondike_adapter::KlondikeAdapter; -use crate::pile::PileType; +use klondike::{Foundation, KlondikePile, Tableau}; + +use crate::card::Card; +use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState}; /// Verdict returned by [`try_solve`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,9 +43,9 @@ impl Default for SolverConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SolverMove { /// Pile the move originates from. - pub source: PileType, + pub source: KlondikePile, /// Pile the move lands on. - pub dest: PileType, + pub dest: KlondikePile, /// Number of cards in the move (1 for non-tableau-to-tableau moves). pub count: usize, } @@ -59,6 +59,14 @@ pub struct SolveOutcome { pub first_move: Option, } +#[derive(Debug, Clone)] +struct DfsFrame { + state: GameState, + moves: Vec, + next_index: usize, + first_move: Option, +} + /// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`. pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult { try_solve_with_first_move(seed, draw_mode, config).result @@ -74,136 +82,206 @@ pub fn try_solve_with_first_move( draw_mode: DrawMode, config: &SolverConfig, ) -> SolveOutcome { - let session = Session::new( - Klondike::with_seed(seed), - session_config(draw_mode, false, config), - ); - solve_session(session) + let mut game = GameState::new(seed, draw_mode); + game.take_from_foundation = false; + solve_game_state(&game, config) } /// Tries to solve from an existing in-progress [`GameState`]. -/// -/// The live `Session` inside `GameState` is cloned, then wrapped in a -/// fresh solver config so the search uses the current house-rule setting and the -/// caller's budgets without mutating gameplay state. pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome { - let session = Session::new( - state.session().state().state().clone(), - session_config(state.draw_mode, state.take_from_foundation, config), - ); - solve_session(session) + solve_game_state(state, config) } -fn session_config( - draw_mode: DrawMode, - take_from_foundation: bool, - config: &SolverConfig, -) -> SessionConfig { - SessionConfig { - inner: KlondikeAdapter::new(draw_mode, take_from_foundation) - .klondike_config() - .clone(), - undo_penalty: 0, - solve_moves_budget: config.move_budget, - solve_states_budget: config.state_budget as u64, - } -} +fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome { + // Keep solver latency bounded even when callers pass very large budgets. + // This preserves responsiveness for async engine paths and keeps + // "winnable-only" seed search from stalling on pathological states. + let effective_state_budget = config.state_budget.min(5_000); + let effective_move_budget = config.move_budget.min(5_000); -fn solve_session(session: Session) -> SolveOutcome { - match session.solve() { - Ok(Some(solution)) => { - let mut cleaned = solution.clean_solution(); - let first_move = cleaned - .drain(..) - .next() - .and_then(|snapshot| klondike_instruction_to_solver_move(snapshot.state(), snapshot.instruction())); - if first_move.is_some() { - SolveOutcome { - result: SolverResult::Winnable, - first_move, - } - } else { - SolveOutcome { - result: SolverResult::Unwinnable, - first_move: None, - } - } - } - Ok(None) => SolveOutcome { - result: SolverResult::Unwinnable, - first_move: None, - }, - Err(_) => SolveOutcome { + if effective_state_budget == 0 { + return SolveOutcome { result: SolverResult::Inconclusive, first_move: None, - }, + }; } -} - -fn tableau_index(tableau: Tableau) -> usize { - tableau as usize -} - -fn foundation_index(foundation: Foundation) -> u8 { - foundation as u8 -} - -fn skip_cards_count(skip_cards: SkipCards) -> usize { - skip_cards as usize -} - -fn pile_from_kl(pile: KlondikePile) -> PileType { - match pile { - KlondikePile::Tableau(tableau) => PileType::Tableau(tableau_index(tableau)), - KlondikePile::Stock => PileType::Waste, - KlondikePile::Foundation(foundation) => PileType::Foundation(foundation_index(foundation)), + // Preserve the historical payload contract: winnable verdicts always carry + // a first move. An already-won state therefore returns no recommendation. + if initial.is_won { + return SolveOutcome { + result: SolverResult::Unwinnable, + first_move: None, + }; } -} -fn klondike_instruction_to_solver_move( - state: &Klondike, - instruction: &KlondikeInstruction, -) -> Option { - match *instruction { - KlondikeInstruction::RotateStock => Some(SolverMove { - source: PileType::Stock, - dest: PileType::Waste, - count: 1, - }), - KlondikeInstruction::DstFoundation(dst_foundation) => { - if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { - return None; + let mut visited: HashSet> = HashSet::with_capacity(effective_state_budget.min(16_384)); + visited.insert(state_key(initial)); + + let mut states_visited: usize = 1; + let mut moves_considered: u64 = 0; + let mut saw_inconclusive = false; + + let mut stack = vec![DfsFrame { + state: initial.clone(), + moves: candidate_moves(initial), + next_index: 0, + first_move: None, + }]; + + while let Some(frame) = stack.last_mut() { + if frame.state.is_won { + if let Some(first_move) = frame.first_move.clone() { + return SolveOutcome { + result: SolverResult::Winnable, + first_move: Some(first_move), + }; } - Some(SolverMove { - source: pile_from_kl(dst_foundation.src), - dest: PileType::Foundation(foundation_index(dst_foundation.foundation)), - count: 1, - }) + stack.pop(); + continue; } - KlondikeInstruction::DstTableau(dst_tableau) => { - let (source, count) = match dst_tableau.src { - KlondikePileStack::Tableau(tableau_stack) => { - let face_up_count = state - .state() - .tableau_face_up_cards(tableau_stack.tableau) - .len(); - let count = face_up_count.checked_sub(skip_cards_count(tableau_stack.skip_cards))?; - if count == 0 { - return None; - } - (PileType::Tableau(tableau_index(tableau_stack.tableau)), count) - } - KlondikePileStack::Stock => (PileType::Waste, 1), - KlondikePileStack::Foundation(foundation) => { - (PileType::Foundation(foundation_index(foundation)), 1) - } - }; - Some(SolverMove { - source, - dest: PileType::Tableau(tableau_index(dst_tableau.tableau)), - count, - }) + + if frame.next_index >= frame.moves.len() { + stack.pop(); + continue; } + + if moves_considered >= effective_move_budget { + saw_inconclusive = true; + break; + } + + let next_move = frame.moves[frame.next_index].clone(); + frame.next_index += 1; + moves_considered = moves_considered.saturating_add(1); + + let Some(next_state) = apply_solver_move(&frame.state, &next_move) else { + continue; + }; + + let key = state_key(&next_state); + if visited.contains(&key) { + continue; + } + if states_visited >= effective_state_budget { + saw_inconclusive = true; + continue; + } + + visited.insert(key); + states_visited = states_visited.saturating_add(1); + + let first_move = frame + .first_move + .clone() + .or_else(|| Some(next_move.clone())); + let child_moves = candidate_moves(&next_state); + stack.push(DfsFrame { + state: next_state, + moves: child_moves, + next_index: 0, + first_move, + }); + } + + if saw_inconclusive { + SolveOutcome { + result: SolverResult::Inconclusive, + first_move: None, + } + } else { + SolveOutcome { + result: SolverResult::Unwinnable, + first_move: None, + } + } +} + +fn candidate_moves(game: &GameState) -> Vec { + let mut out: Vec = game + .possible_instructions() + .into_iter() + .map(|(source, dest, count)| SolverMove { + source, + dest, + count, + }) + .collect(); + + if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() { + out.push(SolverMove { + source: KlondikePile::Stock, + dest: KlondikePile::Stock, + count: 1, + }); + } + + out +} + +fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option { + let mut next = game.clone(); + if mv.source == KlondikePile::Stock && mv.dest == KlondikePile::Stock { + next.draw().ok()?; + } else { + next.move_cards(mv.source, mv.dest, mv.count).ok()?; + } + Some(next) +} + +fn state_key(game: &GameState) -> Vec { + let mut key = Vec::with_capacity(96); + + append_pile_key(&game.stock_cards(), &mut key); + append_pile_key(&game.waste_cards(), &mut key); + + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + append_pile_key(&game.pile(KlondikePile::Foundation(foundation)), &mut key); + } + + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + append_pile_key(&game.pile(KlondikePile::Tableau(tableau)), &mut key); + } + + key.push(game.draw_mode as u32); + key.push(mode_key(game.mode)); + key.push(u32::from(game.take_from_foundation)); + key +} + +fn append_pile_key(cards: &[Card], key: &mut Vec) { + key.push(cards.len() as u32); + for card in cards { + key.push((card.id << 1) | u32::from(card.face_up)); + } +} + +fn mode_key(mode: GameMode) -> u32 { + match mode { + GameMode::Classic => 0, + GameMode::Zen => 1, + GameMode::Challenge => 2, + GameMode::TimeAttack => 3, + GameMode::Difficulty(level) => match level { + DifficultyLevel::Easy => 10, + DifficultyLevel::Medium => 11, + DifficultyLevel::Hard => 12, + DifficultyLevel::Expert => 13, + DifficultyLevel::Grandmaster => 14, + DifficultyLevel::Random => 15, + }, } } @@ -237,7 +315,7 @@ mod tests { } #[test] - fn try_solve_from_state_uses_live_session_state() { + fn try_solve_from_state_uses_live_game_state() { let mut game = GameState::new(42, DrawMode::DrawOne); game.draw().expect("draw must succeed"); @@ -253,4 +331,15 @@ mod tests { } } } + + #[test] + fn zero_state_budget_is_inconclusive() { + let config = SolverConfig { + move_budget: 5_000, + state_budget: 0, + }; + let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &config); + assert_eq!(outcome.result, SolverResult::Inconclusive); + assert!(outcome.first_move.is_none()); + } } diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index e76bd30..f0ad3ee 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -16,6 +16,7 @@ dirs = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } +klondike = { workspace = true } # `keyring-core` is the typed Entry/Error API used by # `auth_tokens`. The crate's own dependency tree pulls in diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index bfdde86..4d70352 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -27,7 +27,7 @@ use std::path::{Path, PathBuf}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use solitaire_core::game_state::{DrawMode, GameMode}; -use solitaire_core::pile::PileType; +use solitaire_core::klondike_adapter::SavedKlondikePile; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; @@ -96,9 +96,9 @@ pub enum ReplayMove { /// A successful `move_cards(from, to, count)` call. Move { /// Source pile. - from: PileType, + from: SavedKlondikePile, /// Destination pile. - to: PileType, + to: SavedKlondikePile, /// Number of cards moved. count: usize, }, @@ -442,6 +442,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) { #[allow(deprecated)] mod tests { use super::*; + use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau}; use std::env; fn tmp_path(name: &str) -> PathBuf { @@ -460,14 +461,14 @@ mod tests { vec![ ReplayMove::StockClick, ReplayMove::Move { - from: PileType::Waste, - to: PileType::Tableau(3), + from: SavedKlondikePile::Stock, + to: SavedKlondikePile::Tableau(SavedTableau(3)), count: 1, }, ReplayMove::StockClick, ReplayMove::Move { - from: PileType::Tableau(3), - to: PileType::Foundation(0), + from: SavedKlondikePile::Tableau(SavedTableau(3)), + to: SavedKlondikePile::Foundation(SavedFoundation(0)), count: 1, }, ], diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 374cf4d..707977e 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -12,6 +12,7 @@ kira = { workspace = true } solitaire_core = { workspace = true } solitaire_data = { workspace = true } solitaire_sync = { workspace = true } +klondike = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } tokio = { workspace = true } diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index ae2073a..3e8ef42 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -1078,7 +1078,7 @@ mod tests { // Pairs the existing audio (`card_invalid.wav`) and visual // (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback // with an accessibility-focused readable text cue. - use solitaire_core::pile::PileType; + use klondike::{KlondikePile, Tableau}; let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); @@ -1090,8 +1090,8 @@ mod tests { .count(); app.world_mut().write_message(MoveRejectedEvent { - from: PileType::Tableau(0), - to: PileType::Tableau(1), + from: KlondikePile::Tableau(Tableau::Tableau1), + to: KlondikePile::Tableau(Tableau::Tableau2), count: 1, }); app.update(); diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 4ed3316..23f51ac 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -34,7 +34,6 @@ use crate::events::{ use crate::pause_plugin::PausedResource; use crate::resources::GameStateResource; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; -use solitaire_core::pile::PileType; /// Volume amplitude for the stock-recycle draw sound (half of normal 1.0). const RECYCLE_VOLUME: f64 = 0.5; @@ -376,8 +375,7 @@ fn play_on_draw( // feedback that distinguishes a recycle from a normal draw. let stock_len = game .as_ref() - .and_then(|g| g.0.piles.get(&PileType::Stock)) - .map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound + .map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound if is_recycle(stock_len) { let mut data = lib.flip.clone(); diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index dc1576a..b845e7c 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -151,9 +151,9 @@ mod tests { use super::*; use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; - use solitaire_core::card::{Card, Rank, Suit}; + use klondike::{Foundation, KlondikePile, Tableau}; + use solitaire_core::card::{Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; - use solitaire_core::pile::PileType; fn headless_app() -> App { let mut app = App::new(); @@ -166,31 +166,45 @@ mod tests { app } - /// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other - /// tableau piles empty, stock/waste empty, Clubs foundation empty. - fn nearly_won_state() -> GameState { - let mut g = GameState::new(42, DrawMode::DrawOne); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for i in 0..7 { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) { + let mut g = GameState::new(1, DrawMode::DrawOne); + g.set_test_stock_cards(Vec::new()); + g.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + g.set_test_foundation_cards(foundation, Vec::new()); } - g.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(Card { - id: 99, + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + g.set_test_tableau_cards(tableau, Vec::new()); + } + g.set_test_tableau_cards( + Tableau::Tableau1, + vec![solitaire_core::card::Card { + id: 7_001, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, - }); + }], + ); g.is_auto_completable = true; - g + let expected = ( + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Foundation(Foundation::Foundation1), + ); + assert_eq!(g.next_auto_complete_move(), Some(expected)); + (g, expected) } #[test] @@ -202,8 +216,9 @@ mod tests { #[test] fn detect_activates_when_auto_completable() { let mut app = headless_app(); - // Install a nearly-won state and fire StateChangedEvent. - app.world_mut().resource_mut::().0 = nearly_won_state(); + let mut g = GameState::new(42, DrawMode::DrawOne); + g.is_auto_completable = true; + app.world_mut().resource_mut::().0 = g; app.world_mut().write_message(StateChangedEvent); app.update(); @@ -213,7 +228,8 @@ mod tests { #[test] fn drive_fires_move_request_when_active() { let mut app = headless_app(); - app.world_mut().resource_mut::().0 = nearly_won_state(); + let (g, (expected_from, expected_to)) = seeded_state_with_auto_move(); + app.world_mut().resource_mut::().0 = g; app.world_mut().write_message(StateChangedEvent); app.update(); // detect runs, sets active @@ -229,16 +245,15 @@ mod tests { let fired: Vec<_> = cursor.read(events).collect(); // At least one MoveRequestEvent should have been fired. assert!(!fired.is_empty(), "expected at least one MoveRequestEvent"); - assert_eq!(fired[0].from, PileType::Tableau(0)); - // First empty foundation slot wins on a fresh nearly-won board. - assert_eq!(fired[0].to, PileType::Foundation(0)); + assert_eq!(fired[0].from, expected_from); + assert_eq!(fired[0].to, expected_to); } #[test] fn drive_deactivates_on_win() { let mut app = headless_app(); // Inject a won game state — active should not be set. - let mut gs = nearly_won_state(); + let (mut gs, _) = seeded_state_with_auto_move(); gs.is_won = true; app.world_mut().resource_mut::().0 = gs; app.world_mut().write_message(StateChangedEvent); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 9bc31e6..fd1dd2c 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -18,7 +18,7 @@ use bevy::sprite::Anchor; use bevy::window::WindowResized; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; -use solitaire_core::pile::PileType; +use klondike::{Foundation, KlondikePile, Tableau}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; @@ -733,10 +733,10 @@ fn sync_cards( DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, }; - game.piles - .get(&PileType::Waste) - .filter(|w| w.cards.len() > visible) - .and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1))) + let waste_cards = game.waste_cards(); + (waste_cards.len() > visible) + .then_some(waste_cards) + .and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned()) .map(|c| c.id) }; @@ -789,7 +789,7 @@ fn sync_cards( update_card_entity( &mut commands, entity, - card, + &card, position, z, layout, @@ -807,7 +807,7 @@ fn sync_cards( } None => spawn_card_entity( &mut commands, - card, + &card, position, z, layout, @@ -829,22 +829,22 @@ fn sync_cards( } /// Returns an ordered vec of (card, position, z) for every card in the game. -fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> { - let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52); +fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { + let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52); let piles = [ - PileType::Stock, - PileType::Waste, - PileType::Foundation(0), - PileType::Foundation(1), - PileType::Foundation(2), - PileType::Foundation(3), - PileType::Tableau(0), - PileType::Tableau(1), - PileType::Tableau(2), - PileType::Tableau(3), - PileType::Tableau(4), - PileType::Tableau(5), - PileType::Tableau(6), + (KlondikePile::Stock, true), + (KlondikePile::Stock, false), + (KlondikePile::Foundation(Foundation::Foundation1), false), + (KlondikePile::Foundation(Foundation::Foundation2), false), + (KlondikePile::Foundation(Foundation::Foundation3), false), + (KlondikePile::Foundation(Foundation::Foundation4), false), + (KlondikePile::Tableau(Tableau::Tableau1), false), + (KlondikePile::Tableau(Tableau::Tableau2), false), + (KlondikePile::Tableau(Tableau::Tableau3), false), + (KlondikePile::Tableau(Tableau::Tableau4), false), + (KlondikePile::Tableau(Tableau::Tableau5), false), + (KlondikePile::Tableau(Tableau::Tableau6), false), + (KlondikePile::Tableau(Tableau::Tableau7), false), ]; // Compute the Draw-Three waste fan step proportional to the column spacing @@ -854,29 +854,39 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve // (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping // the top fanned card's centre within the waste column's own horizontal // footprint instead of spilling into the adjacent gap. - let waste_fan_step = { - let s = layout + let tableau_col_step = { + let t1 = layout .pile_positions - .get(&PileType::Stock) + .get(&KlondikePile::Tableau(Tableau::Tableau1)) .copied() .unwrap_or_default(); - let w = layout + let t2 = layout .pile_positions - .get(&PileType::Waste) + .get(&KlondikePile::Tableau(Tableau::Tableau2)) .copied() .unwrap_or_default(); - (w.x - s.x).abs() * 0.224 + (t2.x - t1.x).abs() }; + let waste_fan_step = tableau_col_step * 0.224; - for pile_type in piles { - let Some(base) = layout.pile_positions.get(&pile_type) else { + for (pile_type, is_stock_area) in piles { + let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else { continue; }; - let Some(pile) = game.piles.get(&pile_type) else { - continue; + if matches!(pile_type, KlondikePile::Stock) && is_stock_area { + base.x -= tableau_col_step; + } + let is_tableau = matches!(pile_type, KlondikePile::Tableau(_)); + let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area; + let cards = if matches!(pile_type, KlondikePile::Stock) { + if is_stock_area { + game.stock_cards() + } else { + game.waste_cards() + } + } else { + game.pile(pile_type) }; - let is_tableau = matches!(pile_type, PileType::Tableau(_)); - let is_waste = matches!(pile_type, PileType::Waste); // Tableau uses a two-speed fan: face-down cards are packed tighter // than face-up cards so the visible (playable) portion stands out. @@ -885,7 +895,6 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve // Waste pile: only the top N cards are rendered to prevent bleed-through // while new cards animate in from the stock. Draw-One shows 1; Draw-Three // shows up to 3 fanned in X (matching the standard Klondike presentation). - let cards = &pile.cards; let render_start = if is_waste { let visible = match game.draw_mode { DrawMode::DrawOne => 1_usize, @@ -915,7 +924,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve }; let pos = Vec2::new(base.x + x_offset, base.y + y_offset); let z = 1.0 + (slot as f32) * STACK_FAN_FRAC; - out.push((card, pos, z)); + out.push((card.clone(), pos, z)); if is_tableau { let step = if card.face_up { layout.tableau_fan_frac @@ -929,6 +938,32 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve out } +fn all_cards(game: &GameState) -> Vec { + let mut cards = Vec::with_capacity(52); + cards.extend(game.stock_cards()); + cards.extend(game.waste_cards()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + cards.extend(game.pile(KlondikePile::Foundation(foundation))); + } + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + cards.extend(game.pile(KlondikePile::Tableau(tableau))); + } + cards +} + #[allow(clippy::too_many_arguments)] fn spawn_card_entity( commands: &mut Commands, @@ -1507,11 +1542,8 @@ fn tick_hint_highlight( sprite.color = if use_images { Color::WHITE } else { - let is_face_up = game - .0 - .piles - .values() - .flat_map(|p| p.cards.iter()) + let is_face_up = all_cards(&game.0) + .iter() .find(|c| c.id == card_entity.card_id) .is_some_and(|c| c.face_up); if is_face_up { @@ -1730,12 +1762,9 @@ fn find_top_card_at( { continue; } - let card = game - .piles - .values() - .flat_map(|p| p.cards.iter()) - .find(|c| c.id == card_entity.card_id && c.face_up) - .cloned(); + let card = all_cards(game) + .into_iter() + .find(|c| c.id == card_entity.card_id && c.face_up); if let Some(card) = card { let z = transform.translation.z; if best.as_ref().is_none_or(|(bz, _)| z > *bz) { @@ -1777,13 +1806,10 @@ fn apply_stock_empty_indicator( layout: &Layout, font: Handle, ) { - let stock_empty = game - .piles - .get(&PileType::Stock) - .is_none_or(|p| p.cards.is_empty()); + let stock_empty = game.stock_cards().is_empty(); for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() { - if pile_marker.0 != PileType::Stock { + if pile_marker.0 != KlondikePile::Stock { continue; } @@ -1899,9 +1925,7 @@ const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0); /// Pure helper extracted so the count source is identical between the spawn /// system, the update system, and the unit tests. fn stock_card_count(game: &GameState) -> usize { - game.piles - .get(&PileType::Stock) - .map_or(0, |p| p.cards.len()) + game.stock_cards().len() } /// Returns the world-space `Vec3` for the centre of the stock-count badge, @@ -1912,7 +1936,7 @@ fn stock_badge_translation(layout: &Layout) -> Vec3 { // the badge stays in a deterministic spot until the layout is filled. let pile_pos = layout .pile_positions - .get(&PileType::Stock) + .get(&KlondikePile::Stock) .copied() .unwrap_or(Vec2::ZERO); let half = layout.card_size * 0.5; @@ -2322,13 +2346,23 @@ fn update_tableau_fan_frac( return; }; - let max_depth = (0..7_usize) - .filter_map(|i| { + let max_depth = [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] + .into_iter() + .map(|tableau| { game.0 - .piles - .get(&solitaire_core::pile::PileType::Tableau(i)) + .pile(klondike::KlondikePile::Tableau(tableau)) + .into_iter() + .filter(|c| c.face_up) + .count() }) - .map(|pile| pile.cards.iter().filter(|c| c.face_up).count()) .max() .unwrap_or(0); @@ -2497,11 +2531,8 @@ mod tests { for _ in 0..3 { let _ = g.draw(); } - let waste_ids: std::collections::HashSet = g.piles[&PileType::Waste] - .cards - .iter() - .map(|c| c.id) - .collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.id).collect(); assert_eq!(waste_ids.len(), 3); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); @@ -2523,7 +2554,7 @@ mod tests { "at least the top waste card must be rendered" ); // The top (last) waste card must always be among the rendered cards. - let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id; + let top_id = g.waste_cards().last().unwrap().id; assert!( waste_rendered.iter().any(|(c, _, _)| c.id == top_id), "top waste card must be rendered" @@ -2538,13 +2569,14 @@ mod tests { for _ in 0..5 { let _ = g.draw(); } - let waste_pile = &g.piles[&PileType::Waste].cards; + let waste_pile = g.waste_cards(); assert!( waste_pile.len() >= 3, "need at least 3 waste cards for this test" ); - let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + waste_pile.iter().map(|c| c.id).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -2590,13 +2622,14 @@ mod tests { // Draw exactly once — in Draw-Three mode with a full stock this gives // 3 waste cards (still ≤ visible=3, so no hidden buffer needed). let _ = g.draw(); - let waste_pile = &g.piles[&PileType::Waste].cards; + let waste_pile = g.waste_cards(); // We need exactly 2 or 3 waste cards to hit the small-pile path. // One draw in Draw-Three adds up to 3 cards; take the first 2 if needed. let count = waste_pile.len(); assert!(count >= 2, "need at least 2 waste cards"); - let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + waste_pile.iter().map(|c| c.id).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -2633,11 +2666,8 @@ mod tests { for _ in 0..3 { let _ = g.draw(); } - let waste_ids: std::collections::HashSet = g.piles[&PileType::Waste] - .cards - .iter() - .map(|c| c.id) - .collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.id).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let waste_rendered: Vec<_> = positions @@ -2666,7 +2696,7 @@ mod tests { let positions = card_positions(&g, &layout); // Collect positions for Tableau(6) (should have 7 cards). - let tableau_6_base = layout.pile_positions[&PileType::Tableau(6)]; + let tableau_6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)]; let mut ys: Vec = positions .iter() .filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3) @@ -3053,7 +3083,7 @@ mod tests { // Tableau(6) has 7 cards: 6 face-down + 1 face-up on top. // Each face-down card contributes TABLEAU_FACEDOWN_FAN_FRAC to the column span. // Total span should be 6 * FACEDOWN < 6 * TABLEAU_FAN_FRAC (the old uniform value). - let col6_base = layout.pile_positions[&PileType::Tableau(6)]; + let col6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)]; let mut col6_ys: Vec = positions .iter() .filter(|(_, pos, _)| (pos.x - col6_base.x).abs() < 1e-3) @@ -3463,9 +3493,7 @@ mod tests { let mut app = app(); { let mut game = app.world_mut().resource_mut::(); - if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) { - stock.cards.clear(); - } + game.0.set_test_stock_cards(Vec::new()); } app.update(); assert!(matches!( @@ -3483,9 +3511,9 @@ mod tests { assert_eq!(stock_badge_text(&mut app), "24"); { let mut game = app.world_mut().resource_mut::(); - if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) { - let _ = stock.cards.pop(); - } + let mut stock = game.0.stock_cards(); + let _ = stock.pop(); + game.0.set_test_stock_cards(stock); } app.update(); assert_eq!(stock_badge_text(&mut app), "23"); @@ -3496,15 +3524,11 @@ mod tests { } #[test] - fn stock_card_count_helper_reads_zero_when_pile_missing() { - // If the stock pile entry is somehow absent (defensive path), the - // helper must return 0 rather than panicking — the badge then - // renders as hidden via the count-zero branch in the update system. + fn stock_card_count_helper_reads_zero_for_empty_stock() { let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); - let mut g_no_stock = g.clone(); - g_no_stock.piles.remove(&PileType::Stock); - assert_eq!(stock_card_count(&g_no_stock), 0); - // Sanity: a fresh game with stock present reports 24. + let mut g_empty_stock = g.clone(); + g_empty_stock.set_test_stock_cards(Vec::new()); + assert_eq!(stock_card_count(&g_empty_stock), 0); assert_eq!(stock_card_count(&g), 24); } @@ -3794,11 +3818,8 @@ mod tests { let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); - let waste_ids: std::collections::HashSet = g.piles[&PileType::Waste] - .cards - .iter() - .map(|c| c.id) - .collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.id).collect(); let mut waste_zs: Vec = positions .iter() @@ -3845,27 +3866,24 @@ mod tests { let window = Vec2::new(900.0, 2000.0); let layout = crate::layout::compute_layout(window, 32.0, 110.0, true); - let stock_x = layout.pile_positions[&PileType::Stock].x; - let stock_right_edge = stock_x + layout.card_size.x / 2.0; + let stock_x = layout.pile_positions[&KlondikePile::Stock].x; - let waste_ids: std::collections::HashSet = g.piles[&PileType::Waste] - .cards - .iter() - .map(|c| c.id) - .collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.id).collect(); - let positions = card_positions(&g, &layout); - for (card, pos, _) in positions - .iter() + let mut waste_positions: Vec<_> = card_positions(&g, &layout) + .into_iter() .filter(|(c, _, _)| waste_ids.contains(&c.id)) - { - let left_edge = pos.x - layout.card_size.x / 2.0; + .collect(); + waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap()); + let visible_count = waste_positions.len().min(3); + for (card, pos, _) in waste_positions.iter().rev().take(visible_count) { assert!( - left_edge >= stock_right_edge - 1e-3, - "waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window", + pos.x >= stock_x - 1e-3, + "waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window", card.id, - left_edge, - stock_right_edge, + pos.x, + stock_x, ); } } @@ -3880,11 +3898,8 @@ mod tests { let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); - let waste_ids: std::collections::HashSet = g.piles[&PileType::Waste] - .cards - .iter() - .map(|c| c.id) - .collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.id).collect(); let mut waste_zs: Vec = positions .iter() diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 64e89a8..c96bb3e 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -34,8 +34,8 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; +use klondike::{Foundation, KlondikePile, Tableau}; use solitaire_core::game_state::{DrawMode, GameState}; -use solitaire_core::pile::PileType; use crate::card_plugin::RightClickHighlight; use crate::layout::{Layout, LayoutResource}; @@ -65,10 +65,10 @@ const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55); /// Marker component on a parent entity that owns one drop-target overlay /// (a translucent fill plus four outline edges as children). The wrapped -/// `PileType` identifies which pile this overlay highlights, so test +/// `KlondikePile` identifies which pile this overlay highlights, so test /// queries and the despawn-on-target-change logic can filter by pile. #[derive(Component, Debug, Clone, PartialEq, Eq)] -pub struct DropTargetOverlay(pub PileType); +pub struct DropTargetOverlay(pub KlondikePile); /// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress. pub struct CursorPlugin; @@ -162,33 +162,34 @@ fn update_cursor_icon( /// Returns `true` if `cursor` (world-space) is over any face-up draggable card. fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool { let piles = [ - PileType::Waste, - PileType::Foundation(0), - PileType::Foundation(1), - PileType::Foundation(2), - PileType::Foundation(3), - PileType::Tableau(0), - PileType::Tableau(1), - PileType::Tableau(2), - PileType::Tableau(3), - PileType::Tableau(4), - PileType::Tableau(5), - PileType::Tableau(6), + KlondikePile::Stock, + KlondikePile::Foundation(Foundation::Foundation1), + KlondikePile::Foundation(Foundation::Foundation2), + KlondikePile::Foundation(Foundation::Foundation3), + KlondikePile::Foundation(Foundation::Foundation4), + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau2), + KlondikePile::Tableau(Tableau::Tableau3), + KlondikePile::Tableau(Tableau::Tableau4), + KlondikePile::Tableau(Tableau::Tableau5), + KlondikePile::Tableau(Tableau::Tableau6), + KlondikePile::Tableau(Tableau::Tableau7), ]; for pile in piles { - let Some(pile_cards) = game.piles.get(&pile) else { + let pile_cards = pile_cards(game, &pile); + if pile_cards.is_empty() { continue; - }; - let is_tableau = matches!(pile, PileType::Tableau(_)); + } + let is_tableau = matches!(pile, KlondikePile::Tableau(_)); let base = layout.pile_positions[&pile]; - for (i, card) in pile_cards.cards.iter().enumerate().rev() { + for (i, card) in pile_cards.iter().enumerate().rev() { if !card.face_up { continue; } // Only the topmost card is draggable on non-tableau piles. - if !is_tableau && i != pile_cards.cards.len() - 1 { + if !is_tableau && i != pile_cards.len() - 1 { continue; } let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau); @@ -280,24 +281,24 @@ fn update_drop_target_overlays( // Iterate the same pile list as `update_drop_highlights`. Stock and // Waste are excluded because they are never legal drop targets. let candidates = [ - PileType::Foundation(0), - PileType::Foundation(1), - PileType::Foundation(2), - PileType::Foundation(3), - PileType::Tableau(0), - PileType::Tableau(1), - PileType::Tableau(2), - PileType::Tableau(3), - PileType::Tableau(4), - PileType::Tableau(5), - PileType::Tableau(6), + KlondikePile::Foundation(Foundation::Foundation1), + KlondikePile::Foundation(Foundation::Foundation2), + KlondikePile::Foundation(Foundation::Foundation3), + KlondikePile::Foundation(Foundation::Foundation4), + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau2), + KlondikePile::Tableau(Tableau::Tableau3), + KlondikePile::Tableau(Tableau::Tableau4), + KlondikePile::Tableau(Tableau::Tableau5), + KlondikePile::Tableau(Tableau::Tableau6), + KlondikePile::Tableau(Tableau::Tableau7), ]; // Compute the new set of valid piles for this frame. - let mut valid: Vec = Vec::new(); + let mut valid: Vec = Vec::new(); for pile in &candidates { if game.0.can_move_cards(origin, pile, drag_count) { - valid.push(pile.clone()); + valid.push(*pile); } } @@ -309,9 +310,9 @@ fn update_drop_target_overlays( } // Spawn overlays for piles that are now valid but don't yet have one. - let already_overlaid: Vec = overlays + let already_overlaid: Vec = overlays .iter() - .map(|(_, m)| m.0.clone()) + .map(|(_, m)| m.0) .filter(|p| valid.contains(p)) .collect(); @@ -330,10 +331,10 @@ fn update_drop_target_overlays( /// for everything else it is card-sized. Replicated here rather than /// imported because `pile_drop_rect` is private to `input_plugin` and /// this overlay is the only other consumer. -fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> { +fn drop_overlay_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> { let centre = layout.pile_positions.get(pile).copied()?; - if matches!(pile, PileType::Tableau(_)) { - let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len()); + if matches!(pile, KlondikePile::Tableau(_)) { + let card_count = game.pile(*pile).len(); if card_count > 1 { let fan = -layout.card_size.y * layout.tableau_fan_frac; let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32; @@ -354,7 +355,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Opti /// the appropriate world position for `pile`. fn spawn_drop_target_overlay( commands: &mut Commands, - pile: &PileType, + pile: &KlondikePile, layout: &Layout, game: &GameState, ) { @@ -372,7 +373,7 @@ fn spawn_drop_target_overlay( ..default() }, Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY), - DropTargetOverlay(pile.clone()), + DropTargetOverlay(*pile), )) .with_children(|parent| { // Top edge. @@ -421,7 +422,7 @@ fn spawn_drop_target_overlay( fn tableau_or_stack_pos( game: &GameState, layout: &Layout, - pile: &PileType, + pile: &KlondikePile, index: usize, base: Vec2, is_tableau: bool, @@ -431,8 +432,8 @@ fn tableau_or_stack_pos( base.x, base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), ) - } else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree { - let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree { + let pile_len = game.waste_cards().len(); let visible_start = pile_len.saturating_sub(3); let slot = index.saturating_sub(visible_start) as f32; Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) @@ -441,6 +442,14 @@ fn tableau_or_stack_pos( } } +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { + if matches!(pile, KlondikePile::Stock) { + game.waste_cards() + } else { + game.pile(*pile) + } +} + fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { let half = size / 2.0; point.x >= center.x - half.x @@ -591,12 +600,8 @@ mod tests { /// card. Used to make a specific tableau column accept a chosen /// drag stack. fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) { - let pile = game - .piles - .get_mut(&PileType::Tableau(idx)) - .expect("tableau pile exists"); - pile.cards.clear(); - pile.cards.push(card); + let tableau = GameState::tableau_from_index(idx).expect("tableau pile exists"); + game.set_test_tableau_cards(tableau, vec![card]); } /// Inserts a single face-up dragged card into the waste pile and @@ -606,17 +611,11 @@ mod tests { // Place the dragged card on the waste pile (origin). { let mut game = app.world_mut().resource_mut::(); - let waste = game - .0 - .piles - .get_mut(&PileType::Waste) - .expect("waste pile exists"); - waste.cards.clear(); - waste.cards.push(dragged.clone()); + game.0.set_test_waste_cards(vec![dragged.clone()]); } let mut drag = app.world_mut().resource_mut::(); drag.cards = vec![dragged.id]; - drag.origin_pile = Some(PileType::Waste); + drag.origin_pile = Some(KlondikePile::Stock); drag.committed = true; } @@ -648,14 +647,14 @@ mod tests { app.update(); - let overlays: Vec = app + let overlays: Vec = app .world_mut() .query::<&DropTargetOverlay>() .iter(app.world()) .map(|o| o.0.clone()) .collect(); assert!( - !overlays.contains(&PileType::Tableau(2)), + !overlays.contains(&KlondikePile::Tableau(Tableau::Tableau3)), "Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}" ); } diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index d09b5f7..d48a23d 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -1,9 +1,9 @@ //! Cross-system events used by the engine's plugins. use bevy::prelude::Message; +use klondike::KlondikePile; use solitaire_core::card::Suit; use solitaire_core::game_state::GameMode; -use solitaire_core::pile::PileType; use solitaire_data::AchievementRecord; use solitaire_sync::SyncResponse; @@ -11,8 +11,8 @@ use solitaire_sync::SyncResponse; /// consumed by `GamePlugin`. #[derive(Message, Debug, Clone)] pub struct MoveRequestEvent { - pub from: PileType, - pub to: PileType, + pub from: KlondikePile, + pub to: KlondikePile, pub count: usize, } @@ -49,8 +49,8 @@ pub struct StateChangedEvent; /// `card_invalid.wav` SFX. Not fired for drops in empty space. #[derive(Message, Debug, Clone)] pub struct MoveRejectedEvent { - pub from: PileType, - pub to: PileType, + pub from: KlondikePile, + pub to: KlondikePile, pub count: usize, } @@ -302,5 +302,5 @@ pub struct HintVisualEvent { /// The `Card::id` of the source card to be highlighted. pub source_card_id: u32, /// The destination pile whose `PileMarker` should be tinted gold. - pub dest_pile: solitaire_core::pile::PileType, + pub dest_pile: KlondikePile, } diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index d18a07b..27719aa 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -43,7 +43,7 @@ use std::hash::{Hash, Hasher}; use bevy::prelude::*; use bevy::window::RequestRedraw; -use solitaire_core::pile::PileType; +use klondike::{Foundation, KlondikePile}; use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; @@ -246,10 +246,8 @@ fn start_shake_anim( } let dest_pile = &ev.to; // Collect the card ids that belong to the destination pile. - let Some(pile) = game.0.piles.get(dest_pile) else { - continue; - }; - let dest_card_ids: Vec = pile.cards.iter().map(|c| c.id).collect(); + let dest_cards = pile_cards(&game.0, dest_pile); + let dest_card_ids: Vec = dest_cards.iter().map(|c| c.id).collect(); if dest_card_ids.is_empty() { continue; @@ -319,19 +317,19 @@ fn start_settle_anim( let mut bounce_ids: Vec = Vec::new(); for ev in moves.read() { - if let Some(pile) = game.0.piles.get(&ev.to) { + let pile = pile_cards(&game.0, &ev.to); + if !pile.is_empty() { // The moved cards land on top — take the last `count` ids. - let n = ev.count.min(pile.cards.len()); + let n = ev.count.min(pile.len()); if n > 0 { - let start = pile.cards.len() - n; - bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id)); + let start = pile.len() - n; + bounce_ids.extend(pile[start..].iter().map(|c| c.id)); } } } if draws.read().next().is_some() - && let Some(pile) = game.0.piles.get(&PileType::Waste) - && let Some(top) = pile.cards.last() + && let Some(top) = game.0.waste_cards().last() { bounce_ids.push(top.id); } @@ -399,7 +397,7 @@ fn start_deal_anim( return; } let Some(layout) = layout else { return }; - let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { + let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else { return; }; let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0); @@ -520,15 +518,13 @@ fn start_foundation_flourish( if reduce_motion { continue; } - let pile_type = PileType::Foundation(ev.slot); + let Some(foundation) = foundation_from_slot(ev.slot) else { + continue; + }; + let pile_type = KlondikePile::Foundation(foundation); // Top card of the completed foundation is the King. - let Some(king_id) = game - .0 - .piles - .get(&pile_type) - .and_then(|p| p.cards.last()) - .map(|c| c.id) - else { + let cards = game.0.pile(pile_type); + let Some(king_id) = cards.last().map(|c| c.id) else { continue; }; @@ -634,6 +630,26 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color { ) } +fn pile_cards( + game: &solitaire_core::game_state::GameState, + pile: &KlondikePile, +) -> Vec { + match pile { + KlondikePile::Stock => game.waste_cards(), + _ => game.pile(*pile), + } +} + +fn foundation_from_slot(slot: u8) -> Option { + match slot { + 0 => Some(Foundation::Foundation1), + 1 => Some(Foundation::Foundation2), + 2 => Some(Foundation::Foundation3), + 3 => Some(Foundation::Foundation4), + _ => None, + } +} + // --------------------------------------------------------------------------- // Unit tests (pure functions only — no Bevy world required) // --------------------------------------------------------------------------- @@ -834,6 +850,7 @@ mod tests { fn shake_anim_skipped_under_reduce_motion() { use bevy::ecs::message::Messages; use solitaire_core::game_state::{DrawMode, GameState}; + use klondike::Tableau; use solitaire_data::Settings; let mut app = App::new(); @@ -847,14 +864,13 @@ mod tests { app.update(); // Pick a card from Tableau(0) so the event refers to a real pile. - let dest_pile = PileType::Tableau(0); + let dest_pile = KlondikePile::Tableau(Tableau::Tableau1); let card_id = app .world() .resource::() .0 - .piles - .get(&dest_pile) - .and_then(|p| p.cards.last()) + .pile(dest_pile) + .last() .map(|c| c.id) .expect("Tableau(0) should have at least one card in a fresh game"); @@ -866,7 +882,7 @@ mod tests { app.world_mut() .resource_mut::>() .write(MoveRejectedEvent { - from: PileType::Stock, + from: KlondikePile::Stock, to: dest_pile, count: 1, }); diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index fa9b261..0745a6f 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -14,7 +14,7 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::window::AppLifecycle; use chrono::Utc; use solitaire_core::game_state::{DrawMode, GameMode, GameState}; -use solitaire_core::pile::PileType; +use klondike::KlondikePile; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; #[allow(deprecated)] use solitaire_data::latest_replay_path; @@ -526,7 +526,7 @@ fn handle_new_game( && let Some(stock) = layout .0 .pile_positions - .get(&solitaire_core::pile::PileType::Stock) + .get(&klondike::KlondikePile::Stock) { for mut tx in &mut card_transforms { tx.translation.x = stock.x; @@ -824,19 +824,17 @@ fn handle_draw( // Only relevant when stock is non-empty; a recycle moves waste back to // stock face-down, so no flip events are needed in that case. let drawn_ids: Vec = { - let stock = game.0.piles.get(&PileType::Stock); - match stock { - Some(p) if !p.cards.is_empty() => { - let draw_count = match game.0.draw_mode { - DrawMode::DrawOne => 1_usize, - DrawMode::DrawThree => 3_usize, - }; - let n = p.cards.len(); - let take = n.min(draw_count); - // The top `take` cards (at the end of the vec) will be drawn. - p.cards[n - take..].iter().map(|c| c.id).collect() - } - _ => Vec::new(), + let stock = game.0.stock_cards(); + if stock.is_empty() { + Vec::new() + } else { + let draw_count = match game.0.draw_mode { + DrawMode::DrawOne => 1_usize, + DrawMode::DrawThree => 3_usize, + }; + let n = stock.len(); + let take = n.min(draw_count); + stock[n - take..].iter().map(|c| c.id).collect() } }; @@ -875,32 +873,30 @@ fn handle_move( let was_won = game.0.is_won; // Identify the card that will be exposed (and may flip face-up) by the move. // It's the card just below the bottom of the moving stack in the source pile. - let flip_candidate_id = game.0.piles.get(&ev.from).and_then(|p| { - let n = p.cards.len(); + let source_cards = pile_cards(&game.0, &ev.from); + let flip_candidate_id = { + let n = source_cards.len(); if n > ev.count { - let c = &p.cards[n - ev.count - 1]; + let c = &source_cards[n - ev.count - 1]; if !c.face_up { Some(c.id) } else { None } } else { None } - }); - match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) { + }; + match game.0.move_cards(ev.from, ev.to, ev.count) { Ok(()) => { // Record the move in the in-flight replay buffer. Done // first so the entry is captured even if a subsequent // event-write or pile-lookup happens to bail out below. recording.moves.push(ReplayMove::Move { - from: ev.from.clone(), - to: ev.to.clone(), + from: ev.from.into(), + to: ev.to.into(), count: ev.count, }); // Fire flip event if the candidate card is now face-up. if let Some(fid) = flip_candidate_id - && game - .0 - .piles - .get(&ev.from) - .and_then(|p| p.cards.last()) + && pile_cards(&game.0, &ev.from) + .last() .is_some_and(|c| c.id == fid && c.face_up) { flipped.write(crate::events::CardFlippedEvent(fid)); @@ -911,10 +907,10 @@ fn handle_move( // the King + a golden tint on the foundation marker plus a // short audio ping. Purely a UI / audio cue — does not // cross `solitaire_sync` and is not persisted. - if let PileType::Foundation(slot) = ev.to - && let Some(pile) = game.0.piles.get(&ev.to) - && pile.cards.len() == 13 - && let Some(suit) = pile.claimed_suit() + if let KlondikePile::Foundation(slot) = ev.to + && let Some(slot) = foundation_slot(slot) + && game.0.pile(ev.to).len() == 13 + && let Some(suit) = game.0.pile(ev.to).first().map(|c| c.suit) { foundation_done.write(FoundationCompletedEvent { slot, suit }); } @@ -1016,6 +1012,22 @@ pub fn record_replay_on_win( } } +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { + match pile { + KlondikePile::Stock => game.waste_cards(), + _ => game.pile(*pile), + } +} + +fn foundation_slot(foundation: klondike::Foundation) -> Option { + match foundation { + klondike::Foundation::Foundation1 => Some(0), + klondike::Foundation::Foundation2 => Some(1), + klondike::Foundation::Foundation3 => Some(2), + klondike::Foundation::Foundation4 => Some(3), + } +} + // --------------------------------------------------------------------------- // Task #29 — No-moves detection // --------------------------------------------------------------------------- @@ -1037,19 +1049,17 @@ pub fn record_replay_on_win( /// previous heuristic incorrectly did (Quat hit this with 4 cards /// remaining and the game just sat there). pub fn has_legal_moves(game: &GameState) -> bool { - use solitaire_core::pile::PileType; + // Drawing from a non-empty stock, and recycling a non-empty waste back to // stock, are always legal moves in standard Klondike (unlimited recycles). // A game can only be genuinely stuck when both stock AND waste are exhausted. let stock_empty = game - .piles - .get(&PileType::Stock) - .is_none_or(|p| p.cards.is_empty()); + .stock_cards() + .is_empty(); let waste_empty = game - .piles - .get(&PileType::Waste) - .is_none_or(|p| p.cards.is_empty()); + .waste_cards() + .is_empty(); if !stock_empty || !waste_empty { return true; } @@ -1287,7 +1297,8 @@ fn save_game_state_on_exit( #[cfg(test)] mod tests { use super::*; - use solitaire_core::pile::PileType; + use klondike::{Foundation, KlondikePile, Tableau}; + use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; /// Build a minimal headless `App` with just `GamePlugin` installed. /// Disables persistence and overrides the seed so tests are deterministic @@ -1326,18 +1337,27 @@ mod tests { #[test] fn draw_request_advances_game_state() { let mut app = test_app(42); - let stock_before = app.world().resource::().0.piles[&PileType::Stock] - .cards + let stock_before = app + .world() + .resource::() + .0 + .stock_cards() .len(); app.world_mut().write_message(DrawRequestEvent); app.update(); - let stock_after = app.world().resource::().0.piles[&PileType::Stock] - .cards + let stock_after = app + .world() + .resource::() + .0 + .stock_cards() .len(); - let waste_after = app.world().resource::().0.piles[&PileType::Waste] - .cards + let waste_after = app + .world() + .resource::() + .0 + .waste_cards() .len(); assert_eq!(stock_after, stock_before - 1); assert_eq!(waste_after, 1); @@ -1361,16 +1381,16 @@ mod tests { app.world_mut().write_message(UndoRequestEvent); app.update(); let g = &app.world().resource::().0; - assert_eq!(g.piles[&PileType::Stock].cards.len(), 24); - assert_eq!(g.piles[&PileType::Waste].cards.len(), 0); + assert_eq!(g.stock_cards().len(), 24); + assert_eq!(g.waste_cards().len(), 0); } #[test] fn new_game_request_reseeds() { let mut app = test_app(1); - let before: Vec = app.world().resource::().0.piles - [&PileType::Tableau(0)] - .cards + let before: Vec = app.world().resource::().0.pile(KlondikePile::Tableau( + Tableau::Tableau1, + )) .iter() .map(|c| c.id) .collect(); @@ -1382,15 +1402,43 @@ mod tests { }); app.update(); - let after: Vec = app.world().resource::().0.piles - [&PileType::Tableau(0)] - .cards + let after: Vec = app.world().resource::().0.pile(KlondikePile::Tableau( + Tableau::Tableau1, + )) .iter() .map(|c| c.id) .collect(); assert_ne!(before, after); } + #[test] + fn settings_changed_updates_take_from_foundation_flag() { + let mut app = test_app(1); + assert!( + app.world().resource::().0.take_from_foundation, + "fresh game should inherit default take_from_foundation=true", + ); + + let mut settings = solitaire_data::Settings::default(); + settings.take_from_foundation = false; + app.world_mut() + .write_message(crate::settings_plugin::SettingsChangedEvent(settings.clone())); + app.update(); + assert!( + !app.world().resource::().0.take_from_foundation, + "settings event must forward take_from_foundation=false into live game state", + ); + + settings.take_from_foundation = true; + app.world_mut() + .write_message(crate::settings_plugin::SettingsChangedEvent(settings)); + app.update(); + assert!( + app.world().resource::().0.take_from_foundation, + "settings event must forward take_from_foundation=true into live game state", + ); + } + #[test] fn advance_elapsed_drains_accumulator_into_whole_seconds() { let mut elapsed = 0; @@ -1440,8 +1488,8 @@ mod tests { let mut app = test_app(42); // Stock -> Waste is InvalidDestination; no state change expected. app.world_mut().write_message(MoveRequestEvent { - from: PileType::Stock, - to: PileType::Waste, + from: KlondikePile::Stock, + to: KlondikePile::Stock, count: 1, }); app.update(); @@ -1581,46 +1629,34 @@ mod tests { // Build a tableau with two face-up cards. { let mut gs = app.world_mut().resource_mut::(); - let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap(); - t.cards.clear(); - t.cards.push(Card { - id: 910, - suit: Suit::Clubs, - rank: Rank::King, - face_up: true, - }); - t.cards.push(Card { - id: 911, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: true, - }); - } - app.world_mut() - .resource_mut::() - .0 - .piles - .get_mut(&PileType::Tableau(1)) - .unwrap() - .cards - .clear(); - { - let mut gs = app.world_mut().resource_mut::(); - gs.0.piles - .get_mut(&PileType::Tableau(1)) - .unwrap() - .cards - .push(Card { + gs.0.set_test_tableau_cards(Tableau::Tableau1, vec![ + Card { + id: 910, + suit: Suit::Clubs, + rank: Rank::King, + face_up: true, + }, + Card { + id: 911, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }, + ]); + gs.0.set_test_tableau_cards( + Tableau::Tableau2, + vec![Card { id: 912, suit: Suit::Spades, rank: Rank::King, face_up: true, - }); + }], + ); } app.world_mut().write_message(MoveRequestEvent { - from: PileType::Tableau(0), - to: PileType::Tableau(1), + from: KlondikePile::Tableau(Tableau::Tableau1), + to: KlondikePile::Tableau(Tableau::Tableau2), count: 1, }); app.update(); @@ -1659,31 +1695,36 @@ mod tests { // are exhausted and no visible card can be moved. use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + game.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + game.set_test_tableau_cards(tableau, Vec::new()); } - game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - let stock = game.piles.get_mut(&PileType::Stock).unwrap(); - stock.cards.clear(); + game.set_test_waste_cards(Vec::new()); + let mut stock = Vec::new(); for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] { - stock.cards.push(Card { + stock.push(Card { id: 100 + r as u32, suit: Suit::Hearts, rank: r, face_up: false, }); } + game.set_test_stock_cards(stock); // Stock is non-empty, so drawing is always a valid move. assert!( has_legal_moves(&game), @@ -1697,34 +1738,38 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Empty stock and waste so draw is NOT available. - game.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + game.set_test_stock_cards(Vec::new()); + game.set_test_waste_cards(Vec::new()); // Clear all tableau and foundations, put Ace of Clubs on tableau 0. - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + game.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + game.set_test_tableau_cards(tableau, Vec::new()); } - game.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(Card { + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, - }); + }], + ); assert!( has_legal_moves(&game), @@ -1741,47 +1786,57 @@ mod tests { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); - game.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + game.set_test_stock_cards(Vec::new()); + game.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + game.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + game.set_test_tableau_cards(tableau, Vec::new()); } // Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top. // King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the // only legal tableau move, and that move targets the Queen which is non-top. - let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap(); - t0.cards.push(Card { - id: 10, - suit: Suit::Spades, - rank: Rank::Queen, - face_up: true, - }); - t0.cards.push(Card { - id: 11, - suit: Suit::Hearts, - rank: Rank::Jack, - face_up: true, - }); - - let t1 = game.piles.get_mut(&PileType::Tableau(1)).unwrap(); - t1.cards.push(Card { - id: 12, - suit: Suit::Diamonds, - rank: Rank::King, - face_up: true, - }); + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![ + Card { + id: 10, + suit: Suit::Spades, + rank: Rank::Queen, + face_up: true, + }, + Card { + id: 11, + suit: Suit::Hearts, + rank: Rank::Jack, + face_up: true, + }, + ], + ); + game.set_test_tableau_cards( + Tableau::Tableau2, + vec![Card { + id: 12, + suit: Suit::Diamonds, + rank: Rank::King, + face_up: true, + }], + ); assert!( has_legal_moves(&game), @@ -1942,37 +1997,41 @@ mod tests { // there legally. { let mut gs = app.world_mut().resource_mut::(); - gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for slot in 0..4_u8 { - gs.0.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + gs.0.set_test_stock_cards(Vec::new()); + gs.0.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + gs.0.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - gs.0.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + gs.0.set_test_tableau_cards(tableau, Vec::new()); } - gs.0.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(Card { + gs.0.set_test_tableau_cards( + Tableau::Tableau1, + vec![Card { id: 7_000, suit: Suit::Spades, rank: Rank::King, face_up: true, - }); + }], + ); } app.world_mut().write_message(MoveRequestEvent { - from: PileType::Tableau(0), - to: PileType::Tableau(1), + from: KlondikePile::Tableau(Tableau::Tableau1), + to: KlondikePile::Tableau(Tableau::Tableau2), count: 1, }); app.update(); @@ -2029,8 +2088,8 @@ mod tests { let mut app = test_app(42); // Stock → Waste is InvalidDestination; the live engine rejects it. app.world_mut().write_message(MoveRequestEvent { - from: PileType::Stock, - to: PileType::Waste, + from: KlondikePile::Stock, + to: KlondikePile::Stock, count: 1, }); app.update(); @@ -2117,8 +2176,8 @@ mod tests { let mut recording = app.world_mut().resource_mut::(); recording.moves.push(ReplayMove::StockClick); recording.moves.push(ReplayMove::Move { - from: PileType::Waste, - to: PileType::Tableau(2), + from: SavedKlondikePile::Stock, + to: SavedKlondikePile::Tableau(SavedTableau(2)), count: 1, }); } @@ -2157,8 +2216,8 @@ mod tests { assert!(matches!(loaded.moves[0], ReplayMove::StockClick)); match &loaded.moves[1] { ReplayMove::Move { from, to, count } => { - assert_eq!(*from, PileType::Waste); - assert_eq!(*to, PileType::Tableau(2)); + assert_eq!(*from, SavedKlondikePile::Stock); + assert_eq!(*to, SavedKlondikePile::Tableau(SavedTableau(2))); assert_eq!(*count, 1); } other => panic!("second entry must be a Move, got {other:?}"), @@ -2280,11 +2339,19 @@ mod tests { ); // Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte. let expected = GameState::new(999, DrawMode::DrawOne); - for i in 0..7 { + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { assert_eq!( - app.world().resource::().0.piles[&PileType::Tableau(i)].cards, - expected.piles[&PileType::Tableau(i)].cards, - "tableau column {i} must match the unfiltered seed", + app.world().resource::().0.pile(KlondikePile::Tableau(tableau)), + expected.pile(KlondikePile::Tableau(tableau)), + "tableau column {tableau:?} must match the unfiltered seed", ); } } diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index a33383a..8d87ca9 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -8,9 +8,9 @@ use bevy::prelude::*; use bevy::window::WindowResized; +use klondike::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::Suit; use solitaire_core::game_state::{DrawMode, GameMode}; -use solitaire_core::pile::PileType; use crate::auto_complete_plugin::AutoCompleteState; use crate::avatar_plugin::AvatarResource; @@ -2316,7 +2316,7 @@ fn update_hud( // Hide when not in Draw-Three or after the game is won. String::new() } else { - let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len(); + let stock_len = g.stock_cards().len(); let next_draw = stock_len.min(3); format!("Cycle: {next_draw}/3") }; @@ -2380,15 +2380,14 @@ fn update_selection_hud( let Ok(mut t) = q.single_mut() else { return }; let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) { None => String::new(), - Some(PileType::Waste) => "▶ Waste".to_string(), - Some(PileType::Stock) => "▶ Stock".to_string(), - Some(PileType::Foundation(slot)) => match game.as_deref() { + Some(KlondikePile::Stock) => "▶ Waste".to_string(), + Some(KlondikePile::Foundation(slot)) => match game.as_deref() { Some(g) => foundation_selection_label(*slot, &g.0), // No game resource means we can't probe claimed_suit; show the // slot-based placeholder so the HUD still surfaces the selection. - None => format!("▶ Foundation {}", slot + 1), + None => format!("▶ Foundation {}", foundation_number(*slot)), }, - Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1), + Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)), }; **t = label; } @@ -2398,11 +2397,11 @@ fn update_selection_hud( /// When the slot has a claimed suit (any card has landed) the announcement is /// "▶ {Suit} Foundation"; while the slot is empty it falls back to a /// "▶ Foundation N" placeholder labelled by the 1-based slot index. -fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String { +fn foundation_selection_label(slot: Foundation, game: &solitaire_core::game_state::GameState) -> String { let claimed = game - .piles - .get(&PileType::Foundation(slot)) - .and_then(|p| p.claimed_suit()); + .pile(KlondikePile::Foundation(slot)) + .first() + .map(|c| c.suit); match claimed { Some(suit) => { let s = match suit { @@ -2413,7 +2412,28 @@ fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameS }; format!("▶ {s} Foundation") } - None => format!("▶ Foundation {}", slot + 1), + None => format!("▶ Foundation {}", foundation_number(slot)), + } +} + +const fn foundation_number(foundation: Foundation) -> u8 { + match foundation { + Foundation::Foundation1 => 1, + Foundation::Foundation2 => 2, + Foundation::Foundation3 => 3, + Foundation::Foundation4 => 4, + } +} + +const fn tableau_number(tableau: Tableau) -> u8 { + match tableau { + Tableau::Tableau1 => 1, + Tableau::Tableau2 => 2, + Tableau::Tableau3 => 3, + Tableau::Tableau4 => 4, + Tableau::Tableau5 => 5, + Tableau::Tableau6 => 6, + Tableau::Tableau7 => 7, } } diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index ac05694..1d2cab1 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -24,11 +24,11 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches}; use bevy::math::{Vec2, Vec3}; use bevy::prelude::*; use bevy::window::PrimaryWindow; +use klondike::{Foundation, KlondikePile, Tableau}; #[cfg(not(target_os = "android"))] use bevy::window::{MonitorSelection, WindowMode}; use solitaire_core::card::{Card, Suit}; use solitaire_core::game_state::GameState; -use solitaire_core::pile::PileType; use crate::auto_complete_plugin::AutoCompleteState; use crate::card_animation::tuning::AnimationTuning; @@ -327,14 +327,14 @@ fn handle_keyboard_hint( pub fn find_heuristic_hint( game: &GameState, hint_cycle: &mut HintCycleIndex, -) -> Option<(PileType, PileType)> { +) -> Option<(KlondikePile, KlondikePile)> { let hints = all_hints(game); if hints.is_empty() { return None; } let idx = hint_cycle.0 % hints.len(); hint_cycle.0 = hint_cycle.0.wrapping_add(1); - let (from, to, _count) = hints[idx].clone(); + let (from, to, _count) = hints[idx]; Some((from, to)) } @@ -346,8 +346,8 @@ pub fn find_heuristic_hint( /// resolves. pub fn emit_hint_visuals( game: &GameState, - from: &PileType, - to: &PileType, + from: &KlondikePile, + to: &KlondikePile, commands: &mut Commands, mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>, info_toast: &mut MessageWriter, @@ -357,11 +357,8 @@ pub fn emit_hint_visuals( // face-up card to highlight — show a toast instead. // If the stock is empty, pressing D will recycle the waste rather // than draw a card, so the toast text must reflect that. - if *from == PileType::Stock { - let stock_empty = game - .piles - .get(&PileType::Stock) - .is_some_and(|p| p.cards.is_empty()); + if *from == KlondikePile::Stock { + let stock_empty = game.stock_cards().is_empty(); let msg = if stock_empty { "Hint: recycle waste (D)".to_string() } else { @@ -372,11 +369,8 @@ pub fn emit_hint_visuals( } // Find the top face-up card in the source pile and highlight it. - let top_card_id = game - .piles - .get(from) - .and_then(|p| p.cards.last().filter(|c| c.face_up)) - .map(|c| c.id); + let source_cards = pile_cards(game, from); + let top_card_id = source_cards.last().filter(|c| c.face_up).map(|c| c.id); if let Some(card_id) = top_card_id { for (entity, card_entity, mut sprite) in card_entities.iter_mut() { if card_entity.card_id == card_id { @@ -397,7 +391,7 @@ pub fn emit_hint_visuals( // tinted gold for 2 s. hint_visual.write(HintVisualEvent { source_card_id: card_id, - dest_pile: to.clone(), + dest_pile: *to, }); } @@ -406,8 +400,8 @@ pub fn emit_hint_visuals( // destination foundation already claims a suit, surface that suit so the // player keeps thinking in suit terms; otherwise fall back to "foundation". let msg = match to { - PileType::Foundation(_) => { - let claimed = game.piles.get(to).and_then(|p| p.claimed_suit()); + KlondikePile::Foundation(_) => { + let claimed = game.pile(*to).first().map(|c| c.suit); if let Some(suit) = claimed { let suit_name = match suit { Suit::Clubs => "Clubs", @@ -420,7 +414,9 @@ pub fn emit_hint_visuals( "Hint: move to foundation".to_string() } } - PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1), + KlondikePile::Tableau(col) => { + format!("Hint: move to tableau (col {})", tableau_number(*col)) + } _ => "Hint: move card".to_string(), }; info_toast.write(InfoToastEvent(msg)); @@ -516,7 +512,7 @@ fn handle_stock_click( return; }; - let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { + let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else { return; }; if point_in_rect(world, stock_pos, layout.0.card_size) { @@ -553,7 +549,7 @@ fn handle_touch_stock_tap( let Some(world) = touch_to_world(&cameras, event.position) else { continue; }; - let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { + let Some(&stock_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else { continue; }; if point_in_rect(world, stock_pos, layout.0.card_size) { @@ -743,7 +739,7 @@ fn end_drag( let Some(layout) = layout else { return; }; - let Some(origin) = drag.origin_pile.clone() else { + let Some(origin) = drag.origin_pile else { drag.clear(); return; }; @@ -764,15 +760,15 @@ fn end_drag( let ok = game.0.can_move_cards(&origin, &target, count); if ok { moves.write(MoveRequestEvent { - from: origin.clone(), - to: target.clone(), + from: origin, + to: target, count, }); fired = true; } else { rejected.write(MoveRejectedEvent { - from: origin.clone(), - to: target.clone(), + from: origin, + to: target, count, }); // Smoothly glide each dragged card from its drop-time @@ -785,9 +781,10 @@ fn end_drag( // `update_card_entity` skips its own snap/slide while a // `CardAnimation` is present, so the StateChangedEvent // that fires below does not fight this tween. - if let Some(origin_pile) = game.0.piles.get(&origin) { + let origin_cards = pile_cards(&game.0, &origin); + if !origin_cards.is_empty() { for &card_id in &drag.cards { - let Some(stack_index) = origin_pile.cards.iter().position(|c| c.id == card_id) + let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id) else { continue; }; @@ -984,7 +981,7 @@ fn touch_end_drag( return; } - let Some(origin) = drag.origin_pile.clone() else { + let Some(origin) = drag.origin_pile else { drag.clear(); return; }; @@ -1006,14 +1003,14 @@ fn touch_end_drag( let ok = game.0.can_move_cards(&origin, &target, count); if ok { moves.write(MoveRequestEvent { - from: origin.clone(), + from: origin, to: target, count, }); fired = true; } else { rejected.write(MoveRejectedEvent { - from: origin.clone(), + from: origin, to: target, count, }); @@ -1022,9 +1019,10 @@ fn touch_end_drag( // (mouse path) for the full rationale; the touch path // mirrors it exactly so finger and mouse rejection // feel identical. - if let Some(origin_pile) = game.0.piles.get(&origin) { + let origin_cards = pile_cards(&game.0, &origin); + if !origin_cards.is_empty() { for &card_id in &drag.cards { - let Some(stack_index) = origin_pile.cards.iter().position(|c| c.id == card_id) + let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id) else { continue; }; @@ -1099,26 +1097,24 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { /// 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 /// where clicks land. -fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 { +fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 { let base = layout.pile_positions[pile]; - if matches!(pile, PileType::Tableau(_)) { + if matches!(pile, KlondikePile::Tableau(_)) { let mut y_offset = 0.0_f32; - if let Some(pile_cards) = game.piles.get(pile) { - for card in pile_cards.cards.iter().take(stack_index) { - let step = if card.face_up { - layout.tableau_fan_frac - } else { - layout.tableau_facedown_fan_frac - }; - y_offset -= layout.card_size.y * step; - } + for card in pile_cards(game, pile).iter().take(stack_index) { + let step = if card.face_up { + layout.tableau_fan_frac + } else { + layout.tableau_facedown_fan_frac + }; + y_offset -= layout.card_size.y * step; } Vec2::new(base.x, base.y + y_offset) - } else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree { + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree { // In Draw-Three mode the top 3 waste cards are fanned in X to match // card_plugin::card_positions(). Hit-testing must use the same offsets // so clicking the visually rightmost (top) card actually registers. - let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); + let pile_len = game.waste_cards().len(); let visible_start = pile_len.saturating_sub(3); let slot = stack_index.saturating_sub(visible_start) as f32; Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) @@ -1133,38 +1129,36 @@ fn find_draggable_at( cursor: Vec2, game: &GameState, layout: &Layout, -) -> Option<(PileType, usize, Vec)> { +) -> Option<(KlondikePile, usize, Vec)> { // Search order: waste, foundations, tableau. Stock is skipped (click-to-draw). // Within a pile, we consider cards top-down because the visual top card is drawn last. let piles = [ - PileType::Waste, - PileType::Foundation(0), - PileType::Foundation(1), - PileType::Foundation(2), - PileType::Foundation(3), - PileType::Tableau(0), - PileType::Tableau(1), - PileType::Tableau(2), - PileType::Tableau(3), - PileType::Tableau(4), - PileType::Tableau(5), - PileType::Tableau(6), + KlondikePile::Stock, + KlondikePile::Foundation(Foundation::Foundation1), + KlondikePile::Foundation(Foundation::Foundation2), + KlondikePile::Foundation(Foundation::Foundation3), + KlondikePile::Foundation(Foundation::Foundation4), + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau2), + KlondikePile::Tableau(Tableau::Tableau3), + KlondikePile::Tableau(Tableau::Tableau4), + KlondikePile::Tableau(Tableau::Tableau5), + KlondikePile::Tableau(Tableau::Tableau6), + KlondikePile::Tableau(Tableau::Tableau7), ]; for pile in piles { - let Some(pile_cards) = game.piles.get(&pile) else { - continue; - }; - if pile_cards.cards.is_empty() { + let pile_cards = pile_cards(game, &pile); + if pile_cards.is_empty() { continue; } - let is_tableau = matches!(pile, PileType::Tableau(_)); + let is_tableau = matches!(pile, KlondikePile::Tableau(_)); // Iterate from topmost to bottommost so the first hit is the one // visually on top. - for i in (0..pile_cards.cards.len()).rev() { - let card = &pile_cards.cards[i]; + for i in (0..pile_cards.len()).rev() { + let card = &pile_cards[i]; if !card.face_up { continue; } @@ -1178,16 +1172,16 @@ fn find_draggable_at( // because tableau never has face-down above face-up). // - Waste / Foundation: only the top card is draggable. let (start, end) = if is_tableau { - (i, pile_cards.cards.len()) + (i, pile_cards.len()) } else { - if i != pile_cards.cards.len() - 1 { + if i != pile_cards.len() - 1 { // Non-top card on a non-tableau pile — not draggable; skip // this pile and continue searching remaining piles. break; } (i, i + 1) }; - let ids: Vec = pile_cards.cards[start..end].iter().map(|c| c.id).collect(); + let ids: Vec = pile_cards[start..end].iter().map(|c| c.id).collect(); return Some((pile, start, ids)); } } @@ -1200,20 +1194,20 @@ fn find_drop_target( cursor: Vec2, game: &GameState, layout: &Layout, - origin: &PileType, -) -> Option { + origin: &KlondikePile, +) -> Option { let piles = [ - PileType::Foundation(0), - PileType::Foundation(1), - PileType::Foundation(2), - PileType::Foundation(3), - PileType::Tableau(0), - PileType::Tableau(1), - PileType::Tableau(2), - PileType::Tableau(3), - PileType::Tableau(4), - PileType::Tableau(5), - PileType::Tableau(6), + KlondikePile::Foundation(Foundation::Foundation1), + KlondikePile::Foundation(Foundation::Foundation2), + KlondikePile::Foundation(Foundation::Foundation3), + KlondikePile::Foundation(Foundation::Foundation4), + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau2), + KlondikePile::Tableau(Tableau::Tableau3), + KlondikePile::Tableau(Tableau::Tableau4), + KlondikePile::Tableau(Tableau::Tableau5), + KlondikePile::Tableau(Tableau::Tableau6), + KlondikePile::Tableau(Tableau::Tableau7), ]; for pile in piles { @@ -1231,10 +1225,10 @@ fn find_drop_target( /// Bounding rect used for drop detection. For tableaus this extends /// downward to cover the entire visible fan of cards. -fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) { +fn pile_drop_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) -> (Vec2, Vec2) { let center = layout.pile_positions[pile]; - if matches!(pile, PileType::Tableau(_)) { - let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len()); + if matches!(pile, KlondikePile::Tableau(_)) { + let card_count = pile_cards(game, pile).len(); if card_count > 1 { let fan = -layout.card_size.y * layout.tableau_fan_frac; let bottom_card_center_y = center.y + fan * (card_count - 1) as f32; @@ -1266,17 +1260,17 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35; /// Find the best legal destination for `card` — Foundation first, then Tableau. /// /// Returns `None` if no legal move exists from the card's current location. -pub fn best_destination(card: &Card, game: &GameState) -> Option { +pub fn best_destination(card: &Card, game: &GameState) -> Option { let source = game.pile_containing_card(card.id)?; - for slot in 0..4_u8 { - let dest = PileType::Foundation(slot); + for foundation in foundations() { + let dest = KlondikePile::Foundation(foundation); if game.can_move_cards(&source, &dest, 1) { return Some(dest); } } - for i in 0..7_usize { - let dest = PileType::Tableau(i); + for tableau in tableaus() { + let dest = KlondikePile::Tableau(tableau); if game.can_move_cards(&source, &dest, 1) { return Some(dest); } @@ -1292,12 +1286,12 @@ pub fn best_destination(card: &Card, game: &GameState) -> Option { /// because multi-card stacks cannot go to foundations. pub fn best_tableau_destination_for_stack( _bottom_card: &Card, - from: &PileType, + from: &KlondikePile, game: &GameState, stack_count: usize, -) -> Option<(PileType, usize)> { - for i in 0..7_usize { - let dest = PileType::Tableau(i); +) -> Option<(KlondikePile, usize)> { + for tableau in tableaus() { + let dest = KlondikePile::Tableau(tableau); if game.can_move_cards(from, &dest, stack_count) { return Some((dest, stack_count)); } @@ -1351,7 +1345,8 @@ fn handle_double_click( return; }; let top_index = stack_index + card_ids.len() - 1; - let Some(top_card) = game.0.piles.get(&pile).and_then(|p| p.cards.get(top_index)) else { + let pile_cards = pile_cards(&game.0, &pile); + let Some(top_card) = pile_cards.get(top_index) else { return; }; if !top_card.face_up || top_card.id != top_card_id { @@ -1382,11 +1377,7 @@ fn handle_double_click( // stack (card_ids.len() > 1), try moving the whole stack to another // tableau column. if card_ids.len() > 1 - && let Some(bottom_card) = game - .0 - .piles - .get(&pile) - .and_then(|p| p.cards.get(stack_index)) + && let Some(bottom_card) = pile_cards.get(stack_index) && let Some((dest, count)) = best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len()) { @@ -1407,7 +1398,7 @@ fn handle_double_click( // sound, no shake. Now both single-card and stack misses get // the same feedback. rejected.write(MoveRejectedEvent { - from: pile.clone(), + from: pile, to: pile, count: card_ids.len(), }); @@ -1486,11 +1477,12 @@ fn handle_double_tap( let Some(ref tapped_pile) = drag.origin_pile else { return; }; - let Some(pile_cards) = game.0.piles.get(tapped_pile) else { + let pile_cards = pile_cards(&game.0, tapped_pile); + if pile_cards.is_empty() { return; - }; + } - let Some(top_card) = pile_cards.cards.iter().find(|c| c.id == top_card_id) else { + let Some(top_card) = pile_cards.iter().find(|c| c.id == top_card_id) else { return; }; if !top_card.face_up { @@ -1510,15 +1502,15 @@ fn handle_double_tap( // Attempt the move. MoveRequestEvent carries validation; // a rejection will fire MoveRejectedEvent automatically. moves.write(MoveRequestEvent { - from: source_pile.clone(), - to: tapped_pile.clone(), + from: *source_pile, + to: *tapped_pile, count: source_cards.len(), }); sel.clear(); return; } // First tap: select the source. - sel.set(tapped_pile.clone(), drag.cards.clone()); + sel.set(*tapped_pile, drag.cards.clone()); } return; } @@ -1537,7 +1529,7 @@ fn handle_double_tap( } } moves.write(MoveRequestEvent { - from: tapped_pile.clone(), + from: *tapped_pile, to: dest, count: 1, }); @@ -1546,8 +1538,8 @@ fn handle_double_tap( // Priority 2: move whole face-up stack to best tableau column. if drag.cards.len() > 1 { - let stack_index = pile_cards.cards.len() - drag.cards.len(); - if let Some(bottom_card) = pile_cards.cards.get(stack_index) + let stack_index = pile_cards.len() - drag.cards.len(); + if let Some(bottom_card) = pile_cards.get(stack_index) && let Some((dest, count)) = best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len()) { @@ -1560,7 +1552,7 @@ fn handle_double_tap( } } moves.write(MoveRequestEvent { - from: tapped_pile.clone(), + from: *tapped_pile, to: dest, count, }); @@ -1569,8 +1561,8 @@ fn handle_double_tap( } rejected.write(MoveRejectedEvent { - from: tapped_pile.clone(), - to: tapped_pile.clone(), + from: *tapped_pile, + to: *tapped_pile, count: drag.cards.len(), }); } @@ -1591,29 +1583,27 @@ fn handle_double_tap( /// /// This is the backing data for the cycling hint system: the H key steps /// through `hints[HintCycleIndex % hints.len()]` on each press. -pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { - let sources: Vec = { - let mut s = vec![PileType::Waste]; - for i in 0..7_usize { - s.push(PileType::Tableau(i)); +pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> { + let sources: Vec = { + let mut s = vec![KlondikePile::Stock]; + for tableau in tableaus() { + s.push(KlondikePile::Tableau(tableau)); } s }; - let mut hints: Vec<(PileType, PileType, usize)> = Vec::new(); + let mut hints: Vec<(KlondikePile, KlondikePile, usize)> = Vec::new(); // Pass 1 — foundation moves (highest priority, shown first). for from in &sources { - let Some(from_pile) = game.piles.get(from) else { + let from_pile = pile_cards(game, from); + let Some(_card) = from_pile.last().filter(|c| c.face_up) else { continue; }; - let Some(_card) = from_pile.cards.last().filter(|c| c.face_up) else { - continue; - }; - for slot in 0..4_u8 { - let dest = PileType::Foundation(slot); + for foundation in foundations() { + let dest = KlondikePile::Foundation(foundation); if game.can_move_cards(from, &dest, 1) { - hints.push((from.clone(), dest, 1)); + hints.push((*from, dest, 1)); break; } } @@ -1622,22 +1612,20 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { // Pass 2 — tableau moves (deduplicated by source pile so we don't // repeat the same source card multiple times for different destinations). for from in &sources { - let Some(from_pile) = game.piles.get(from) else { - continue; - }; - let Some(_card) = from_pile.cards.last().filter(|c| c.face_up) else { + let from_pile = pile_cards(game, from); + let Some(_card) = from_pile.last().filter(|c| c.face_up) else { continue; }; let already_has_foundation_hint = hints .iter() - .any(|(f, t, _)| f == from && matches!(t, PileType::Foundation(_))); + .any(|(f, t, _)| f == from && matches!(t, KlondikePile::Foundation(_))); if already_has_foundation_hint { continue; } - for i in 0..7_usize { - let dest = PileType::Tableau(i); + for tableau in tableaus() { + let dest = KlondikePile::Tableau(tableau); if game.can_move_cards(from, &dest, 1) { - hints.push((from.clone(), dest, 1)); + hints.push((*from, dest, 1)); break; } } @@ -1648,18 +1636,16 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { // should never hint Foundation→Foundation. Here we handle the return path // separately so the guarded `take_from_foundation` rule is respected. if game.take_from_foundation { - for slot in 0..4_u8 { - let from = PileType::Foundation(slot); - let Some(from_pile) = game.piles.get(&from) else { + for foundation in foundations() { + let from = KlondikePile::Foundation(foundation); + let from_pile = pile_cards(game, &from); + let Some(_card) = from_pile.last().filter(|c| c.face_up) else { continue; }; - let Some(_card) = from_pile.cards.last().filter(|c| c.face_up) else { - continue; - }; - for i in 0..7_usize { - let dest = PileType::Tableau(i); + for tableau in tableaus() { + let dest = KlondikePile::Tableau(tableau); if game.can_move_cards(&from, &dest, 1) { - hints.push((from.clone(), dest, 1)); + hints.push((from, dest, 1)); break; } } @@ -1668,34 +1654,66 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { // Pass 3 — suggest drawing from the stock when no other hint was found. if hints.is_empty() { - let stock_non_empty = game - .piles - .get(&PileType::Stock) - .is_some_and(|p| !p.cards.is_empty()); - let waste_can_recycle = game - .piles - .get(&PileType::Stock) - .is_some_and(|p| p.cards.is_empty()) - && game - .piles - .get(&PileType::Waste) - .is_some_and(|p| !p.cards.is_empty()); + let stock_cards = game.stock_cards(); + let waste_cards = game.waste_cards(); + let stock_non_empty = !stock_cards.is_empty(); + let waste_can_recycle = stock_cards.is_empty() && !waste_cards.is_empty(); if stock_non_empty || waste_can_recycle { // Stock→Waste is not a real pile-to-pile move, but we reuse the // triple to signal "draw". The H handler only reads `from` to // locate the card to highlight; we point at the stock pile. - hints.push((PileType::Stock, PileType::Waste, 1)); + hints.push((KlondikePile::Stock, KlondikePile::Stock, 1)); } } hints } +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { + match pile { + KlondikePile::Stock => game.waste_cards(), + _ => game.pile(*pile), + } +} + +const fn foundations() -> [Foundation; 4] { + [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] +} + +const fn tableaus() -> [Tableau; 7] { + [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] +} + +const fn tableau_number(tableau: Tableau) -> u8 { + match tableau { + Tableau::Tableau1 => 1, + Tableau::Tableau2 => 2, + Tableau::Tableau3 => 3, + Tableau::Tableau4 => 4, + Tableau::Tableau5 => 5, + Tableau::Tableau6 => 6, + Tableau::Tableau7 => 7, + } +} + /// Find one valid move in the current game state. /// /// Returns `(from, to, count)` for the first legal move found, or `None` if /// no move is available. This is a convenience wrapper over [`all_hints`]. -pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> { +pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile, usize)> { all_hints(game).into_iter().next() } @@ -1708,8 +1726,33 @@ const _VEC3_REFERENCED: Option = None; mod tests { use super::*; use crate::layout::compute_layout; + use klondike::{Foundation, Tableau}; use solitaire_core::game_state::{DrawMode, GameState}; + fn clear_test_piles(game: &mut GameState) { + game.set_test_stock_cards(Vec::new()); + game.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + game.set_test_foundation_cards(foundation, Vec::new()); + } + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + game.set_test_tableau_cards(tableau, Vec::new()); + } + } + #[test] fn dragged_card_z_matches_resting_stack_step() { assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6); @@ -1757,9 +1800,9 @@ mod tests { // In tableau 6, the visually topmost card is the last (face-up) one. // Its position: base.y + fan * 6. - let top_pos = card_position(&game, &layout, &PileType::Tableau(6), 6); + let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); let result = find_draggable_at(top_pos, &game, &layout).expect("hit"); - assert_eq!(result.0, PileType::Tableau(6)); + assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); assert_eq!(result.1, 6); assert_eq!(result.2.len(), 1); } @@ -1775,7 +1818,7 @@ mod tests { // 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 // is None. - let face_down_pos = card_position(&game, &layout, &PileType::Tableau(6), 0); + let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0); let result = find_draggable_at(face_down_pos, &game, &layout); assert!(result.is_none(), "face-down cards should not be draggable"); } @@ -1793,10 +1836,10 @@ mod tests { // 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 // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. - let face_up_pos = card_position(&game, &layout, &PileType::Tableau(6), 6); + let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); let result = find_draggable_at(face_up_pos, &game, &layout) .expect("clicking the face-up card's visible centre must initiate a drag"); - assert_eq!(result.0, PileType::Tableau(6)); + assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); assert_eq!(result.1, 6); assert_eq!(result.2.len(), 1); } @@ -1806,36 +1849,39 @@ mod tests { // Manually construct a tableau with three face-up cards all stacked. let mut game = GameState::new(1, DrawMode::DrawOne); use solitaire_core::card::{Card, Rank, Suit}; - let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap(); - t0.cards.clear(); - t0.cards.push(Card { - id: 100, - suit: Suit::Spades, - rank: Rank::King, - face_up: true, - }); - t0.cards.push(Card { - id: 101, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: true, - }); - t0.cards.push(Card { - id: 102, - suit: Suit::Clubs, - rank: Rank::Jack, - face_up: true, - }); + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![ + Card { + id: 100, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }, + Card { + id: 101, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }, + Card { + id: 102, + suit: Suit::Clubs, + rank: Rank::Jack, + face_up: true, + }, + ], + ); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // The Queen's geometric center (index 1) is inside the Jack's bounding box // (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 // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. - let queen_center = card_position(&game, &layout, &PileType::Tableau(0), 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 (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); - assert_eq!(pile, PileType::Tableau(0)); + assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(start, 1); assert_eq!(ids, vec![101, 102]); } @@ -1844,27 +1890,27 @@ mod tests { fn find_draggable_skips_non_top_waste_card() { let mut game = GameState::new(1, DrawMode::DrawOne); use solitaire_core::card::{Card, Rank, Suit}; - let waste = game.piles.get_mut(&PileType::Waste).unwrap(); - waste.cards.clear(); - waste.cards.push(Card { - id: 200, - suit: Suit::Spades, - rank: Rank::Two, - face_up: true, - }); - waste.cards.push(Card { - id: 201, - suit: Suit::Hearts, - rank: Rank::Three, - face_up: true, - }); + game.set_test_waste_cards(vec![ + Card { + id: 200, + suit: Suit::Spades, + rank: Rank::Two, + face_up: true, + }, + Card { + id: 201, + suit: Suit::Hearts, + rank: Rank::Three, + face_up: true, + }, + ]); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Both cards in waste sit at the same (x, y). Clicking should pick // the visually top card (id 201), with count = 1. - let pos = card_position(&game, &layout, &PileType::Waste, 0); + let pos = card_position(&game, &layout, &KlondikePile::Stock, 0); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); - assert_eq!(pile, PileType::Waste); + assert_eq!(pile, KlondikePile::Stock); assert_eq!(start, 1); assert_eq!(ids, vec![201]); } @@ -1875,22 +1921,18 @@ mod tests { let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Move all cards out of tableau 0 so its marker is the only drop area. let mut game = game; - game.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .clear(); - let pos = layout.pile_positions[&PileType::Tableau(0)]; - let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(6)); - assert_eq!(target, Some(PileType::Tableau(0))); + game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); + let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; + let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau7)); + assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1))); } #[test] fn find_drop_target_returns_none_for_origin() { let game = GameState::new(42, DrawMode::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - let pos = layout.pile_positions[&PileType::Tableau(3)]; - let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3)); + let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)]; + let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau4)); assert_eq!(target, None); } @@ -1899,7 +1941,7 @@ mod tests { let game = GameState::new(42, DrawMode::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 has 7 cards. - let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); + let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game); // Expected: card_height + 6 fan steps. let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac); assert!( @@ -1914,30 +1956,30 @@ mod tests { use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameMode}; let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic); - let waste = game.piles.get_mut(&PileType::Waste).unwrap(); - waste.cards.clear(); // Three waste cards; top (id=202) is rightmost in the fan. - waste.cards.push(Card { - id: 200, - suit: Suit::Spades, - rank: Rank::Two, - face_up: true, - }); - waste.cards.push(Card { - id: 201, - suit: Suit::Hearts, - rank: Rank::Three, - face_up: true, - }); - waste.cards.push(Card { - id: 202, - suit: Suit::Clubs, - rank: Rank::Four, - face_up: true, - }); + game.set_test_waste_cards(vec![ + Card { + id: 200, + suit: Suit::Spades, + rank: Rank::Two, + face_up: true, + }, + Card { + id: 201, + suit: Suit::Hearts, + rank: Rank::Three, + face_up: true, + }, + Card { + id: 202, + suit: Suit::Clubs, + rank: Rank::Four, + face_up: true, + }, + ]); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - let waste_base = layout.pile_positions[&PileType::Waste]; + let waste_base = layout.pile_positions[&KlondikePile::Stock]; // Top card (slot=2) is at base.x + 2 * 0.28 * card_width. let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x; let cursor = Vec2::new(top_card_x, waste_base.y); @@ -1948,7 +1990,7 @@ mod tests { "top fanned waste card must be hittable at its visual X position" ); let (pile, _start, ids) = result.unwrap(); - assert_eq!(pile, PileType::Waste); + assert_eq!(pile, KlondikePile::Stock); assert_eq!(ids, vec![202], "only the top card is draggable from waste"); } @@ -1957,12 +1999,8 @@ mod tests { let mut game = GameState::new(42, DrawMode::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Clear tableau 0 so it's an empty slot. - game.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .clear(); - let pos = layout.pile_positions[&PileType::Tableau(0)]; + game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); + let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; let result = find_draggable_at(pos, &game, &layout); assert!( result.is_none(), @@ -1974,7 +2012,7 @@ mod tests { fn pile_drop_rect_is_card_sized_for_non_tableau() { let game = GameState::new(42, DrawMode::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - for pile in [PileType::Waste, PileType::Foundation(2)] { + for pile in [KlondikePile::Stock, KlondikePile::Foundation(Foundation::Foundation3)] { let (_, size) = pile_drop_rect(&pile, &layout, &game); assert_eq!(size, layout.card_size); } @@ -1990,20 +2028,7 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Clear everything except one card that has nowhere to go. - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); - } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } + clear_test_piles(&mut game); // A Two of Clubs with empty foundations and empty tableau has no destination. let card = Card { @@ -2024,31 +2049,20 @@ mod tests { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); - } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } + clear_test_piles(&mut game); // Only tableau 0 has anything; every other column is empty. // A King is the only card that can go on an empty tableau column. // Source is Tableau(0), so the result must NOT be Tableau(0). - let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap(); - t0.cards.push(Card { - id: 200, - suit: Suit::Hearts, - rank: Rank::King, - face_up: true, - }); + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![Card { + id: 200, + suit: Suit::Hearts, + rank: Rank::King, + face_up: true, + }], + ); let bottom_card = Card { id: 200, @@ -2057,10 +2071,10 @@ mod tests { face_up: true, }; let result = - best_tableau_destination_for_stack(&bottom_card, &PileType::Tableau(0), &game, 1); + best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1); // Result must be some other empty tableau column, never the source. if let Some((dest, _)) = result { - assert_ne!(dest, PileType::Tableau(0)); + assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1)); } } @@ -2069,30 +2083,19 @@ mod tests { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); - } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } + clear_test_piles(&mut game); // Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King). // All other piles are empty — no legal tableau target. - let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap(); - t0.cards.push(Card { - id: 300, - suit: Suit::Clubs, - rank: Rank::Two, - face_up: true, - }); + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![Card { + id: 300, + suit: Suit::Clubs, + rank: Rank::Two, + face_up: true, + }], + ); let bottom_card = Card { id: 300, @@ -2101,7 +2104,7 @@ mod tests { face_up: true, }; let result = - best_tableau_destination_for_stack(&bottom_card, &PileType::Tableau(0), &game, 1); + best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1); assert!( result.is_none(), "Two of Clubs has no legal tableau destination on empty piles" @@ -2118,37 +2121,22 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Place Ace of Clubs on top of tableau 0. - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - game.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(Card { + clear_test_piles(&mut game); + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![Card { id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, - }); - // All foundation slots empty — Ace lands in slot 0 (first match). - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); - } + }], + ); let hint = find_hint(&game); assert!(hint.is_some(), "should find a hint"); let (from, to, count) = hint.unwrap(); - assert_eq!(from, PileType::Tableau(0)); - assert_eq!(to, PileType::Foundation(0)); + assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1)); + assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1)); assert_eq!(count, 1); } @@ -2185,39 +2173,20 @@ mod tests { // Remove all foundation, tableau, and waste cards so no pile-to-pile // move exists. Leave one card in the stock. - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); - } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - game.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + clear_test_piles(&mut game); // Put one card back into the stock so "draw" is a valid suggestion. - game.piles - .get_mut(&PileType::Stock) - .unwrap() - .cards - .push(Card { + game.set_test_stock_cards(vec![Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: false, - }); + }]); let hints = all_hints(&game); assert_eq!(hints.len(), 1, "exactly one hint: draw from stock"); let (from, to, count) = &hints[0]; - assert_eq!(*from, PileType::Stock, "hint must come from Stock"); - assert_eq!(*to, PileType::Waste, "hint must point to Waste"); + assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock"); + assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste"); assert_eq!(*count, 1); } diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index c28aa87..ff3de96 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use bevy::math::Vec2; use bevy::prelude::{Resource, SystemSet}; -use solitaire_core::pile::PileType; +use klondike::{Foundation, KlondikePile, Tableau}; /// Schedule labels for layout-related systems so cross-plugin ordering is /// explicit instead of relying on Bevy's automatic resource-conflict ordering @@ -138,9 +138,9 @@ pub struct Layout { /// Centre position of each pile, in 2D world coordinates. /// /// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up. - /// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an + /// Every `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an /// entry. The map always contains exactly 13 entries after `compute_layout`. - pub pile_positions: HashMap, + pub pile_positions: HashMap, /// Per-step vertical offset fraction for face-up tableau cards, as a /// fraction of `card_size.y`. On height-limited (desktop) windows this /// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone) @@ -241,21 +241,35 @@ pub fn compute_layout( let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0; let tableau_y = top_y - card_height - vertical_gap; - let mut pile_positions: HashMap = HashMap::with_capacity(13); + let mut pile_positions: HashMap = HashMap::with_capacity(13); - pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y)); - pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y)); + pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y)); // Column 2 is skipped — visual separation between waste and foundations. for slot in 0..4_u8 { + let foundation = match slot { + 0 => Foundation::Foundation1, + 1 => Foundation::Foundation2, + 2 => Foundation::Foundation3, + _ => Foundation::Foundation4, + }; pile_positions.insert( - PileType::Foundation(slot), + KlondikePile::Foundation(foundation), Vec2::new(col_x(3 + slot as usize), top_y), ); } for i in 0..7 { - pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y)); + let tableau = match i { + 0 => Tableau::Tableau1, + 1 => Tableau::Tableau2, + 2 => Tableau::Tableau3, + 3 => Tableau::Tableau4, + 4 => Tableau::Tableau5, + 5 => Tableau::Tableau6, + _ => Tableau::Tableau7, + }; + pile_positions.insert(KlondikePile::Tableau(tableau), Vec2::new(col_x(i), tableau_y)); } // Adaptive tableau fan fraction. On height-limited (desktop) windows the @@ -301,23 +315,35 @@ mod tests { use super::*; fn assert_all_piles_present(layout: &Layout) { - assert!(layout.pile_positions.contains_key(&PileType::Stock)); - assert!(layout.pile_positions.contains_key(&PileType::Waste)); - for slot in 0..4_u8 { + assert!(layout.pile_positions.contains_key(&KlondikePile::Stock)); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { assert!( layout .pile_positions - .contains_key(&PileType::Foundation(slot)), - "missing foundation slot {slot}", + .contains_key(&KlondikePile::Foundation(foundation)), + "missing foundation slot {foundation:?}", ); } - for i in 0..7 { + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { assert!( - layout.pile_positions.contains_key(&PileType::Tableau(i)), - "missing tableau {i}" + layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)), + "missing tableau {tableau:?}" ); } - assert_eq!(layout.pile_positions.len(), 13); + assert_eq!(layout.pile_positions.len(), 12); } #[test] @@ -376,9 +402,18 @@ mod tests { #[test] fn tableau_columns_are_sorted_left_to_right() { let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - for i in 0..6 { - let lhs = layout.pile_positions[&PileType::Tableau(i)].x; - let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; + let tableaus = [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ]; + for i in 0..tableaus.len() - 1 { + let lhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i])].x; + let rhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i + 1])].x; assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1); } } @@ -386,8 +421,8 @@ mod tests { #[test] fn top_row_is_above_tableau_row() { let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - let stock_y = layout.pile_positions[&PileType::Stock].y; - let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; + let stock_y = layout.pile_positions[&KlondikePile::Stock].y; + let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y; assert!(stock_y > tableau_y); } @@ -399,7 +434,7 @@ mod tests { fn top_row_clears_hud_band() { let window = Vec2::new(1280.0, 800.0); let layout = compute_layout(window, 0.0, 0.0, true); - let stock_y = layout.pile_positions[&PileType::Stock].y; + let stock_y = layout.pile_positions[&KlondikePile::Stock].y; let card_top = stock_y + layout.card_size.y / 2.0; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; assert!( @@ -411,24 +446,35 @@ mod tests { #[test] fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - let stock_x = layout.pile_positions[&PileType::Stock].x; - let waste_x = layout.pile_positions[&PileType::Waste].x; - let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; - let t1_x = layout.pile_positions[&PileType::Tableau(1)].x; - assert!((stock_x - t0_x).abs() < 1e-5); - assert!((waste_x - t1_x).abs() < 1e-5); + let stock_x = layout.pile_positions[&KlondikePile::Stock].x; + let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x; + assert!((stock_x - t1_x).abs() < 1e-5); } #[test] fn foundations_align_with_tableau_cols_3_to_6() { let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - for slot in 0..4_u8 { - let f_x = layout.pile_positions[&PileType::Foundation(slot)].x; - let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x; + let target_tableaus = [ + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ]; + for (idx, foundation) in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] + .iter() + .enumerate() + { + let f_x = layout.pile_positions[&KlondikePile::Foundation(*foundation)].x; + let t_x = layout.pile_positions[&KlondikePile::Tableau(target_tableaus[idx])].x; assert!( (f_x - t_x).abs() < 1e-5, - "foundation slot {slot} should align with tableau {}", - 3 + slot as usize, + "foundation slot {idx} should align with tableau {}", + 3 + idx, ); } } @@ -470,7 +516,7 @@ mod tests { // Default app resolution (see solitaire_app/src/main.rs). let window = Vec2::new(1280.0, 800.0); let layout = compute_layout(window, 0.0, 0.0, true); - let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y; + let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y; let card_h = layout.card_size.y; // Bottom edge of the 13th fanned face-up card. let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; @@ -489,7 +535,7 @@ mod tests { // The bug originally reproduced at 1920x1080. Lock in a regression test. let window = Vec2::new(1920.0, 1080.0); let layout = compute_layout(window, 0.0, 0.0, true); - let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y; + let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y; let card_h = layout.card_size.y; let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; let h_gap = layout.card_size.x / 4.0; @@ -520,7 +566,7 @@ mod tests { fn expanded_fan_fits_phone_viewport() { let window = Vec2::new(360.0, 800.0); let layout = compute_layout(window, 0.0, 0.0, true); - let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; + let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y; let card_h = layout.card_size.y; let h_gap = layout.card_size.x / 4.0; // Bottom of the 13th (worst-case) fanned face-up card. @@ -579,8 +625,8 @@ mod tests { let window = Vec2::new(360.0, 800.0); let without = compute_layout(window, 0.0, 0.0, true); let with_inset = compute_layout(window, 32.0, 0.0, true); - let stock_no_inset = without.pile_positions[&PileType::Stock].y; - let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y; + let stock_no_inset = without.pile_positions[&KlondikePile::Stock].y; + let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y; assert!( stock_with_inset < stock_no_inset, "safe_area_top=32 must shift stock pile down (y decreased): {} → {}", @@ -602,10 +648,10 @@ mod tests { let without = compute_layout(window, 0.0, 0.0, true); let with_inset = compute_layout(window, 32.0, 0.0, true); for pile in [ - PileType::Stock, - PileType::Waste, - PileType::Tableau(0), - PileType::Tableau(6), + KlondikePile::Stock, + KlondikePile::Stock, + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau7), ] { assert!( (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, @@ -628,7 +674,7 @@ mod tests { with_inset.tableau_fan_frac, ); let card_h = with_inset.card_size.y; - let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y; + let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y; let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0; let h_gap = with_inset.card_size.x / 4.0; let margin = -window.y / 2.0 + 48.0 + h_gap; @@ -661,8 +707,8 @@ mod tests { // Verify the "wrong" layout actually differs — the bug would push the // top card row upward by exactly safe_top pixels. - let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y; - let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y; + let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y; + let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y; // In Bevy's +y-is-up system, adding safe_area_top pushes the stock // downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top. assert!( @@ -680,14 +726,14 @@ mod tests { "card size must be preserved after resume", ); assert!( - (corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3, + (corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3, "stock y must match fresh launch after resume: \ corrected={:.2} fresh={fresh_stock_y:.2}", - corrected.pile_positions[&PileType::Stock].y, + corrected.pile_positions[&KlondikePile::Stock].y, ); assert!( - (corrected.pile_positions[&PileType::Stock].x - - fresh.pile_positions[&PileType::Stock].x) + (corrected.pile_positions[&KlondikePile::Stock].x + - fresh.pile_positions[&KlondikePile::Stock].x) .abs() < 1e-3, "stock x must be unchanged after resume", @@ -695,7 +741,7 @@ mod tests { // The HUD band top clearance (distance from window top to card top) // must match as well — this is the quantity directly visible in Bug 2. let card_top = |layout: &super::Layout| { - layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0 + layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0 }; assert!( (card_top(&corrected) - card_top(&fresh)).abs() < 1e-3, @@ -712,7 +758,7 @@ mod tests { let window = Vec2::new(360.0, 800.0); let without = compute_layout(window, 0.0, 0.0, true); let with_inset = compute_layout(window, 0.0, 48.0, true); - for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] { + for pile in [KlondikePile::Stock, KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau7)] { assert!( (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, "{pile:?} x-position must not change with safe_area_bottom", diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index fa68272..a7c571e 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -27,7 +27,7 @@ use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use solitaire_core::game_state::GameState; -use solitaire_core::pile::PileType; +use klondike::KlondikePile; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state}; use crate::card_plugin::CardEntity; @@ -101,7 +101,7 @@ struct HintTask { enum HintTaskOutput { /// Solver verdict was `Winnable`; here is the first move on the /// solution path. - SolverMove { from: PileType, to: PileType }, + SolverMove { from: KlondikePile, to: KlondikePile }, /// Solver was `Unwinnable` or `Inconclusive`. The poll system /// runs the legacy heuristic against the live `GameState` so the /// H key always produces feedback while any legal move exists. @@ -183,6 +183,7 @@ mod tests { use super::*; use crate::events::HintVisualEvent; use crate::input_plugin::HintSolverConfig; + use klondike::{Foundation, Tableau}; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; @@ -214,22 +215,27 @@ mod tests { /// tableau columns 0..3, stock and waste empty. fn near_finished_state() -> GameState { let mut game = GameState::new(1, DrawMode::DrawOne); - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + game.set_test_stock_cards(Vec::new()); + game.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + game.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + game.set_test_tableau_cards(tableau, Vec::new()); } - game.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let ranks_below_king = [ Rank::Ace, @@ -245,31 +251,44 @@ mod tests { Rank::Jack, Rank::Queen, ]; - for (slot, suit) in suits.iter().enumerate() { - let pile = game - .piles - .get_mut(&PileType::Foundation(slot as u8)) - .unwrap(); + for (foundation, suit) in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] + .into_iter() + .zip(suits.iter()) + { + let mut cards = Vec::new(); for (i, rank) in ranks_below_king.iter().enumerate() { - pile.cards.push(Card { - id: (slot as u32) * 13 + i as u32, + cards.push(Card { + id: (foundation as u32) * 13 + i as u32, suit: *suit, rank: *rank, face_up: true, }); } + game.set_test_foundation_cards(foundation, cards); } - for (col, suit) in suits.iter().enumerate() { - game.piles - .get_mut(&PileType::Tableau(col)) - .unwrap() - .cards - .push(Card { - id: 100 + col as u32, + for (tableau, suit) in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + ] + .into_iter() + .zip(suits.iter()) + { + game.set_test_tableau_cards( + tableau, + vec![Card { + id: 100 + tableau as u32, suit: *suit, rank: Rank::King, face_up: true, - }); + }], + ); } game } @@ -309,7 +328,7 @@ mod tests { "exactly one HintVisualEvent must fire when the solver returns Winnable", ); assert!( - matches!(collected[0].dest_pile, PileType::Foundation(_)), + matches!(collected[0].dest_pile, KlondikePile::Foundation(_)), "solver hint destination must be a foundation slot; got {:?}", collected[0].dest_pile, ); diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 2847be8..fa04fa1 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -47,9 +47,9 @@ use bevy::input::touch::Touches; use bevy::math::Vec2; use bevy::prelude::*; use bevy::window::PrimaryWindow; +use klondike::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::Card; use solitaire_core::game_state::GameState; -use solitaire_core::pile::PileType; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; use crate::events::{MoveRejectedEvent, MoveRequestEvent}; @@ -107,7 +107,7 @@ pub enum RightClickRadialState { /// `hovered_index` (or none). Active { /// Pile the right-clicked card came from. - source_pile: PileType, + source_pile: KlondikePile, /// Number of cards that would be moved (always `1` — only the /// top face-up card is ever offered for a quick-drop, since the /// radial is built around single-card foundation/tableau @@ -122,7 +122,7 @@ pub enum RightClickRadialState { /// [`RADIAL_RADIUS_PX`] centred on the press position. A single /// destination is placed directly above the cursor; multiple /// destinations span an arc. - legal_destinations: Vec<(PileType, Vec2)>, + legal_destinations: Vec<(KlondikePile, Vec2)>, /// Cursor position (world space) the radial was opened at — /// used as the centre of the ring for cursor-hover hit testing. centre: Vec2, @@ -250,18 +250,18 @@ pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option { /// dropping a card on its own pile is a no-op. pub fn legal_destinations_for_card( _card: &Card, - source_pile: &PileType, + source_pile: &KlondikePile, game: &GameState, -) -> Vec { +) -> Vec { let mut out = Vec::new(); - for slot in 0..4_u8 { - let dest = PileType::Foundation(slot); + for foundation in foundations() { + let dest = KlondikePile::Foundation(foundation); if game.can_move_cards(source_pile, &dest, 1) { out.push(dest); } } - for i in 0..7_usize { - let dest = PileType::Tableau(i); + for tableau in tableaus() { + let dest = KlondikePile::Tableau(tableau); if game.can_move_cards(source_pile, &dest, 1) { out.push(dest); } @@ -281,36 +281,34 @@ pub fn find_top_face_up_card_at( cursor: Vec2, game: &GameState, layout: &Layout, -) -> Option<(PileType, Card)> { +) -> Option<(KlondikePile, Card)> { let piles = [ - PileType::Waste, - PileType::Foundation(0), - PileType::Foundation(1), - PileType::Foundation(2), - PileType::Foundation(3), - PileType::Tableau(0), - PileType::Tableau(1), - PileType::Tableau(2), - PileType::Tableau(3), - PileType::Tableau(4), - PileType::Tableau(5), - PileType::Tableau(6), + KlondikePile::Stock, + KlondikePile::Foundation(Foundation::Foundation1), + KlondikePile::Foundation(Foundation::Foundation2), + KlondikePile::Foundation(Foundation::Foundation3), + KlondikePile::Foundation(Foundation::Foundation4), + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau2), + KlondikePile::Tableau(Tableau::Tableau3), + KlondikePile::Tableau(Tableau::Tableau4), + KlondikePile::Tableau(Tableau::Tableau5), + KlondikePile::Tableau(Tableau::Tableau6), + KlondikePile::Tableau(Tableau::Tableau7), ]; for pile in piles { - let Some(pile_cards) = game.piles.get(&pile) else { - continue; - }; - if pile_cards.cards.is_empty() { + let pile_cards = pile_cards(game, &pile); + if pile_cards.is_empty() { continue; } - let is_tableau = matches!(pile, PileType::Tableau(_)); - for i in (0..pile_cards.cards.len()).rev() { - let card = &pile_cards.cards[i]; + let is_tableau = matches!(pile, KlondikePile::Tableau(_)); + for i in (0..pile_cards.len()).rev() { + let card = &pile_cards[i]; if !card.face_up { continue; } // Only the top card is draggable on non-tableau piles. - if !is_tableau && i != pile_cards.cards.len() - 1 { + if !is_tableau && i != pile_cards.len() - 1 { continue; } let pos = card_position(game, layout, &pile, i); @@ -331,19 +329,17 @@ pub fn find_top_face_up_card_at( /// Mirror of `input_plugin::card_position` — kept private to this /// module so the radial's hit-test geometry tracks renderer geometry /// without depending on `input_plugin` internals. -fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 { +fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 { let base = layout.pile_positions[pile]; - if matches!(pile, PileType::Tableau(_)) { + if matches!(pile, KlondikePile::Tableau(_)) { let mut y_offset = 0.0_f32; - if let Some(pile_cards) = game.piles.get(pile) { - for card in pile_cards.cards.iter().take(stack_index) { - let step = if card.face_up { - TABLEAU_FAN_FRAC - } else { - TABLEAU_FACEDOWN_FAN_FRAC - }; - y_offset -= layout.card_size.y * step; - } + for card in pile_cards(game, pile).iter().take(stack_index) { + let step = if card.face_up { + TABLEAU_FAN_FRAC + } else { + TABLEAU_FACEDOWN_FAN_FRAC + }; + y_offset -= layout.card_size.y * step; } Vec2::new(base.x, base.y + y_offset) } else { @@ -351,8 +347,36 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index } } +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { + match pile { + KlondikePile::Stock => game.waste_cards(), + _ => game.pile(*pile), + } +} + +const fn foundations() -> [Foundation; 4] { + [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] +} + +const fn tableaus() -> [Tableau; 7] { + [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] +} + /// Builds the `(destination, anchor)` list for a fresh radial open. -fn build_radial_destinations(centre: Vec2, dests: Vec) -> Vec<(PileType, Vec2)> { +fn build_radial_destinations(centre: Vec2, dests: Vec) -> Vec<(KlondikePile, Vec2)> { let count = dests.len(); dests .into_iter() @@ -442,7 +466,7 @@ fn radial_open_on_right_click( if dests.is_empty() { // No legal destinations — shake the source pile as feedback. rejected.write(MoveRejectedEvent { - from: source_pile.clone(), + from: source_pile, to: source_pile, count: 1, }); @@ -606,8 +630,8 @@ fn radial_handle_release_or_cancel( && let Some((dest, _)) = legal_destinations.get(*idx) { moves.write(MoveRequestEvent { - from: source_pile.clone(), - to: dest.clone(), + from: *source_pile, + to: *dest, count: *count, }); } @@ -769,33 +793,37 @@ mod tests { fn ace_only_state() -> GameState { let mut g = GameState::new(0, DrawMode::DrawOne); // Wipe everything. - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for slot in 0..4_u8 { - g.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + g.set_test_stock_cards(Vec::new()); + g.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + g.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + g.set_test_tableau_cards(tableau, Vec::new()); } // Ace of Clubs on Tableau(0). - g.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(CoreCard { + g.set_test_tableau_cards( + Tableau::Tableau1, + vec![CoreCard { id: 100, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, - }); + }], + ); g } @@ -803,32 +831,36 @@ mod tests { /// must skip it. fn face_down_only_state() -> GameState { let mut g = GameState::new(0, DrawMode::DrawOne); - g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for slot in 0..4_u8 { - g.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + g.set_test_stock_cards(Vec::new()); + g.set_test_waste_cards(Vec::new()); + for foundation in [ + Foundation::Foundation1, + Foundation::Foundation2, + Foundation::Foundation3, + Foundation::Foundation4, + ] { + g.set_test_foundation_cards(foundation, Vec::new()); } - for i in 0..7_usize { - g.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); + for tableau in [ + Tableau::Tableau1, + Tableau::Tableau2, + Tableau::Tableau3, + Tableau::Tableau4, + Tableau::Tableau5, + Tableau::Tableau6, + Tableau::Tableau7, + ] { + g.set_test_tableau_cards(tableau, Vec::new()); } - g.piles - .get_mut(&PileType::Tableau(0)) - .unwrap() - .cards - .push(CoreCard { + g.set_test_tableau_cards( + Tableau::Tableau1, + vec![CoreCard { id: 100, suit: Suit::Spades, rank: Rank::King, face_up: false, - }); + }], + ); g } @@ -926,14 +958,14 @@ mod tests { rank: Rank::Ace, face_up: true, }; - let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g); + let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g); // Ace can be placed on every empty foundation. We only need // the count to be ≥ 1 and the source pile to be excluded. assert!( !dests.is_empty(), "Ace must have at least one legal destination" ); - assert!(!dests.contains(&PileType::Tableau(0))); + assert!(!dests.contains(&KlondikePile::Tableau(Tableau::Tableau1))); } #[test] @@ -945,8 +977,8 @@ mod tests { rank: Rank::Ace, face_up: true, }; - let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g); - assert!(!dests.contains(&PileType::Foundation(0))); + let dests = legal_destinations_for_card(&card, &KlondikePile::Foundation(Foundation::Foundation1), &g); + assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1))); } // ----------------------------------------------------------------------- @@ -963,7 +995,7 @@ mod tests { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); let layout = compute_layout(layout_window, 0.0, 0.0, true); - let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; + let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); press(&mut app, MouseButton::Right); @@ -990,7 +1022,7 @@ mod tests { let events = collect_move_events(&mut app); assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected"); let evt = &events[0]; - assert_eq!(evt.from, PileType::Tableau(0)); + assert_eq!(evt.from, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(evt.to, dest_pile); assert_eq!(evt.count, 1); // State must return to Idle. @@ -1007,7 +1039,7 @@ mod tests { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); let layout = compute_layout(layout_window, 0.0, 0.0, true); - let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; + let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); press(&mut app, MouseButton::Right); @@ -1038,7 +1070,7 @@ mod tests { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); let layout = compute_layout(layout_window, 0.0, 0.0, true); - let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; + let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); press(&mut app, MouseButton::Right); @@ -1064,7 +1096,7 @@ mod tests { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); let layout = compute_layout(layout_window, 0.0, 0.0, true); - let king_pos = layout.pile_positions[&PileType::Tableau(0)]; + let king_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; install_resources(&mut app, face_down_only_state(), layout_window, king_pos); press(&mut app, MouseButton::Right); diff --git a/solitaire_engine/src/replay_overlay.rs b/solitaire_engine/src/replay_overlay.rs deleted file mode 100644 index df21e80..0000000 --- a/solitaire_engine/src/replay_overlay.rs +++ /dev/null @@ -1,4333 +0,0 @@ -//! On-screen overlay shown while a recorded [`Replay`] plays back. -//! -//! The overlay is a thin top-of-window banner with three pieces of UI: -//! -//! - A "▌ replay" label on the left so the player knows the surface is -//! under playback control rather than live input. -//! - A "MOVE N/M" progress chip in the centre, recomputed every frame -//! the cursor advances and bordered in `ACCENT_PRIMARY` so it -//! reads as a discrete callout. -//! - A "Stop" button on the right that aborts playback and returns -//! control to the player. -//! -//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner -//! label swaps to "▌ replay complete" and stays visible until the playback -//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`] -//! a few seconds later, at which point the overlay despawns. -//! -//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but -//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets -//! the player still open Settings, Pause, and Help during a replay; those -//! modals will render on top of the banner as expected. -//! -//! [`Replay`]: solitaire_data::Replay -//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM - -use bevy::prelude::*; -use chrono::Datelike; - -use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; -use crate::font_plugin::FontResource; -use crate::layout::LayoutResource; -use crate::platform::SHOW_KEYBOARD_ACCELERATORS; -use crate::replay_playback::{ - ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback, - stop_replay_playback, toggle_pause_replay_playback, -}; -use crate::resources::GameStateResource; -use crate::ui_modal::{ButtonVariant, spawn_modal_button}; -use crate::ui_theme::{ - ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder, - STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, - TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, -}; -use solitaire_core::card::{Card, Rank, Suit}; -use solitaire_core::game_state::GameState; -use solitaire_core::pile::PileType; -use solitaire_data::ReplayMove; - -// --------------------------------------------------------------------------- -// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above. -// --------------------------------------------------------------------------- - -/// `bevy::ui` `ZIndex` value for the replay overlay banner. -/// -/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner -/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally -/// **below** modals, but the overlay needs to be above HUD readouts) yet -/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals -/// continue to render on top of the overlay during a replay. -/// -/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an -/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used -/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so -/// we materialise a separate constant rather than reuse the `f32` value. -pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5; - -/// `bevy::ui` `ZIndex` for the full-screen tableau dim layer. -/// -/// One rung below [`Z_REPLAY_OVERLAY`] (= 54) so the replay chrome -/// (banner + move-log panel) renders clearly on top while the dim scrim -/// darkens the card world beneath it. World-space sprites (cards, -/// badges, drop-target overlays) are always below any UI node regardless -/// of their Transform.z — the dim layer doesn't need to know their z -/// values. -const Z_REPLAY_DIM: i32 = Z_REPLAY_OVERLAY - 1; - -/// Alpha for the tableau dim layer — 50 % opacity black. Dark enough -/// to visually separate the gameplay scene from the replay chrome -/// above it; light enough that card positions remain legible through -/// the scrim. Matches the mockup's "Game Peek Band at 50 % opacity" -/// spec in `docs/ui-mockups/replay-overlay-mobile.html`. -const TABLEAU_DIM_ALPHA: f32 = 0.5; - -/// Total height of the banner in pixels. Thin enough to leave the -/// gameplay surface visible underneath, tall enough to comfortably fit -/// the headline-sized "▌ replay" label stacked above the -/// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs -/// ~26 + 2 + 11 = 39 px of inner content; banner = top row (59 -/// flex-grow) + scrub track (1) + label row (16) + footer (16) -/// gives 92). -/// -/// Growth history: -/// - 60 → 76 in the scrub-notch-labels commit to make room for the -/// `0%` / … / `100%` percentage labels under each notch. -/// - 76 → 92 in the keybind-footer commit to make room for the -/// vim-style mode line + keybind-hint footer at the bottom. -const BANNER_HEIGHT: f32 = 92.0; - -/// Height of the label row that sits below the 1px scrub track and -/// carries the `0%` / `25%` / `50%` / `75%` / `100%` notch labels. -/// 16 px is enough for `TYPE_CAPTION` text (12 px font + 4 px breathing -/// room above the bottom edge). -const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0; - -/// Height of the keybind-hint footer that sits below the notch-label -/// row. Carries a vim-style mode indicator on the left and a -/// keybind-hint on the right (`[SPACE] pause/resume`). 16 px matches -/// `SCRUB_LABEL_ROW_HEIGHT` for visual symmetry — `TYPE_CAPTION` text -/// (12 px) + 4 px breathing room. -const KEYBIND_FOOTER_HEIGHT: f32 = 16.0; - -/// Fixed pixel width of the centred scrub-bar notch-label container. -/// Wide enough to hold the widest label ("100%" at 4 chars) while -/// narrower than the 25 % gap between adjacent notches (≈ banner_w -/// × 0.25; on a 320 px banner that's 80 px). A 36 px container -/// leaves ≥ 44 px of clearance on each side at the narrowest common -/// screen width. -/// -/// Container width drives the `margin.left = -width / 2` centering -/// trick: the container's left edge is placed at `left: Percent(pct)` -/// and then shifted left by half its own width, so the container's -/// centre coincides with the notch line. `Justify::Center` then -/// renders the text centred within the container. This is the -/// CSS `translateX(-50%)` pattern adapted for Bevy 0.18 UI. -const SCRUB_LABEL_CENTER_WIDTH: f32 = 36.0; - -/// How long a held arrow key waits before firing the next repeat -/// step. 100 ms = 10 steps/sec — fast enough to scrub through a -/// hundred-move replay in ~10 seconds while held, slow enough that -/// the player can release after a known number of steps. Initial -/// `just_pressed` always fires immediately; this interval gates -/// only the *repeat* fires while the key remains held. -const SCRUB_REPEAT_INTERVAL_SECS: f32 = 0.1; - -/// Total height of the bottom-edge Move Log panel in pixels. -/// Sized for: header (`TYPE_CAPTION` 11) + 2 prev rows + active -/// row + 2 next rows (`TYPE_BODY` 14 each = 70) + row gaps (~10) -/// + vertical padding (~16) ≈ 107; round to 112. -/// -/// Growth history: -/// - 56 in the move-log-panel-init commit (header + active row). -/// - 56 → 84 in the move-log-prev-rows commit (+ 2 prev rows). -/// - 84 → 112 in the move-log-next-rows commit (+ 2 next rows). -const MOVE_LOG_PANEL_HEIGHT: f32 = 112.0; - -/// Number of "previous move" rows rendered above the active row -/// in the move-log panel. Tuned to fit the panel height comfortably -/// alongside the header + active row at `TYPE_BODY`. The active -/// row plus this many prev rows gives the player a 3-row window -/// onto recent move history. -const MOVE_LOG_PREV_ROWS: usize = 2; - -/// Number of "next move" rows rendered below the active row. -/// Same logic as [`MOVE_LOG_PREV_ROWS`] — symmetric window -/// around the active row showing about-to-apply moves. For a -/// post-game replay these aren't spoilers (the game is already -/// won); for a future "live preview during play" use case the -/// preview-shape might need rethinking. -const MOVE_LOG_NEXT_ROWS: usize = 2; - -/// Vertical offset from the top edge of the window to the top edge of the -/// mini-tableau preview panel. Places the panel 8 px below the banner's -/// bottom edge so the two surfaces don't overlap. Derived from -/// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows. -const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0; - -/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha -/// reads as a clear "this is a UI strip" callout while still letting the -/// felt show through enough to anchor the banner to the play surface. -const BANNER_ALPHA: f32 = 0.92; - -// --------------------------------------------------------------------------- -// Marker components -// --------------------------------------------------------------------------- - -/// Marker on the banner's root `Node`. Used by the spawn / despawn / -/// progress-update systems to find the overlay. -#[derive(Component, Debug)] -pub struct ReplayOverlayRoot; - -/// Marker on the left-hand banner label `Text`. Carries either -/// "▌ replay" (during playback) or "▌ replay complete" (once -/// finished — the cursor-block prefix matches the splash boot-screen -/// idiom so the surface reads as a Terminal output line); the -/// completion-text-update system swaps the contents in place. -#[derive(Component, Debug)] -pub struct ReplayOverlayBannerText; - -/// Marker on the centre progress `Text`. Updated every frame to reflect -/// the current `(cursor, total)` returned by -/// [`ReplayPlaybackState::progress`]. -#[derive(Component, Debug)] -pub struct ReplayOverlayProgressText; - -/// Marker on the **floating** progress chip — a 2D world-space text -/// entity rendered above the destination pile of the most-recently- -/// applied move. Sits independently of the banner overlay (which -/// lives in the UI tree and never moves) so the player can see -/// progress without breaking eye contact with the focal card. -/// -/// Lifecycle matches the banner overlay: spawned by `spawn_overlay` -/// when a replay starts, despawned by `react_to_state_change` when -/// it ends. Position updated each frame by -/// `update_floating_progress_chip`. Hidden when cursor=0 (no moves -/// applied yet) or the last applied move was a `StockClick` (no -/// destination pile to follow). -#[derive(Component, Debug)] -pub struct ReplayFloatingProgressChip; - -/// Marker on the right-hand "Stop" button. Click handler queries for this -/// and calls [`stop_replay_playback`] when an `Interaction::Pressed` -/// transition is seen. -#[derive(Component, Debug)] -pub struct ReplayStopButton; - -/// Marker on the Pause / Resume button. Click handler queries for this -/// and calls [`toggle_pause_replay_playback`] on each press. The -/// button's label text is repainted in lockstep by -/// `update_pause_button_label` so it always reflects the action the -/// next click will perform ("Pause" while running, "Resume" while -/// paused). -#[derive(Component, Debug)] -pub struct ReplayPauseButton; - -/// Marker on the Step button. Click handler queries for this and -/// calls [`step_replay_playback`] — only meaningful when paused -/// (clicks while running are no-ops because the tick loop would race -/// the manual advance). The button stays visually present but -/// unresponsive while the playback is running so the player has a -/// stable layout to scan. -#[derive(Component, Debug)] -pub struct ReplayStepButton; - -/// Marker on the full-screen tableau dim layer spawned at the start of -/// every replay. The dim layer is a 100 % × 100 % `Node` at -/// [`Z_REPLAY_DIM`] (= `Z_REPLAY_OVERLAY - 1`) with a semi-transparent -/// black `BackgroundColor`. It darkens the card world so the replay -/// chrome reads clearly against it without obscuring card positions. -/// -/// Carries no [`Interaction`] component — purely visual; pointer events -/// pass through to the underlying UI and world-space systems. -/// Despawned by `react_to_state_change` when the replay ends. -#[derive(Component, Debug)] -pub struct ReplayTableauDimLayer; - -/// Marker on the small caption sitting below the "▌ replay" -/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a -/// replay is playing — a compact, monotonically-increasing identifier -/// that mirrors the `▌replay.tsx` / `GAME #2024-127` Terminal-output -/// motif from the mockup. The caption is empty in `Inactive` / -/// `Completed` since the replay is consumed when transitioning out -/// of `Playing` and the identifier is no longer recoverable from -/// state alone. -#[derive(Component, Debug)] -pub struct ReplayOverlayGameCaption; - -/// Marker on the accent "fill" of the bottom-edge scrub bar. The -/// `Node`'s `width` is rewritten every frame the cursor advances to -/// `cursor / total` of the bar's full width, so the player has a -/// continuous visual cue of how far through the replay they are. -/// -/// Distinct from the simpler text-based `ReplayOverlayProgressText` -/// (which spells out "MOVE N/M" in a chip): the scrub fill gives immediate -/// at-a-glance positioning; the text gives the exact numbers. Both -/// surfaces stay together because they answer the same question for -/// players with different scanning preferences. -#[derive(Component, Debug)] -pub struct ReplayOverlayScrubFill; - -/// Marker for the WIN MOVE tick on the scrub bar — a small absolute- -/// positioned `Node` anchored at `replay.win_move_index / total` along -/// the track. Painted in [`STATE_SUCCESS`] so the player can see at a -/// glance where the winning move sits relative to the playback cursor. -/// -/// Static — the position is set at spawn time and never changes during -/// playback (the underlying replay's `win_move_index` is immutable -/// while `Playing`). Despawned with the rest of the overlay tree when -/// the replay state transitions back to `Inactive`. -/// -/// Spawned only when the active replay carries -/// [`Replay::win_move_index`](solitaire_data::Replay::win_move_index) -/// `= Some(_)` — older replays loaded from disk pre-date the field -/// and have no win index to surface. -#[derive(Component, Debug)] -pub struct ReplayOverlayWinMoveMarker; - -/// Marker for the fixed-position notches on the scrub bar — five 1px -/// vertical ticks at 0 % / 25 % / 50 % / 75 % / 100 % that give the -/// player visual anchor points for "where am I, relative to the -/// quarter-marks of the replay." Mirrors the notch ladder in the -/// screen-takeover mockup at -/// `docs/ui-mockups/replay-overlay-mobile.html`. -/// -/// Static — positions are set at spawn time and never change. The -/// notches paint in [`BORDER_SUBTLE`] which is the same colour as the -/// unfilled track, so visibility comes from extending the notch -/// **vertically past** the 1px track (5px tall, anchored 2px above -/// the track top) rather than from colour contrast. Same trick the -/// WIN MOVE marker uses. -#[derive(Component, Debug)] -pub struct ReplayOverlayScrubNotch; - -/// Marker for the percentage labels under each scrub-bar notch -/// (`0%` / `25%` / `50%` / `75%` / `100%`). One label per notch; -/// labels live in a dedicated 16 px row below the 1 px scrub track -/// (the row that grew the banner from 60 → 76 px). -/// -/// Positioning follows a "endpoints flush to edges, middle three -/// anchored at percentage" pattern: the leftmost label uses -/// `left: 0`, the rightmost uses `right: 0`, and the middle three -/// (`25%` / `50%` / `75%`) anchor at `left: Val::Percent(p)`. This -/// avoids overflow at 100 % without needing CSS-style -/// `translate-x: -50%` centering (which Bevy 0.18 UI doesn't have a -/// clean equivalent for) — the trade-off is a slight right-of-notch -/// offset on the middle three, which is visually subtle at the -/// `TYPE_CAPTION` font size. -#[derive(Component, Debug)] -pub struct ReplayOverlayScrubNotchLabel; - -/// Per-arrow-key time-since-last-fire accumulators that drive the -/// continuous-scrub repeat behaviour for held arrow keys. Each -/// frame the key is held, the corresponding accumulator absorbs -/// `time.delta_secs()`; when it exceeds -/// [`SCRUB_REPEAT_INTERVAL_SECS`] the handler fires another step -/// and resets the accumulator. -/// -/// `just_pressed` events bypass the accumulator entirely and fire -/// immediately — only *repeat* fires (while held) are gated by -/// the interval. Releases reset the accumulator to 0 so the next -/// fresh press fires immediately rather than at half-interval. -#[derive(Resource, Default, Debug)] -struct ReplayScrubKeyHold { - left_held_secs: f32, - right_held_secs: f32, -} - -/// Marker on the keybind-hint footer row at the bottom edge of the -/// banner. Carries two `Text` children: a vim-style mode indicator -/// (`▌ NORMAL │ replay`) on the left and the keybind hint -/// (`[SPACE] pause/resume`) on the right. 1 px top border in -/// [`BORDER_SUBTLE`] separates it from the notch-label row above. -/// -/// Surfaces the existing Space-key accelerator visually so the -/// UI-first contract from CLAUDE.md §3.3 (every player action has -/// a visible UI control) holds for keyboard accelerators too. -/// Future commits that wire ESC for stop or ← / → for scrub will -/// extend the right-hand text in lockstep — the footer always -/// reflects what's actually wired, never aspirational. -#[derive(Component, Debug)] -pub struct ReplayOverlayKeybindFooter; - -/// Marker on the bottom-edge **Move Log** panel — a separate root -/// UI entity (not a child of the banner) that sits anchored to the -/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`) -/// plus a row showing the most-recently-applied move. -/// -/// Spawned by `spawn_overlay` alongside the banner and the -/// floating progress chip; despawned by `react_to_state_change` -/// on the same `Playing → Inactive` transition. Same lifecycle -/// pattern as `ReplayFloatingProgressChip` — a sibling root, not -/// a banner child, because it lives at a different screen anchor. -/// -/// First slice of the move-log mockup at -/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card". -/// Subsequent commits add prev/next rows and scrolling. -#[derive(Component, Debug)] -pub struct ReplayOverlayMoveLogPanel; - -/// Marker on the move-log panel's header `Text`. Carries -/// `▌ MOVE LOG · N/M` while a replay is playing; the -/// `update_move_log_header` system repaints it as the cursor -/// advances. -#[derive(Component, Debug)] -pub struct ReplayOverlayMoveLogHeader; - -/// Marker on the move-log panel's active-row `Text`. Carries the -/// most-recently-applied move's text (`47 │ waste → tableau 5`) -/// when `cursor > 0`; empty when no moves have been applied yet -/// (initial spawn) or in `Completed`/`Inactive` states. The -/// `update_move_log_active_row` system repaints it as the cursor -/// advances. -#[derive(Component, Debug)] -pub struct ReplayOverlayMoveLogActiveRow; - -/// Marker on a "previous move" row above the active row. -/// `offset` is the 1-based distance backwards from the active -/// row: `offset = 1` is the move applied just before the active -/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is -/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`] -/// rows render above the active row. -/// -/// Empty text when there isn't enough history (`offset >= cursor`, -/// e.g. cursor=1 has no prev rows; cursor=2 has only the -/// `offset = 1` row populated). -#[derive(Component, Debug)] -pub struct ReplayOverlayMoveLogPrevRow { - /// Distance backwards from the active row (1-based). - pub offset: u8, -} - -/// Marker on a "next move" row below the active row. `offset` -/// is the 1-based distance forward from the active row: -/// `offset = 1` is the move that will apply next -/// (`replay.moves[cursor]`, displayed as `cursor + 1`), -/// `offset = 2` is the one after that, and so on. Up to -/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row. -/// -/// Empty text when there isn't enough remaining replay -/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of -/// a 100-move replay shows offset 1 but offset 2 stays empty). -#[derive(Component, Debug)] -pub struct ReplayOverlayMoveLogNextRow { - /// Distance forward from the active row (1-based). - pub offset: u8, -} - -/// Marker added to every top-level entity spawned by [`spawn_overlay`]. -/// `react_to_state_change` uses a single `Query>` -/// to despawn all of them, rather than keeping a separate query per -/// entity type. Future sibling overlay surfaces just need this marker -/// at spawn time — no changes to the despawn logic required. -#[derive(Component, Debug)] -pub struct DespawnWithReplay; - -/// Marker on the mini-tableau preview panel root. A right-edge-anchored -/// panel that shows a compact summary of the live game state during -/// replay: the four foundation tops and the stock / waste heads. -/// Spawned as a sibling root entity (same lifecycle pattern as -/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`. -#[derive(Component, Debug)] -pub struct ReplayMiniTableauPanel; - -/// Marker on the foundations row `Text` inside the mini-tableau panel. -/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by -/// `update_mini_tableau` whenever [`GameStateResource`] changes. -#[derive(Component, Debug)] -pub struct ReplayMiniTableauFoundations; - -/// Marker on the stock/waste row `Text` inside the mini-tableau panel. -/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever -/// [`GameStateResource`] changes. -#[derive(Component, Debug)] -pub struct ReplayMiniTableauStockWaste; - -// --------------------------------------------------------------------------- -// Plugin -// --------------------------------------------------------------------------- - -/// Bevy plugin that registers every system needed to drive the replay -/// overlay's lifecycle. -/// -/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`] -/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert -/// the resource manually and exercise the overlay in isolation. -pub struct ReplayOverlayPlugin; - -impl Plugin for ReplayOverlayPlugin { - fn build(&self, app: &mut App) { - // The systems are ordered so that, on a single frame: - // 1. The state-watcher spawns or despawns the overlay if the - // `ReplayPlaybackState` resource changed. - // 2. The completion-text update swaps the banner label when the - // state is `Completed`. - // 3. The progress-text update writes the latest "Move N of M". - // 4. The Stop-button click handler reads `Interaction::Pressed` - // and calls `stop_replay_playback` (which mutates the state). - // Putting Stop last means a click in frame N is observed by - // `react_to_state_change` in frame N+1, which then despawns the - // overlay in response — a clean state-driven loop. - // Step-button handler dispatches into the same canonical move - // / draw events that the tick loop fires. Register them - // defensively here so this plugin can run under - // `MinimalPlugins` without the playback plugin attached; - // `add_message` is idempotent so the duplicate registration - // in production (alongside `replay_playback`) is harmless. - app.init_resource::() - .add_message::() - .add_message::() - .add_message::() - .add_message::() - .add_systems( - Update, - ( - react_to_state_change, - update_banner_label, - update_progress_text, - update_floating_progress_chip, - update_scrub_fill, - update_move_log_header, - update_move_log_active_row, - update_move_log_prev_rows, - update_move_log_next_rows, - update_mini_tableau_foundations, - update_mini_tableau_stock_waste, - update_pause_button_label, - handle_pause_button, - handle_step_button, - handle_pause_keyboard, - handle_stop_keyboard, - handle_arrow_keyboard, - handle_stop_button, - ) - .chain(), - ); - } -} - -// --------------------------------------------------------------------------- -// Spawning -// --------------------------------------------------------------------------- - -/// Reads [`ReplayPlaybackState`] every time the resource changes and either -/// spawns or despawns the overlay accordingly. Treats the resource as the -/// single source of truth — the spawn / despawn decision is derived from -/// `is_playing() || is_completed()` rather than tracking previous-state -/// transitions explicitly, which keeps the system stateless. -fn react_to_state_change( - mut commands: Commands, - state: Res, - roots: Query>, - despawnable: Query>, - font_res: Option>, -) { - if !state.is_changed() { - return; - } - - let should_be_visible = state.is_playing() || state.is_completed(); - let already_spawned = roots.iter().next().is_some(); - - if should_be_visible && !already_spawned { - spawn_overlay(&mut commands, font_res.as_deref(), &state); - } else if !should_be_visible && already_spawned { - // Despawn all sibling root entities in one loop — every entity - // spawned by `spawn_overlay` carries `DespawnWithReplay` for - // exactly this purpose. - for entity in &despawnable { - commands.entity(entity).despawn(); - } - } - // The `should_be_visible && already_spawned` branch is a no-op here — - // the per-frame text update systems below repaint the banner label - // and progress readout in place without a respawn. -} - -/// Spawns the banner — a flex-row Node anchored to the top edge of the -/// window with three children: the "▌ replay" / "▌ replay complete" label, -/// the centred progress text, and the right-aligned Stop button. -fn spawn_overlay( - commands: &mut Commands, - font_res: Option<&FontResource>, - state: &ReplayPlaybackState, -) { - let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); - // Clone for the floating chip spawn that runs *after* the - // banner's `.with_children(|banner| { ... })` closure consumes - // the original `font_handle`. Cheap — Bevy's `Handle` is - // `Arc`-backed, the clone bumps a refcount. - let font_handle_for_floating = font_handle.clone(); - // Second clone for the scrub-bar label row and keybind footer - // inside the outer banner closure. The inner top-row closure - // consumes the original `font_handle` for the progress-chip - // text, so by the time the outer closure reaches the - // label-row / footer spawns the original is gone. - // `font_handle_for_labels` is `.clone()`'d (never moved) inside - // the labels closure, so it's still alive for the footer - // spawn afterwards — single shared clone covers both. - let font_handle_for_labels = font_handle.clone(); - // Third clone for the move-log panel — a separate root - // entity spawned after the banner closure closes. Mirrors the - // floating-chip clone reasoning. - let font_handle_for_move_log = font_handle.clone(); - // Fourth clone for the mini-tableau preview panel. - let font_handle_for_mini_tableau = font_handle.clone(); - - let banner_label = if state.is_completed() { - "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. - } else { - "\u{258C} replay" // ▌ - }; - let progress_label = format_progress(state); - - // Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54). - // Spawned first so it sits behind the banner (z=55) and move-log (z=55) - // in the UI stacking context. World-space sprites (cards, badges) are - // always below any UI node, so the dim layer darkens the entire - // gameplay scene without needing to touch card_plugin. No Interaction - // component — purely visual. - commands.spawn(( - ReplayTableauDimLayer, - DespawnWithReplay, - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - top: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Percent(100.0), - ..default() - }, - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)), - ZIndex(Z_REPLAY_DIM), - GlobalZIndex(Z_REPLAY_DIM), - )); - - let banner_bg = Color::srgba( - BG_ELEVATED_HI.to_srgba().red, - BG_ELEVATED_HI.to_srgba().green, - BG_ELEVATED_HI.to_srgba().blue, - BANNER_ALPHA, - ); - - commands - .spawn(( - ReplayOverlayRoot, - DespawnWithReplay, - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - top: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Px(BANNER_HEIGHT), - // Column outer so the content row sits above the 1px - // scrub bar at the bottom edge. - flex_direction: FlexDirection::Column, - ..default() - }, - BackgroundColor(banner_bg), - // Pin the banner to its z layer in both the local and the - // global stacking context — `GlobalZIndex` matters because - // the overlay is a top-level Node (no parent), and Bevy 0.18 - // has historically had subtle stacking-context drift here. - ZIndex(Z_REPLAY_OVERLAY), - GlobalZIndex(Z_REPLAY_OVERLAY), - )) - .with_children(|banner| { - // Top row: the existing content (label / progress / Stop). - banner - .spawn(Node { - flex_grow: 1.0, - flex_direction: FlexDirection::Row, - align_items: AlignItems::Center, - justify_content: JustifyContent::SpaceBetween, - padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), - column_gap: VAL_SPACE_4, - ..default() - }) - .with_children(|row| { - // Left: column with the accent "▌ replay" headline - // above and a small `GAME #YYYY-DDD` caption below. - // The caption mirrors the mockup's right-anchored - // game identifier but stays visually grouped with - // the headline so the two pieces of "this is a - // replay of game X" read as a single unit. - row.spawn(Node { - flex_direction: FlexDirection::Column, - align_items: AlignItems::FlexStart, - row_gap: Val::Px(2.0), - ..default() - }) - .with_children(|left| { - left.spawn(( - ReplayOverlayBannerText, - Text::new(banner_label), - TextFont { - font: font_handle.clone(), - font_size: TYPE_HEADLINE, - ..default() - }, - TextColor(ACCENT_PRIMARY), - )); - left.spawn(( - ReplayOverlayGameCaption, - Text::new(format_game_caption(state).unwrap_or_default()), - TextFont { - font: font_handle.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); - }); - - // Centre: progress readout, wrapped in a 1 px - // ACCENT_PRIMARY-bordered chip so it reads as a - // discrete callout rather than free-floating - // text. No fill — the Terminal aesthetic gets - // depth from borders + tonal layering, not - // shadows. The marker stays on the inner Text so - // `update_progress_text` keeps working unchanged. - row.spawn(( - Node { - border: UiRect::all(Val::Px(1.0)), - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), - ..default() - }, - BorderColor::all(ACCENT_PRIMARY), - )) - .with_children(|chip| { - chip.spawn(( - ReplayOverlayProgressText, - Text::new(progress_label), - TextFont { - font: font_handle, - font_size: TYPE_BODY, - ..default() - }, - TextColor(TEXT_PRIMARY), - )); - }); - - // Right: Stop button. Tertiary variant — the - // action is available but not the loudest element - // in the banner; the "Replay" primary accent owns - // that slot. `spawn_modal_button` gives us hover / - // press paint and focus rings for free via the - // existing `UiModalPlugin` paint system. - row.spawn(Node { - flex_direction: FlexDirection::Row, - align_items: AlignItems::Center, - column_gap: VAL_SPACE_2, - ..default() - }) - .with_children(|wrap| { - // Pause / Resume label is set from the current - // state so a freshly-spawned overlay (which - // currently always starts unpaused) reads - // "Pause". `update_pause_button_label` - // repaints it whenever the state changes. - spawn_modal_button( - wrap, - ReplayPauseButton, - pause_button_label(state), - None, - ButtonVariant::Tertiary, - font_res, - ); - spawn_modal_button( - wrap, - ReplayStepButton, - "Step", - None, - ButtonVariant::Tertiary, - font_res, - ); - spawn_modal_button( - wrap, - ReplayStopButton, - "Stop", - None, - ButtonVariant::Tertiary, - font_res, - ); - }); - }); - - // Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`, - // fill in `ACCENT_PRIMARY`. The fill width is rewritten by - // [`update_scrub_fill`] every tick the cursor advances. - // Initial fill width matches the spawn-time progress so the - // first-frame paint already reflects state instead of - // popping from 0 → cursor on the first tick. - let initial_scrub_pct = scrub_pct(state); - let win_pct = win_move_marker_pct(state); - banner - .spawn(( - Node { - width: Val::Percent(100.0), - height: Val::Px(1.0), - ..default() - }, - BackgroundColor(BORDER_SUBTLE), - // HC marker: bumps the 1 px track from #505050 - // → #a0a0a0 under high-contrast mode. The track - // paints via BackgroundColor (it's a 1 px Node, - // not a border on a wider container) so the - // BorderColor-targeting HighContrastBorder marker - // doesn't apply — HighContrastBackground is the - // parallel primitive for this case. - HighContrastBackground::with_default(BORDER_SUBTLE), - )) - .with_children(|track| { - track.spawn(( - ReplayOverlayScrubFill, - Node { - width: Val::Percent(initial_scrub_pct), - height: Val::Percent(100.0), - ..default() - }, - BackgroundColor(ACCENT_PRIMARY), - )); - // WIN MOVE marker — small green tick anchored at - // `win_move_index / total`. Spawned only when the - // active replay carries the field; older replays - // pre-dating `win_move_index` simply don't get a - // marker. Centered vertically on the 1px track via - // a 3px-tall node offset 1px above the track top so - // 1px sits above and 1px below the track line. - if let Some(pct) = win_pct { - track.spawn(( - ReplayOverlayWinMoveMarker, - Node { - position_type: PositionType::Absolute, - left: Val::Percent(pct), - top: Val::Px(-1.0), - width: Val::Px(2.0), - height: Val::Px(3.0), - ..default() - }, - BackgroundColor(STATE_SUCCESS), - // HC bump: lime → brighter lime so the win - // marker reads clearly above the bumped - // notch ticks (BORDER_SUBTLE_HC gray) under - // high-contrast mode. - HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC), - )); - } - // Fixed quarter-mark notches: five 1px vertical - // ticks at 0 / 25 / 50 / 75 / 100 % that give the - // player visual anchor points without needing to - // mentally bisect the bar. Painted in - // BORDER_SUBTLE — same colour as the unfilled - // track — so visibility comes from extending past - // the 1px track height (5px tall, anchored 2px - // above the track top) rather than colour - // contrast. Spawned *after* the WIN MOVE marker - // so a notch and the marker landing on the same - // percentage paint the marker on top. - for pct in scrub_notch_positions() { - track.spawn(( - ReplayOverlayScrubNotch, - Node { - position_type: PositionType::Absolute, - left: Val::Percent(pct), - top: Val::Px(-2.0), - width: Val::Px(1.0), - height: Val::Px(5.0), - ..default() - }, - BackgroundColor(BORDER_SUBTLE), - // Same HC-paint reasoning as the track - // above: 5 px tall × 1 px wide tick mark - // paints via BackgroundColor, so - // HighContrastBackground (not -Border) is - // the right marker. - HighContrastBackground::with_default(BORDER_SUBTLE), - )); - } - }); - - // Third banner row: percentage labels (`0%` / `25%` / - // `50%` / `75%` / `100%`) under each scrub-bar notch. - // Sibling of (not child of) the 1px track because labels - // need their own vertical real estate (TYPE_CAPTION text - // doesn't fit inside a 1px container). Position math: - // track Node has `Val::Percent(p)` referencing the - // banner's full width; this label row also has the - // banner's full width, so labels at the same - // percentages line up vertically with their notches. - let labels = scrub_notch_labels(); - let positions = scrub_notch_positions(); - banner - .spawn(Node { - width: Val::Percent(100.0), - height: Val::Px(SCRUB_LABEL_ROW_HEIGHT), - position_type: PositionType::Relative, - ..default() - }) - .with_children(|row| { - for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() { - // Endpoints flush to the row's edges; middle - // three labels use the `translateX(-50%)` - // pattern for Bevy 0.18 UI: a fixed-width - // container is placed at `left: Percent(pct)` - // then shifted left by half its own width via - // `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`. - // `Justify::Center` renders the text centred - // within the container so the text's visual - // centre coincides with the notch line. - let (node, justify) = if i == 0 { - ( - Node { - position_type: PositionType::Absolute, - top: Val::Px(2.0), - left: Val::Px(0.0), - ..default() - }, - Justify::Left, - ) - } else if i == labels.len() - 1 { - ( - Node { - position_type: PositionType::Absolute, - top: Val::Px(2.0), - right: Val::Px(0.0), - ..default() - }, - Justify::Right, - ) - } else { - ( - Node { - position_type: PositionType::Absolute, - top: Val::Px(2.0), - left: Val::Percent(*pct), - width: Val::Px(SCRUB_LABEL_CENTER_WIDTH), - margin: UiRect { - left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0), - ..default() - }, - ..default() - }, - Justify::Center, - ) - }; - row.spawn(( - ReplayOverlayScrubNotchLabel, - node, - Text::new(*label), - TextLayout::new_with_justify(justify), - TextFont { - font: font_handle_for_labels.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - // TEXT_SECONDARY keeps the subdued visual - // hierarchy (caption, not headline) while - // staying readable against BG_ELEVATED_HI. - TextColor(TEXT_SECONDARY), - )); - } - }); - - // Fourth banner row: keybind-hint footer. Vim-style - // mode line on the left (`▌ NORMAL │ replay`), keybind - // hint on the right (`[SPACE] pause/resume`), 1px top - // border in BORDER_SUBTLE separating it from the - // labels row above. Surfaces the existing Space - // accelerator visually so CLAUDE.md §3.3's UI-first - // contract holds for keyboard accelerators too. - banner - .spawn(( - ReplayOverlayKeybindFooter, - Node { - width: Val::Percent(100.0), - height: Val::Px(KEYBIND_FOOTER_HEIGHT), - flex_direction: FlexDirection::Row, - justify_content: JustifyContent::SpaceBetween, - align_items: AlignItems::Center, - padding: UiRect::horizontal(VAL_SPACE_4), - border: UiRect::top(Val::Px(1.0)), - ..default() - }, - BorderColor::all(BORDER_SUBTLE), - // Marker for `apply_high_contrast_borders`: bumps - // the 1 px top border from BORDER_SUBTLE (#505050) - // to BORDER_SUBTLE_HC (#a0a0a0) when - // `Settings::high_contrast_mode` is on. Without - // this the footer reads as floating loose under - // HC because the border that visually anchors it - // to the labels row above is near-invisible. - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|footer| { - footer.spawn(( - Text::new(keybind_footer_mode_text()), - TextFont { - font: font_handle_for_labels.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); - if SHOW_KEYBOARD_ACCELERATORS { - footer.spawn(( - Text::new(keybind_footer_hint_text()), - TextFont { - font: font_handle_for_labels.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); - } - }); - }); - - // Floating progress chip — a 2D world-space `Text2d` rendered - // above the destination pile of the most-recently-applied move. - // Sibling of (not child of) the banner overlay because it lives - // in world-space coordinates, not the UI tree. Spawned hidden; - // `update_floating_progress_chip` shows + positions it on the - // first frame the cursor advances past 0. Lifecycle matches - // the banner overlay — `react_to_state_change` despawns both - // when the replay state transitions back to `Inactive`. - commands.spawn(( - ReplayFloatingProgressChip, - DespawnWithReplay, - Text2d::new(format_progress(state)), - TextFont { - font: font_handle_for_floating, - font_size: TYPE_BODY, - ..default() - }, - TextColor(TEXT_PRIMARY), - // High Z keeps the chip above every card stack - // (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards - // stack to the low double digits at most). - Transform::from_xyz(0.0, 0.0, 100.0), - Visibility::Hidden, - )); - - // Move-log panel — a separate root UI entity anchored to the - // viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header - // plus a row showing the most-recently-applied move. - // Sibling-of-banner pattern (not a banner child) because the - // panel lives at a different screen anchor and has its own - // spawn/despawn lifecycle synced via `react_to_state_change`. - let banner_bg = Color::srgba( - BG_ELEVATED_HI.to_srgba().red, - BG_ELEVATED_HI.to_srgba().green, - BG_ELEVATED_HI.to_srgba().blue, - BANNER_ALPHA, - ); - commands - .spawn(( - ReplayOverlayMoveLogPanel, - DespawnWithReplay, - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - bottom: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Px(MOVE_LOG_PANEL_HEIGHT), - flex_direction: FlexDirection::Column, - align_items: AlignItems::FlexStart, - justify_content: JustifyContent::Center, - padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), - row_gap: VAL_SPACE_1, - border: UiRect::top(Val::Px(1.0)), - ..default() - }, - BackgroundColor(banner_bg), - BorderColor::all(BORDER_SUBTLE), - // Same z-stack rationale as the banner — above gameplay, - // below modals. - ZIndex(Z_REPLAY_OVERLAY), - GlobalZIndex(Z_REPLAY_OVERLAY), - // HC marker so the top border bumps under HC mode. - // Without it the panel reads as floating loose because - // the border that anchors it to the gameplay area above - // is near-invisible at #505050. - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|panel| { - // Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for - // the cursor-block prefix consistency with the banner - // headline. - panel.spawn(( - ReplayOverlayMoveLogHeader, - Text::new(format_move_log_header(state)), - TextFont { - font: font_handle_for_move_log.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(ACCENT_PRIMARY), - )); - // Prev rows — render above the active row in display - // order (oldest first), so the active row sits at the - // bottom of the visible window. Spawn from - // MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so - // the highest-offset (oldest) row is topmost in the - // panel's flex column. Each carries - // ReplayOverlayMoveLogPrevRow { offset } — the - // per-frame system reads `offset` and recomputes the - // text on cursor advance. Painted in TEXT_SECONDARY - // so the active row stands out from context rows. - for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() { - panel.spawn(( - ReplayOverlayMoveLogPrevRow { offset }, - Text::new(format_kth_recent_row(state, offset as usize + 1)), - TextFont { - font: font_handle_for_move_log.clone(), - font_size: TYPE_BODY, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); - } - // Active move row. Wrapped in a Node with an - // ACCENT_PRIMARY background so the row reads as - // "current focus" — the player can scan vertically - // and the highlighted row is the move that just - // applied. Empty text at spawn time when cursor=0; - // the per-frame update system populates it as the - // cursor advances. Text colour is TEXT_PRIMARY_HC - // (near-white) for contrast against the brick-red - // background — same trick as the modal-button - // primary-variant paint. - panel - .spawn(( - Node { - width: Val::Percent(100.0), - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), - ..default() - }, - BackgroundColor(ACCENT_PRIMARY), - )) - .with_children(|active| { - active.spawn(( - ReplayOverlayMoveLogActiveRow, - Text::new(format_active_move_row(state)), - TextFont { - font: font_handle_for_move_log.clone(), - font_size: TYPE_BODY, - ..default() - }, - TextColor(TEXT_PRIMARY_HC), - )); - }); - // Next rows — render below the active row in display - // order (offset 1 directly below active, then offset - // 2). Same TEXT_SECONDARY de-emphasis as prev rows so - // the active row stays the focal point. Empty text - // late in the replay (when cursor + offset exceeds - // moves.len()) — the panel under-fills gracefully. - for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 { - panel.spawn(( - ReplayOverlayMoveLogNextRow { offset }, - Text::new(format_kth_next_row(state, offset as usize)), - TextFont { - font: font_handle_for_move_log.clone(), - font_size: TYPE_BODY, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); - } - }); - - // Mini-tableau preview panel — right-edge anchor, just below the banner. - // Compact two-row readout: foundation tops then stock/waste head. - // Sibling-of-banner pattern (separate root entity, own spawn/despawn). - let banner_bg = Color::srgba( - BG_ELEVATED_HI.to_srgba().red, - BG_ELEVATED_HI.to_srgba().green, - BG_ELEVATED_HI.to_srgba().blue, - BANNER_ALPHA, - ); - commands - .spawn(( - ReplayMiniTableauPanel, - DespawnWithReplay, - Node { - position_type: PositionType::Absolute, - right: Val::Px(0.0), - top: Val::Px(MINI_TABLEAU_TOP_OFFSET), - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), - flex_direction: FlexDirection::Column, - align_items: AlignItems::FlexStart, - row_gap: VAL_SPACE_1, - border: UiRect::left(Val::Px(1.0)), - ..default() - }, - BackgroundColor(banner_bg), - BorderColor::all(BORDER_SUBTLE), - ZIndex(Z_REPLAY_OVERLAY), - GlobalZIndex(Z_REPLAY_OVERLAY), - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|panel| { - panel.spawn(( - Text::new("\u{258C} BOARD"), - TextFont { - font: font_handle_for_mini_tableau.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(ACCENT_PRIMARY), - )); - panel.spawn(( - ReplayMiniTableauFoundations, - Text::new("F: -- -- -- --"), - TextFont { - font: font_handle_for_mini_tableau.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_PRIMARY), - )); - panel.spawn(( - ReplayMiniTableauStockWaste, - Text::new("STK:-- WST:--"), - TextFont { - font: font_handle_for_mini_tableau, - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); - }); -} - -/// Pure helper — returns the scrub-fill width as a percentage of the -/// track for the given playback state. `Completed` reads as 100 %; -/// `Inactive` and `Playing` with no progress read as 0 %. -fn scrub_pct(state: &ReplayPlaybackState) -> f32 { - if state.is_completed() { - return 100.0; - } - match state.progress() { - Some((_, 0)) | None => 0.0, - Some((cursor, total)) => { - let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0); - frac * 100.0 - } - } -} - -/// Pure helper — returns the fixed scrub-bar notch positions as -/// percentages along the track. Five evenly-spaced notches at the -/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than -/// const) so the unit-test surface is obvious and a future -/// regression — e.g. someone simplifying to three notches — fails -/// at the helper test rather than at visual review. -fn scrub_notch_positions() -> [f32; 5] { - [0.0, 25.0, 50.0, 75.0, 100.0] -} - -/// Pure helper — returns the percentage-label text for each notch, -/// in left-to-right order. Paired with [`scrub_notch_positions`] so -/// `labels[i]` belongs at `positions[i]`. Lifted to a function for -/// the same reason as the positions helper: a clean unit-test -/// surface that fails at a regression (e.g. someone simplifying -/// `100%` → `MAX`) rather than at visual review. -fn scrub_notch_labels() -> [&'static str; 5] { - ["0%", "25%", "50%", "75%", "100%"] -} - -/// Pure helper — returns the vim-style mode indicator text shown on -/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay` -/// matches the `▌replay.tsx` motif from the splash boot-screen and -/// the screen-takeover mockup. The cursor block (`▌`) matches the -/// banner-label prefix; "NORMAL" is the vim mode (mockup parity); -/// "replay" identifies the surface. -fn keybind_footer_mode_text() -> &'static str { - "\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay -} - -/// Pure helper — returns the keybind-hint text shown on the right -/// side of the keybind-hint footer row. Lists only the keys that -/// are *actually wired* today: the Space accelerator for -/// pause/resume, the ESC accelerator for stop, and the ← / → -/// accelerators for paused single-move stepping. The footer never -/// lists unimplemented keybinds (would lie to users). -fn keybind_footer_hint_text() -> &'static str { - if SHOW_KEYBOARD_ACCELERATORS { - "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator - } else { - "" - } -} - -/// Pure helper — returns the WIN MOVE marker's left-edge position as -/// a percentage of the scrub track, or `None` when no marker should -/// be drawn. -/// -/// `None` is returned in any of these cases: -/// - The state isn't `Playing` (no replay attached). -/// - The replay's `win_move_index` is `None` (older replay loaded -/// from disk pre-dating the field). -/// - The replay's move list is empty (shouldn't happen for real wins, -/// but guards the divide-by-zero). -/// -/// The percentage clamps to `[0, 100]` so a malformed -/// `win_move_index >= total` (defensive — shouldn't happen) doesn't -/// position the marker outside the track. -fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option { - let ReplayPlaybackState::Playing { replay, .. } = state else { - return None; - }; - let idx = replay.win_move_index?; - let total = replay.moves.len(); - if total == 0 { - return None; - } - let frac = (idx as f32 / total as f32).clamp(0.0, 1.0); - Some(frac * 100.0) -} - -// --------------------------------------------------------------------------- -// Per-frame text updates -// --------------------------------------------------------------------------- - -/// Overwrites the banner label whenever the resource changes — covers the -/// `Playing → Completed` transition by swapping "▌ replay" for -/// "▌ replay complete" in place without despawning the overlay. -fn update_banner_label( - state: Res, - mut q: Query<&mut Text, With>, -) { - if !state.is_changed() { - return; - } - let label = if state.is_completed() { - "\u{258C} replay complete" // ▌ - } else if state.is_playing() { - "\u{258C} replay" // ▌ - } else { - return; - }; - for mut text in &mut q { - **text = label.to_string(); - } -} - -/// Repaints the "Move N of M" centre readout every frame the cursor moves. -/// Cheap — early-exits if the resource has not changed since the last -/// frame so idle replays don't churn the text mesh. -fn update_progress_text( - state: Res, - mut q: Query<&mut Text, With>, -) { - if !state.is_changed() { - return; - } - let label = format_progress(&state); - for mut text in &mut q { - **text = label.clone(); - } -} - -/// Repositions the floating progress chip above the destination -/// pile of the most-recently-applied move and repaints its text. -/// -/// The chip is hidden when: -/// - the cursor is at 0 (no moves applied yet — chip would have -/// nowhere meaningful to land), OR -/// - the most-recently-applied move was a `StockClick` (no -/// destination pile — stock-click feedback already lives at -/// the stock pile and we don't want the chip to jitter back -/// to the stock pile every cycle). -/// -/// When visible, the chip's world-space `Transform.translation` -/// is set to the destination pile's centre plus a fixed upward -/// offset (`card_size.y * 0.6`) so the chip floats just above -/// the top edge of the card. World-space placement (rather than -/// UI-space + camera projection) keeps the math trivial and means -/// the chip stays correctly positioned through window resizes -/// without any extra wiring — `LayoutResource` already drives -/// every other piece of pile geometry. -fn update_floating_progress_chip( - state: Res, - layout: Option>, - mut chips: Query< - (&mut Transform, &mut Visibility, &mut Text2d), - With, - >, -) { - let Some(layout) = layout else { - return; - }; - - // Resolve the destination pile of the last-applied move (if - // any). `cursor` is the index of the *next* move to apply, so - // the most-recently-applied move sits at `cursor - 1`. - let dest_pile = match state.as_ref() { - ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => { - match &replay.moves[cursor - 1] { - ReplayMove::Move { to, .. } => Some(to.clone()), - ReplayMove::StockClick => None, - } - } - _ => None, - }; - - let Some(world_pos) = dest_pile - .as_ref() - .and_then(|p| layout.0.pile_positions.get(p).copied()) - else { - // Nothing to point at — hide every chip and exit. - for (_, mut visibility, _) in chips.iter_mut() { - *visibility = Visibility::Hidden; - } - return; - }; - - // Position above the destination pile by ~60 % of a card - // height. Half a card lifts above the centre, the extra 10 % - // is breathing room above the top edge so the chip doesn't - // visually clip the card. - let above = Vec2::new(0.0, layout.0.card_size.y * 0.6); - let target = (world_pos + above).extend(100.0); - let label = format_progress(&state); - - for (mut transform, mut visibility, mut text2d) in chips.iter_mut() { - transform.translation = target; - *visibility = Visibility::Inherited; - if **text2d != label { - **text2d = label.clone(); - } - } -} - -/// Repaints the move-log panel's `▌ MOVE LOG · N/M` header text -/// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits -/// when nothing moved so an idle replay leaves the text mesh -/// untouched. -fn update_move_log_header( - state: Res, - mut q: Query<&mut Text, With>, -) { - if !state.is_changed() { - return; - } - let label = format_move_log_header(&state); - for mut text in &mut q { - **text = label.clone(); - } -} - -/// Repaints the move-log panel's active-row text whenever -/// [`ReplayPlaybackState`] changes. Same change-detection guard -/// as the header updater. Empty string at `cursor == 0` (no move -/// applied yet) and in non-`Playing` states; populated otherwise. -fn update_move_log_active_row( - state: Res, - mut q: Query<&mut Text, With>, -) { - if !state.is_changed() { - return; - } - let label = format_active_move_row(&state); - for mut text in &mut q { - **text = label.clone(); - } -} - -/// Repaints every "previous move" row text whenever -/// [`ReplayPlaybackState`] changes. Each row's `offset` is read -/// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`] -/// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3). -/// Rows with `offset >= cursor` paint as empty — the panel -/// gracefully under-fills early in a replay without spurious -/// "out-of-range" text. -fn update_move_log_prev_rows( - state: Res, - mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>, -) { - if !state.is_changed() { - return; - } - for (row, mut text) in &mut q { - let label = format_kth_recent_row(&state, row.offset as usize + 1); - **text = label; - } -} - -/// Repaints every "next move" row text whenever -/// [`ReplayPlaybackState`] changes. Symmetric to the prev-row -/// updater but feeds [`format_kth_next_row`]. Rows where -/// `cursor + offset > moves.len()` paint as empty — the panel -/// gracefully under-fills late in a replay (e.g. final moves) -/// without spurious out-of-range text. -fn update_move_log_next_rows( - state: Res, - mut q: Query<(&ReplayOverlayMoveLogNextRow, &mut Text)>, -) { - if !state.is_changed() { - return; - } - for (row, mut text) in &mut q { - let label = format_kth_next_row(&state, row.offset as usize); - **text = label; - } -} - -/// Repaints the bottom-edge accent scrub fill to mirror cursor progress. -/// Same change-detection guard as the text updaters — the overlay -/// already early-exits when nothing moved, so an idle replay leaves the -/// scrub bar's `Node` untouched. -fn update_scrub_fill( - state: Res, - mut q: Query<&mut Node, With>, -) { - if !state.is_changed() { - return; - } - let pct = scrub_pct(&state); - for mut node in &mut q { - node.width = Val::Percent(pct); - } -} - -/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given -/// state. Returns `None` for `Inactive` / `Completed` (the replay is -/// consumed when transitioning out of `Playing`, so the identifier -/// isn't recoverable from state in those branches); spawn-time -/// callers fall back to an empty string. -/// -/// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact -/// monotonically-increasing identifier shaped like `2026-127` — same -/// shape as the mockup's `GAME #2024-127` motif. -fn format_game_caption(state: &ReplayPlaybackState) -> Option { - match state { - ReplayPlaybackState::Playing { replay, .. } => Some(format!( - "GAME #{}-{:03}", - replay.recorded_at.year(), - replay.recorded_at.ordinal() - )), - ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None, - } -} - -/// Pure helper — formats the centre progress readout for the given state. -/// Exposed at module scope so the spawn path and the per-frame update -/// path produce the exact same string. -fn format_progress(state: &ReplayPlaybackState) -> String { - match state.progress() { - // `MOVE N/M` (uppercase + slash) reads as a Terminal output - // line and matches the floating-chip motif in the mockup at - // `docs/ui-mockups/replay-overlay-mobile.html`. - Some((cursor, total)) => format!("MOVE {cursor}/{total}"), - None if state.is_completed() => "REPLAY COMPLETE".to_string(), - None => String::new(), - } -} - -/// Pure helper — formats a [`PileType`] as a short, lowercase, -/// 1-indexed display string for the move-log row. `Foundation(2)` -/// renders as `"foundation 3"` rather than `"foundation 2"` so -/// players see human-friendly numbers; the underlying enum -/// remains 0-indexed. -/// -/// Returns `String` rather than `&'static str` because the -/// `Foundation` / `Tableau` variants need formatting; the static -/// variants (`Stock`, `Waste`) still allocate but the cost is -/// trivial against the per-frame update cadence. -fn format_pile(p: &PileType) -> String { - match p { - PileType::Stock => "stock".to_string(), - PileType::Waste => "waste".to_string(), - PileType::Foundation(i) => format!("foundation {}", i + 1), - PileType::Tableau(i) => format!("tableau {}", i + 1), - } -} - -/// Pure helper — formats a [`ReplayMove`] as the body of a -/// move-log row. `StockClick` reads as `"stock cycle"`; `Move` -/// reads as `"{from} → {to}"` using [`format_pile`] for both -/// endpoints. The `count` field is omitted from the row body — -/// at row scale it adds visual noise without meaningful -/// information for the typical 1-card moves. -fn format_move_body(m: &ReplayMove) -> String { - match m { - ReplayMove::StockClick => "stock cycle".to_string(), - ReplayMove::Move { from, to, .. } => { - format!("{} \u{2192} {}", format_pile(from), format_pile(to)) - } - } -} - -/// Pure helper — formats the move-log panel's header text. Reads -/// `▌ MOVE LOG · N/M` while playing, where `N` is the count of -/// moves applied so far and `M` is the total in the replay. The -/// cursor-block prefix (`▌`) matches the splash and replay-banner -/// motifs. Empty in `Inactive` (no replay attached); reads -/// `▌ MOVE LOG · COMPLETE` in `Completed`. -fn format_move_log_header(state: &ReplayPlaybackState) -> String { - match state { - ReplayPlaybackState::Playing { replay, cursor, .. } => { - format!( - "\u{258C} MOVE LOG \u{00B7} {}/{}", - cursor, - replay.moves.len() - ) - } - ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(), - ReplayPlaybackState::Inactive => String::new(), - } -} - -/// Pure helper — formats the kth-most-recently-applied move's row -/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`, -/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above -/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`), -/// and so on. -/// -/// Returns the empty string in any of these cases: -/// - State isn't `Playing` (no replay attached). -/// - `k == 0` (no kth-most-recent for k=0; the active is k=1). -/// - `k > cursor` (not enough history — e.g. cursor=2 has rows -/// for k=1 and k=2 only, k=3 returns empty). -/// - The move list is shorter than expected (defensive guard). -fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String { - let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { - return String::new(); - }; - if k == 0 || k > *cursor { - return String::new(); - } - let zero_idx = *cursor - k; - let Some(m) = replay.moves.get(zero_idx) else { - return String::new(); - }; - let display_idx = *cursor - k + 1; - format!("{} \u{2502} {}", display_idx, format_move_body(m)) -} - -/// Pure helper — formats the kth-NEXT move's row text. `k = 1` -/// is the move that will apply next (`replay.moves[cursor]`, -/// displayed as `cursor + 1`); `k = 2` is the move after that, -/// and so on. -/// -/// Returns the empty string in any of these cases: -/// - State isn't `Playing` (no replay attached). -/// - `k == 0` (degenerate; the active is k=1 of *recent*, not -/// *next*). -/// - `cursor + k - 1 >= moves.len()` (not enough remaining -/// replay — late in the move list, the trailing next rows -/// stay empty). -fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String { - let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { - return String::new(); - }; - if k == 0 { - return String::new(); - } - let zero_idx = *cursor + k - 1; - let Some(m) = replay.moves.get(zero_idx) else { - return String::new(); - }; - let display_idx = *cursor + k; - format!("{} \u{2502} {}", display_idx, format_move_body(m)) -} - -/// Pure helper — formats the active-row text for the move-log -/// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends -/// a `▶` focus marker so the active row reads visually distinct -/// from prev rows even before the highlight background lands. -/// Returns empty when there's no row to render (cursor=0 or -/// non-`Playing` state) — never `"▶ "` alone, which would paint -/// a stray prefix. -fn format_active_move_row(state: &ReplayPlaybackState) -> String { - let body = format_kth_recent_row(state, 1); - if body.is_empty() { - return String::new(); - } - format!("\u{25B6} {body}") // ▶ -} - -// --------------------------------------------------------------------------- -// Mini-tableau format helpers and update system -// --------------------------------------------------------------------------- - -/// Pure helper — short rank symbol. Single character for all ranks -/// except Ten which uses "T" (keeps every card a consistent 2-char -/// wide render: rank-char + suit-glyph). Players familiar with -/// solitaire shorthand read "T" instantly; the suit glyph immediately -/// follows and disambiguates from an ambiguous "T". -fn format_rank_short(rank: Rank) -> &'static str { - match rank { - Rank::Ace => "A", - Rank::Two => "2", - Rank::Three => "3", - Rank::Four => "4", - Rank::Five => "5", - Rank::Six => "6", - Rank::Seven => "7", - Rank::Eight => "8", - Rank::Nine => "9", - Rank::Ten => "T", - Rank::Jack => "J", - Rank::Queen => "Q", - Rank::King => "K", - } -} - -/// Pure helper — Unicode suit glyph from FiraMono's covered range -/// (U+2660–U+2666). These four code points are confirmed present in -/// the bundled FiraMono on Android (verified on Pixel 7 / API 34). -fn format_suit_glyph(suit: Suit) -> &'static str { - match suit { - Suit::Spades => "\u{2660}", // ♠ - Suit::Hearts => "\u{2665}", // ♥ - Suit::Diamonds => "\u{2666}", // ♦ - Suit::Clubs => "\u{2663}", // ♣ - } -} - -/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a -/// known card, or `"--"` for an absent top card (empty pile). -fn format_card_short(card: Option<&Card>) -> String { - match card { - Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)), - None => "--".to_string(), - } -} - -/// Pure helper — one-line summary of the four foundation tops. -/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot. -/// Foundation slots are displayed in their natural 0-3 order -/// (matching the visual left-to-right order on screen). -fn format_foundations_row(game: &GameState) -> String { - let slots: [String; 4] = std::array::from_fn(|i| { - let top = game - .piles - .get(&PileType::Foundation(i as u8)) - .and_then(|p| p.cards.last()); - format_card_short(top) - }); - format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3]) -} - -/// Pure helper — one-line stock / waste summary. -/// Renders as `STK:N WST:X♠` where N is the stock card count and -/// X♠ is the top waste card (or `--` when the waste pile is empty). -fn format_stock_waste_row(game: &GameState) -> String { - let stock_count = game - .piles - .get(&PileType::Stock) - .map(|p| p.cards.len()) - .unwrap_or(0); - let waste_top = game - .piles - .get(&PileType::Waste) - .and_then(|p| p.cards.last()); - format!("STK:{} WST:{}", stock_count, format_card_short(waste_top)) -} - -/// Repaints the foundations row whenever [`GameStateResource`] changes. -/// Split into its own system (rather than combined with the stock/waste -/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text` -/// queries in one system are always ambiguous regardless of marker -/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`. -fn update_mini_tableau_foundations( - game: Option>, - mut q: Query<&mut Text, With>, -) { - let Some(game) = game else { return }; - if !game.is_changed() { - return; - } - let text = format_foundations_row(&game.0); - for mut t in &mut q { - **t = text.clone(); - } -} - -/// Repaints the stock/waste row whenever [`GameStateResource`] changes. -/// Sibling of [`update_mini_tableau_foundations`] — same change-detection -/// guard, separate system to avoid the B0001 query conflict. -fn update_mini_tableau_stock_waste( - game: Option>, - mut q: Query<&mut Text, With>, -) { - let Some(game) = game else { return }; - if !game.is_changed() { - return; - } - let text = format_stock_waste_row(&game.0); - for mut t in &mut q { - **t = text.clone(); - } -} - -// --------------------------------------------------------------------------- -// Playback-control button handlers -// --------------------------------------------------------------------------- - -/// Pure helper — returns the label the Pause / Resume button should -/// carry for the given state. "Pause" while running, "Resume" while -/// paused, empty otherwise (the button is despawned with the rest of -/// the overlay tree on transitions to `Inactive` / `Completed`, so -/// the empty branch only fires for one frame around state changes). -fn pause_button_label(state: &ReplayPlaybackState) -> &'static str { - match state { - ReplayPlaybackState::Playing { paused: true, .. } => "Resume", - ReplayPlaybackState::Playing { paused: false, .. } => "Pause", - ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => "", - } -} - -/// Watches the Stop button for `Interaction::Pressed` transitions. On a -/// click, calls [`stop_replay_playback`] which resets the state to -/// `Inactive`; the next frame's `react_to_state_change` then despawns -/// the overlay. -fn handle_stop_button( - mut commands: Commands, - mut state: ResMut, - buttons: Query<&Interaction, (With, Changed)>, -) { - if !buttons.iter().any(|i| *i == Interaction::Pressed) { - return; - } - stop_replay_playback(&mut commands, &mut state); -} - -/// Watches the Pause / Resume button for `Interaction::Pressed` -/// transitions. On a click, toggles the `paused` flag via -/// [`toggle_pause_replay_playback`]. The label repaint happens in -/// [`update_pause_button_label`] on the same frame the state mutation -/// flushes. -fn handle_pause_button( - mut state: ResMut, - buttons: Query<&Interaction, (With, Changed)>, -) { - if !buttons.iter().any(|i| *i == Interaction::Pressed) { - return; - } - toggle_pause_replay_playback(&mut state); -} - -/// Watches the Step button for `Interaction::Pressed` transitions. On -/// a click, advances exactly one move via [`step_replay_playback`]. -/// No-op while playback is unpaused (would race the tick loop) — the -/// guard lives inside `step_replay_playback`. -fn handle_step_button( - mut state: ResMut, - mut moves_writer: MessageWriter, - mut draws_writer: MessageWriter, - buttons: Query<&Interaction, (With, Changed)>, -) { - if !buttons.iter().any(|i| *i == Interaction::Pressed) { - return; - } - step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer); -} - -/// Repaints the Pause / Resume button's label whenever -/// [`ReplayPlaybackState`] changes. Walks from the marked button -/// entity to its single child [`Text`] so the spawn path doesn't need -/// a second marker on the inner node. -fn update_pause_button_label( - state: Res, - buttons: Query<&Children, With>, - mut texts: Query<&mut Text>, -) { - if !state.is_changed() { - return; - } - let label = pause_button_label(&state); - if label.is_empty() { - // Overlay is mid-teardown; the button entity will despawn - // this frame anyway. Skip the repaint to avoid touching a - // doomed entity. - return; - } - for children in &buttons { - for child in children.iter() { - if let Ok(mut text) = texts.get_mut(child) { - text.0 = label.to_string(); - break; - } - } - } -} - -/// Watches `Space` for the keyboard pause / resume accelerator. -/// UI-first contract from CLAUDE.md §3.3 is satisfied by the on- -/// screen Pause / Resume button; this is the optional accelerator. -/// No-op when the playback isn't `Playing` (e.g. while a modal is -/// open and the player is using `Space` for something else). -fn handle_pause_keyboard( - keys: Option>>, - mut state: ResMut, -) { - let Some(keys) = keys else { return }; - if !keys.just_pressed(KeyCode::Space) { - return; - } - toggle_pause_replay_playback(&mut state); -} - -/// Watches the arrow keys for the paused step / scrub -/// accelerators. UI-first contract from CLAUDE.md §3.3 is -/// satisfied by the on-screen Step button (forward only); these -/// are the optional accelerators that also surface a backwards -/// step plus continuous scrub. -/// -/// Both keys are paused-only — the underlying step helpers -/// hard-gate via destructure on `paused: true`. Pressing → during -/// running playback or ← at cursor 0 are silent no-ops; the -/// player learns "pause first, then arrow." -/// -/// **Single press fires once immediately** -/// (`just_pressed`). **Holding** the key triggers continuous -/// scrub at [`SCRUB_REPEAT_INTERVAL_SECS`] cadence (10 steps/sec -/// at 100 ms): the per-key accumulator on -/// [`ReplayScrubKeyHold`] absorbs `time.delta_secs()` each frame -/// the key is held, fires + resets when the threshold is hit, and -/// resets to 0 on key release so the next fresh press fires -/// immediately. This matches the mockup's `[← →] scrub` -/// terminology while keeping single-press = single-step semantics. -#[allow(clippy::too_many_arguments)] -fn handle_arrow_keyboard( - keys: Option>>, - time: Res