refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
Build and Deploy / build-and-push (push) Failing after 1m24s
- 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 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -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",
|
||||
|
||||
+326
-1031
File diff suppressed because it is too large
Load Diff
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
+12
-37
@@ -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<Card>,
|
||||
}
|
||||
|
||||
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<Suit> {
|
||||
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,
|
||||
|
||||
+213
-124
@@ -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<SolverMove>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DfsFrame {
|
||||
state: GameState,
|
||||
moves: Vec<SolverMove>,
|
||||
next_index: usize,
|
||||
first_move: Option<SolverMove>,
|
||||
}
|
||||
|
||||
/// 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<Klondike>` 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<klondike::KlondikeConfig> {
|
||||
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<Klondike>) -> 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<SolverMove> {
|
||||
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<Vec<u32>> = 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<SolverMove> {
|
||||
let mut out: Vec<SolverMove> = 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<GameState> {
|
||||
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<u32> {
|
||||
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<u32>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<GameStateResource>().0 = nearly_won_state();
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
g.is_auto_completable = true;
|
||||
app.world_mut().resource_mut::<GameStateResource>().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::<GameStateResource>().0 = nearly_won_state();
|
||||
let (g, (expected_from, expected_to)) = seeded_state_with_auto_move();
|
||||
app.world_mut().resource_mut::<GameStateResource>().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::<GameStateResource>().0 = gs;
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
|
||||
+133
-118
@@ -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<Card> {
|
||||
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<F: bevy::ecs::query::QueryFilter>(
|
||||
layout: &Layout,
|
||||
font: Handle<Font>,
|
||||
) {
|
||||
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<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
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<u32> = waste_pile.iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
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<u32> = waste_pile.iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
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<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
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<f32> = 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<f32> = 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::<GameStateResource>();
|
||||
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::<GameStateResource>();
|
||||
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<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = 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<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
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<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
|
||||
@@ -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<PileType> = Vec::new();
|
||||
let mut valid: Vec<KlondikePile> = 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<PileType> = overlays
|
||||
let already_overlaid: Vec<KlondikePile> = 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<solitaire_core::card::Card> {
|
||||
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::<GameStateResource>();
|
||||
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::<DragState>();
|
||||
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<PileType> = app
|
||||
let overlays: Vec<KlondikePile> = 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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<u32> = pile.cards.iter().map(|c| c.id).collect();
|
||||
let dest_cards = pile_cards(&game.0, dest_pile);
|
||||
let dest_card_ids: Vec<u32> = 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<u32> = 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<solitaire_core::card::Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
||||
match slot {
|
||||
0 => Some(Foundation::Foundation1),
|
||||
1 => Some(Foundation::Foundation2),
|
||||
2 => Some(Foundation::Foundation3),
|
||||
3 => Some(Foundation::Foundation4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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::<GameStateResource>()
|
||||
.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::<Messages<MoveRejectedEvent>>()
|
||||
.write(MoveRejectedEvent {
|
||||
from: PileType::Stock,
|
||||
from: KlondikePile::Stock,
|
||||
to: dest_pile,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
+258
-191
@@ -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<u32> = {
|
||||
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<solitaire_core::card::Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
fn foundation_slot(foundation: klondike::Foundation) -> Option<u8> {
|
||||
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::<GameStateResource>().0.piles[&PileType::Stock]
|
||||
.cards
|
||||
let stock_before = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.stock_cards()
|
||||
.len();
|
||||
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
let stock_after = app.world().resource::<GameStateResource>().0.piles[&PileType::Stock]
|
||||
.cards
|
||||
let stock_after = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.stock_cards()
|
||||
.len();
|
||||
let waste_after = app.world().resource::<GameStateResource>().0.piles[&PileType::Waste]
|
||||
.cards
|
||||
let waste_after = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.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::<GameStateResource>().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<u32> = app.world().resource::<GameStateResource>().0.piles
|
||||
[&PileType::Tableau(0)]
|
||||
.cards
|
||||
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
|
||||
Tableau::Tableau1,
|
||||
))
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
@@ -1382,15 +1402,43 @@ mod tests {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.piles
|
||||
[&PileType::Tableau(0)]
|
||||
.cards
|
||||
let after: Vec<u32> = app.world().resource::<GameStateResource>().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::<GameStateResource>().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::<GameStateResource>().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::<GameStateResource>().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::<GameStateResource>();
|
||||
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::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
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::<GameStateResource>();
|
||||
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::<RecordingReplay>();
|
||||
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::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
|
||||
expected.piles[&PileType::Tableau(i)].cards,
|
||||
"tableau column {i} must match the unfiltered seed",
|
||||
app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(tableau)),
|
||||
expected.pile(KlondikePile::Tableau(tableau)),
|
||||
"tableau column {tableau:?} must match the unfiltered seed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PileType, Vec2>,
|
||||
pub pile_positions: HashMap<KlondikePile, Vec2>,
|
||||
/// 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<PileType, Vec2> = HashMap::with_capacity(13);
|
||||
let mut pile_positions: HashMap<KlondikePile, Vec2> = 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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<usize> {
|
||||
/// 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<PileType> {
|
||||
) -> Vec<KlondikePile> {
|
||||
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<Card> {
|
||||
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<PileType>) -> Vec<(PileType, Vec2)> {
|
||||
fn build_radial_destinations(centre: Vec2, dests: Vec<KlondikePile>) -> 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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
use super::ReplayPlaybackState;
|
||||
use chrono::Datelike;
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
use solitaire_data::ReplayMove;
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn format_game_caption(state: &ReplayPlaybackState) -> Option<String> {
|
||||
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.
|
||||
pub(crate) 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 [`KlondikePile`] 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.
|
||||
pub(crate) fn format_pile(p: &KlondikePile) -> String {
|
||||
match p {
|
||||
KlondikePile::Stock => "waste".to_string(),
|
||||
KlondikePile::Foundation(foundation) => {
|
||||
format!("foundation {}", foundation_number(*foundation))
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => format!("tableau {}", tableau_number(*tableau)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
|
||||
KlondikePile::try_from(*p)
|
||||
.map(|pile| format_pile(&pile))
|
||||
.unwrap_or_else(|_| "unknown pile".to_string())
|
||||
}
|
||||
|
||||
fn foundation_number(foundation: Foundation) -> u8 {
|
||||
match foundation {
|
||||
Foundation::Foundation1 => 1,
|
||||
Foundation::Foundation2 => 2,
|
||||
Foundation::Foundation3 => 3,
|
||||
Foundation::Foundation4 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
|
||||
match m {
|
||||
ReplayMove::StockClick => "stock cycle".to_string(),
|
||||
ReplayMove::Move { from, to, .. } => {
|
||||
format!("{} \u{2192} {}", format_saved_pile(from), format_saved_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`.
|
||||
pub(crate) 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).
|
||||
pub(crate) 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).
|
||||
pub(crate) 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.
|
||||
pub(crate) 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".
|
||||
pub(crate) 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).
|
||||
pub(crate) 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).
|
||||
pub(crate) 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).
|
||||
pub(crate) fn format_foundations_row(game: &GameState) -> String {
|
||||
let slots = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
]
|
||||
.map(|foundation| {
|
||||
let cards = game.pile(KlondikePile::Foundation(foundation));
|
||||
format_card_short(cards.last())
|
||||
});
|
||||
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).
|
||||
pub(crate) fn format_stock_waste_row(game: &GameState) -> String {
|
||||
let stock_cards = game.stock_cards();
|
||||
let waste_cards = game.waste_cards();
|
||||
let stock_count = stock_cards.len();
|
||||
let waste_top = waste_cards.last();
|
||||
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use super::format::{
|
||||
format_active_move_row, format_foundations_row, format_kth_next_row,
|
||||
format_kth_recent_row, format_move_log_header, format_progress, format_stock_waste_row,
|
||||
};
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use klondike::KlondikePile;
|
||||
use solitaire_data::ReplayMove;
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn update_banner_label(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
|
||||
) {
|
||||
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.
|
||||
pub(crate) fn update_progress_text(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
|
||||
) {
|
||||
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.
|
||||
pub(crate) fn update_floating_progress_chip(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut chips: Query<
|
||||
(&mut Transform, &mut Visibility, &mut Text2d),
|
||||
With<ReplayFloatingProgressChip>,
|
||||
>,
|
||||
) {
|
||||
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),
|
||||
ReplayMove::StockClick => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some(world_pos) = dest_pile
|
||||
.as_ref()
|
||||
.and_then(|p| KlondikePile::try_from(*p).ok())
|
||||
.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.
|
||||
pub(crate) fn update_move_log_header(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayMoveLogHeader>>,
|
||||
) {
|
||||
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.
|
||||
pub(crate) fn update_move_log_active_row(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayMoveLogActiveRow>>,
|
||||
) {
|
||||
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.
|
||||
pub(crate) fn update_move_log_prev_rows(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
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.
|
||||
pub(crate) fn update_move_log_next_rows(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
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.
|
||||
pub(crate) fn update_scrub_fill(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Node, With<ReplayOverlayScrubFill>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let pct = scrub_pct(&state);
|
||||
for mut node in &mut q {
|
||||
node.width = Val::Percent(pct);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<…>>`.
|
||||
pub(crate) fn update_mini_tableau_foundations(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
|
||||
) {
|
||||
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.
|
||||
pub(crate) fn update_mini_tableau_stock_waste(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! flag is threaded through, no every-callsite gate is added.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use klondike::KlondikePile;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||
@@ -267,9 +268,17 @@ pub fn step_replay_playback(
|
||||
}
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
let (Ok(from), Ok(to)) = (
|
||||
KlondikePile::try_from(*from),
|
||||
KlondikePile::try_from(*to),
|
||||
) else {
|
||||
warn!("skipping replay move with invalid pile encoding at cursor {}", *cursor);
|
||||
*cursor += 1;
|
||||
return false;
|
||||
};
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
from,
|
||||
to,
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
@@ -370,11 +379,21 @@ fn tick_replay_playback(
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
if let (Ok(from), Ok(to)) = (
|
||||
KlondikePile::try_from(*from),
|
||||
KlondikePile::try_from(*to),
|
||||
) {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from,
|
||||
to,
|
||||
count: *count,
|
||||
});
|
||||
} else {
|
||||
warn!(
|
||||
"skipping replay move with invalid pile encoding at cursor {}",
|
||||
*cursor
|
||||
);
|
||||
}
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
@@ -536,8 +555,9 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use klondike::{KlondikePile, Tableau};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
@@ -586,8 +606,8 @@ 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,
|
||||
@@ -739,8 +759,8 @@ mod tests {
|
||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
||||
);
|
||||
let m = &captured_moves.0[0];
|
||||
assert!(matches!(m.from, PileType::Waste));
|
||||
assert!(matches!(m.to, PileType::Tableau(3)));
|
||||
assert!(matches!(m.from, KlondikePile::Stock));
|
||||
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
|
||||
assert_eq!(m.count, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use klondike::KlondikePile;
|
||||
|
||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@@ -29,7 +29,7 @@ pub struct DragState {
|
||||
/// IDs of the cards being dragged (bottom-to-top stacking order).
|
||||
pub cards: Vec<u32>,
|
||||
/// Pile the drag originated from.
|
||||
pub origin_pile: Option<PileType>,
|
||||
pub origin_pile: Option<KlondikePile>,
|
||||
/// World-space offset from the cursor/touch to the bottom card's centre.
|
||||
pub cursor_offset: Vec2,
|
||||
/// Z coordinate used for the dragged cards.
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
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::CardEntity;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
@@ -59,7 +60,7 @@ use crate::ui_theme::{ACCENT_PRIMARY, STATE_SUCCESS, STATE_WARNING};
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SelectionState {
|
||||
/// The pile whose top face-up card is currently selected, or `None`.
|
||||
pub selected_pile: Option<PileType>,
|
||||
pub selected_pile: Option<KlondikePile>,
|
||||
}
|
||||
|
||||
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
|
||||
@@ -86,7 +87,7 @@ pub enum KeyboardDragState {
|
||||
/// `legal_destinations` and `Enter` fires the move.
|
||||
Lifted {
|
||||
/// Pile the cards were lifted from.
|
||||
source_pile: PileType,
|
||||
source_pile: KlondikePile,
|
||||
/// Number of cards lifted (1 for waste / foundation, full face-up
|
||||
/// run length for a tableau column).
|
||||
count: usize,
|
||||
@@ -97,7 +98,7 @@ pub enum KeyboardDragState {
|
||||
/// placed on. Always at least one entry while in this variant —
|
||||
/// if no legal destinations exist the state machine refuses to
|
||||
/// enter `Lifted` in the first place.
|
||||
legal_destinations: Vec<PileType>,
|
||||
legal_destinations: Vec<KlondikePile>,
|
||||
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
|
||||
destination_index: usize,
|
||||
},
|
||||
@@ -109,7 +110,7 @@ impl KeyboardDragState {
|
||||
///
|
||||
/// [`Lifted`]: KeyboardDragState::Lifted
|
||||
/// [`Idle`]: KeyboardDragState::Idle
|
||||
pub fn focused_destination(&self) -> Option<&PileType> {
|
||||
pub fn focused_destination(&self) -> Option<&KlondikePile> {
|
||||
match self {
|
||||
Self::Idle => None,
|
||||
Self::Lifted {
|
||||
@@ -172,13 +173,26 @@ impl Plugin for SelectionPlugin {
|
||||
/// The ordered list of piles that are considered for keyboard cycling.
|
||||
///
|
||||
/// Order: Waste → Foundation slots 0–3 → Tableau 0–6.
|
||||
fn cycled_piles() -> Vec<PileType> {
|
||||
let mut piles = vec![PileType::Waste];
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
fn cycled_piles() -> Vec<KlondikePile> {
|
||||
let mut piles = vec![KlondikePile::Stock];
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
piles.push(KlondikePile::Foundation(foundation));
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
piles.push(PileType::Tableau(i));
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
piles.push(KlondikePile::Tableau(tableau));
|
||||
}
|
||||
piles
|
||||
}
|
||||
@@ -188,7 +202,7 @@ fn cycled_piles() -> Vec<PileType> {
|
||||
///
|
||||
/// If `current` is `None` the first available pile is returned.
|
||||
/// If `available` is empty, `None` is returned.
|
||||
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
|
||||
pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile>) -> Option<KlondikePile> {
|
||||
if available.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -209,7 +223,7 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
|
||||
for offset in 0..n {
|
||||
let candidate = &order[(start + offset) % n];
|
||||
if available.contains(candidate) {
|
||||
return Some(candidate.clone());
|
||||
return Some(*candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -221,14 +235,14 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
|
||||
///
|
||||
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||
/// `false`.
|
||||
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool {
|
||||
fn did_wrap(available: &[KlondikePile], current: Option<&KlondikePile>, next: Option<&KlondikePile>) -> bool {
|
||||
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||
return false;
|
||||
};
|
||||
let order = cycled_piles();
|
||||
// Position of each pile within the *available* subset, ordered by the
|
||||
// global cycle order.
|
||||
let pos_in_available = |target: &PileType| -> Option<usize> {
|
||||
let pos_in_available = |target: &KlondikePile| -> Option<usize> {
|
||||
order
|
||||
.iter()
|
||||
.filter(|p| available.contains(p))
|
||||
@@ -325,7 +339,7 @@ fn handle_selection_keys(
|
||||
if keys.just_pressed(KeyCode::Enter) {
|
||||
if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: source_pile.clone(),
|
||||
from: *source_pile,
|
||||
to: dest,
|
||||
count: *count,
|
||||
});
|
||||
@@ -356,28 +370,24 @@ fn handle_selection_keys(
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Build the list of piles that currently have a face-up draggable top card.
|
||||
let available: Vec<PileType> = {
|
||||
let available: Vec<KlondikePile> = {
|
||||
let all = [
|
||||
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),
|
||||
];
|
||||
all.into_iter()
|
||||
.filter(|p| {
|
||||
game.0
|
||||
.piles
|
||||
.get(p)
|
||||
.and_then(|pile| pile.cards.last())
|
||||
.is_some_and(|c| c.face_up)
|
||||
pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -406,18 +416,16 @@ fn handle_selection_keys(
|
||||
// tableau stack target. Preserved so the muscle memory built around
|
||||
// `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
|
||||
if keys.just_pressed(KeyCode::Space)
|
||||
&& let Some(ref pile) = selection.selected_pile.clone()
|
||||
&& let Some(card) = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
&& let Some(ref pile) = selection.selected_pile
|
||||
{
|
||||
let selected_cards = pile_cards(&game.0, pile);
|
||||
let Some(card) = selected_cards.last().filter(|c| c.face_up) else {
|
||||
return;
|
||||
};
|
||||
// Priority 1: foundation move (single card).
|
||||
if let Some(dest) = try_foundation_dest(card, &game.0) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: *pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
@@ -425,17 +433,16 @@ fn handle_selection_keys(
|
||||
return;
|
||||
}
|
||||
// Priority 2: tableau stack move.
|
||||
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
|
||||
let bottom_card = game.0.piles.get(pile).and_then(|p| {
|
||||
let start = p.cards.len().saturating_sub(run_len);
|
||||
p.cards.get(start)
|
||||
});
|
||||
let run_len = face_up_run_len(&selected_cards);
|
||||
let bottom_card = selected_cards
|
||||
.get(selected_cards.len().saturating_sub(run_len))
|
||||
.cloned();
|
||||
if let Some(bottom) = bottom_card
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
||||
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: *pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
@@ -445,7 +452,7 @@ fn handle_selection_keys(
|
||||
// Fallback for non-tableau sources.
|
||||
if let Some(dest) = best_destination(card, &game.0) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: *pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
@@ -456,25 +463,23 @@ fn handle_selection_keys(
|
||||
|
||||
// Enter — lift the focused pile into destination-pick mode.
|
||||
if keys.just_pressed(KeyCode::Enter)
|
||||
&& let Some(ref source) = selection.selected_pile.clone()
|
||||
&& let Some(ref source) = selection.selected_pile
|
||||
{
|
||||
let Some(pile_cards) = game.0.piles.get(source) else {
|
||||
let source_cards = pile_cards(&game.0, source);
|
||||
if source_cards.is_empty() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
// Determine the lift range: tableau lifts the full face-up run, all
|
||||
// other sources lift only the top card.
|
||||
let run_len = face_up_run_len(pile_cards.cards.as_slice());
|
||||
let count = if matches!(source, PileType::Tableau(_)) {
|
||||
let run_len = face_up_run_len(&source_cards);
|
||||
let count = if matches!(source, KlondikePile::Tableau(_)) {
|
||||
run_len.max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if pile_cards.cards.is_empty() {
|
||||
return;
|
||||
}
|
||||
let start = pile_cards.cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
|
||||
let Some(bottom) = pile_cards.cards.get(start) else {
|
||||
let start = source_cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
|
||||
let Some(bottom) = source_cards.get(start) else {
|
||||
return;
|
||||
};
|
||||
let legal = legal_destinations_for(bottom, source, &game.0, count);
|
||||
@@ -486,7 +491,7 @@ fn handle_selection_keys(
|
||||
// Populate `DragState` with the keyboard sentinel so the existing
|
||||
// mouse-drag systems treat this as "not their drag".
|
||||
drag.cards = lifted_cards.clone();
|
||||
drag.origin_pile = Some(source.clone());
|
||||
drag.origin_pile = Some(*source);
|
||||
drag.cursor_offset = Vec2::ZERO;
|
||||
drag.origin_z = 1.0;
|
||||
drag.press_pos = Vec2::ZERO;
|
||||
@@ -494,7 +499,7 @@ fn handle_selection_keys(
|
||||
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
|
||||
|
||||
*kbd_drag = KeyboardDragState::Lifted {
|
||||
source_pile: source.clone(),
|
||||
source_pile: *source,
|
||||
count,
|
||||
cards: lifted_cards,
|
||||
legal_destinations: legal,
|
||||
@@ -520,21 +525,34 @@ fn handle_selection_keys(
|
||||
/// press the right-arrow key once or twice.
|
||||
pub(crate) fn legal_destinations_for(
|
||||
_bottom: &solitaire_core::card::Card,
|
||||
source: &PileType,
|
||||
source: &KlondikePile,
|
||||
game: &GameState,
|
||||
stack_count: usize,
|
||||
) -> Vec<PileType> {
|
||||
) -> Vec<KlondikePile> {
|
||||
let mut out = Vec::new();
|
||||
if stack_count == 1 {
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
if game.can_move_cards(source, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
let dest = KlondikePile::Tableau(tableau);
|
||||
if game.can_move_cards(source, &dest, stack_count) {
|
||||
out.push(dest);
|
||||
}
|
||||
@@ -572,10 +590,15 @@ fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
) -> Option<KlondikePile> {
|
||||
let source = game.pile_containing_card(card.id)?;
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
if game.can_move_cards(&source, &dest, 1) {
|
||||
return Some(dest);
|
||||
}
|
||||
@@ -656,9 +679,9 @@ fn update_selection_highlight(
|
||||
// Resolve the source pile from KeyboardDragState (when lifted) or
|
||||
// SelectionState (otherwise). Lifted takes precedence so the gold
|
||||
// outline follows the actual lifted cards.
|
||||
let source_pile: Option<PileType> = match &*kbd_drag {
|
||||
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
|
||||
KeyboardDragState::Idle => selection.selected_pile.clone(),
|
||||
let source_pile: Option<KlondikePile> = match &*kbd_drag {
|
||||
KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile),
|
||||
KeyboardDragState::Idle => selection.selected_pile,
|
||||
};
|
||||
|
||||
if let Some(ref pile) = source_pile
|
||||
@@ -694,14 +717,18 @@ fn update_selection_highlight(
|
||||
|
||||
/// Returns the top face-up card on `pile`, or `None` if the pile is
|
||||
/// empty or its top card is face-down.
|
||||
fn top_face_up_card<'a>(
|
||||
pile: &PileType,
|
||||
game: &'a GameState,
|
||||
) -> Option<&'a solitaire_core::card::Card> {
|
||||
game.piles
|
||||
.get(pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
fn top_face_up_card(
|
||||
pile: &KlondikePile,
|
||||
game: &GameState,
|
||||
) -> Option<Card> {
|
||||
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
|
||||
@@ -740,15 +767,15 @@ fn spawn_highlight_on_card(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn piles_from(names: &[&str]) -> Vec<PileType> {
|
||||
fn piles_from(names: &[&str]) -> Vec<KlondikePile> {
|
||||
names
|
||||
.iter()
|
||||
.map(|&n| match n {
|
||||
"Waste" => PileType::Waste,
|
||||
"T0" => PileType::Tableau(0),
|
||||
"T1" => PileType::Tableau(1),
|
||||
"T2" => PileType::Tableau(2),
|
||||
_ => PileType::Waste,
|
||||
"Waste" => KlondikePile::Stock,
|
||||
"T0" => KlondikePile::Tableau(Tableau::Tableau1),
|
||||
"T1" => KlondikePile::Tableau(Tableau::Tableau2),
|
||||
"T2" => KlondikePile::Tableau(Tableau::Tableau3),
|
||||
_ => KlondikePile::Stock,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -762,23 +789,23 @@ mod tests {
|
||||
// With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, None);
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
assert_eq!(result, Some(KlondikePile::Stock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_from_waste() {
|
||||
// Starting from Waste → Tableau(0).
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Tableau(0)));
|
||||
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
|
||||
assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_wraps() {
|
||||
// Starting from Tableau(1) → Waste (wraps back to start).
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Tableau(1)));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
let result = cycle_next_pile(&available, Some(&KlondikePile::Tableau(Tableau::Tableau2)));
|
||||
assert_eq!(result, Some(KlondikePile::Stock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -803,7 +830,7 @@ mod tests {
|
||||
|
||||
// Press 1: no current selection → first pile, no wrap.
|
||||
let sel1 = cycle_next_pile(&available, None);
|
||||
assert_eq!(sel1, Some(PileType::Waste));
|
||||
assert_eq!(sel1, Some(KlondikePile::Stock));
|
||||
assert!(
|
||||
!did_wrap(&available, None, sel1.as_ref()),
|
||||
"first Tab should not wrap"
|
||||
@@ -811,7 +838,7 @@ mod tests {
|
||||
|
||||
// Press 2: Waste → Tableau(0), no wrap.
|
||||
let sel2 = cycle_next_pile(&available, sel1.as_ref());
|
||||
assert_eq!(sel2, Some(PileType::Tableau(0)));
|
||||
assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
assert!(
|
||||
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
|
||||
"second Tab should not wrap"
|
||||
@@ -819,7 +846,7 @@ mod tests {
|
||||
|
||||
// Press 3: Tableau(0) → Tableau(1), still no wrap.
|
||||
let sel3 = cycle_next_pile(&available, sel2.as_ref());
|
||||
assert_eq!(sel3, Some(PileType::Tableau(1)));
|
||||
assert_eq!(sel3, Some(KlondikePile::Tableau(Tableau::Tableau2)));
|
||||
assert!(
|
||||
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
|
||||
"third Tab (T0→T1) should not wrap"
|
||||
@@ -827,7 +854,7 @@ mod tests {
|
||||
|
||||
// Press 4: Tableau(1) → Waste, this IS the wrap.
|
||||
let sel4 = cycle_next_pile(&available, sel3.as_ref());
|
||||
assert_eq!(sel4, Some(PileType::Waste));
|
||||
assert_eq!(sel4, Some(KlondikePile::Stock));
|
||||
assert!(
|
||||
did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
|
||||
"fourth Tab should wrap back to Waste"
|
||||
@@ -836,9 +863,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_single_element_wraps_to_itself() {
|
||||
let available = vec![PileType::Waste];
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
let available = vec![KlondikePile::Stock];
|
||||
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
|
||||
assert_eq!(result, Some(KlondikePile::Stock));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -986,46 +1013,47 @@ mod tests {
|
||||
fn deterministic_state() -> GameState {
|
||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||
// Clear stock, waste, all tableaus.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
g.set_test_stock_cards(Vec::new());
|
||||
g.set_test_waste_cards(Vec::new());
|
||||
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());
|
||||
}
|
||||
// Place test cards.
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
}],
|
||||
);
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau2,
|
||||
vec![Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(2))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
}],
|
||||
);
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau3,
|
||||
vec![Card {
|
||||
id: 102,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
});
|
||||
}],
|
||||
);
|
||||
g
|
||||
}
|
||||
|
||||
@@ -1084,7 +1112,7 @@ mod tests {
|
||||
.clone();
|
||||
// The cycle order starts at Waste, but Waste is empty so the next
|
||||
// available pile (Tableau(0)) is selected.
|
||||
assert_eq!(selected, Some(PileType::Tableau(0)));
|
||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
assert_eq!(
|
||||
*app.world().resource::<KeyboardDragState>(),
|
||||
KeyboardDragState::Idle
|
||||
@@ -1104,7 +1132,7 @@ mod tests {
|
||||
// Manually focus Tableau(0) so we don't depend on Tab.
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
@@ -1119,7 +1147,7 @@ mod tests {
|
||||
legal_destinations,
|
||||
destination_index,
|
||||
} => {
|
||||
assert_eq!(source_pile, PileType::Tableau(0));
|
||||
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert!(
|
||||
@@ -1134,7 +1162,7 @@ mod tests {
|
||||
// DragState must mirror the lifted cards and carry the keyboard sentinel.
|
||||
let drag = app.world().resource::<DragState>();
|
||||
assert_eq!(drag.cards, vec![100]);
|
||||
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
|
||||
assert_eq!(drag.origin_pile, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
|
||||
}
|
||||
|
||||
@@ -1151,7 +1179,7 @@ mod tests {
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1171,7 +1199,7 @@ mod tests {
|
||||
|
||||
let events = collect_move_events(&mut app);
|
||||
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
|
||||
assert_eq!(events[0].from, PileType::Tableau(0));
|
||||
assert_eq!(events[0].from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(events[0].to, expected_dest);
|
||||
assert_eq!(events[0].count, 1);
|
||||
|
||||
@@ -1196,7 +1224,7 @@ mod tests {
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
|
||||
@@ -1213,7 +1241,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<SelectionState>().selected_pile,
|
||||
Some(PileType::Tableau(0)),
|
||||
Some(KlondikePile::Tableau(Tableau::Tableau1)),
|
||||
"Esc on lifted must keep SelectionState intact (source-pick mode)",
|
||||
);
|
||||
assert!(
|
||||
@@ -1236,7 +1264,7 @@ mod tests {
|
||||
{
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![100];
|
||||
drag.origin_pile = Some(PileType::Tableau(0));
|
||||
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
drag.committed = true;
|
||||
drag.active_touch_id = None;
|
||||
}
|
||||
@@ -1269,7 +1297,7 @@ mod tests {
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
@@ -1278,7 +1306,7 @@ mod tests {
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<SelectionState>().selected_pile,
|
||||
Some(PileType::Tableau(0)),
|
||||
Some(KlondikePile::Tableau(Tableau::Tableau1)),
|
||||
"first Esc only cancels the lift",
|
||||
);
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::hud_plugin::HudVisibility;
|
||||
@@ -54,7 +54,7 @@ pub struct TableBackground;
|
||||
|
||||
/// Marker component attached to each of the 13 empty-pile placeholders.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PileMarker(pub PileType);
|
||||
pub struct PileMarker(pub KlondikePile);
|
||||
|
||||
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
|
||||
/// as a hint destination. Stores the remaining countdown and the original sprite
|
||||
@@ -265,14 +265,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
let marker_size = layout.card_size;
|
||||
let font_size = layout.card_size.x * 0.28;
|
||||
|
||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||
piles.push(PileType::Stock);
|
||||
piles.push(PileType::Waste);
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
|
||||
piles.push(KlondikePile::Stock);
|
||||
for foundation in foundations() {
|
||||
piles.push(KlondikePile::Foundation(foundation));
|
||||
}
|
||||
for i in 0..7 {
|
||||
piles.push(PileType::Tableau(i));
|
||||
for tableau in tableaus() {
|
||||
piles.push(KlondikePile::Tableau(tableau));
|
||||
}
|
||||
|
||||
for pile in piles {
|
||||
@@ -284,14 +283,14 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||||
PileMarker(pile.clone()),
|
||||
PileMarker(pile),
|
||||
));
|
||||
|
||||
// Tableau markers show "K" (only a King may start an empty column).
|
||||
// Foundation markers show "A" (only an Ace may claim an empty slot).
|
||||
// Neither label carries a suit because any suit may start any slot.
|
||||
match &pile {
|
||||
PileType::Tableau(_) => {
|
||||
KlondikePile::Tableau(_) => {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("K"),
|
||||
@@ -304,7 +303,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
));
|
||||
});
|
||||
}
|
||||
PileType::Foundation(_) => {
|
||||
KlondikePile::Foundation(_) => {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("A"),
|
||||
@@ -480,11 +479,7 @@ fn sync_pile_marker_visibility(
|
||||
return;
|
||||
}
|
||||
for (pile_marker, mut visibility) in markers.iter_mut() {
|
||||
let is_empty = game
|
||||
.0
|
||||
.piles
|
||||
.get(&pile_marker.0)
|
||||
.is_none_or(|pile| pile.cards.is_empty());
|
||||
let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
|
||||
*visibility = if is_empty {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
@@ -493,6 +488,44 @@ fn sync_pile_marker_visibility(
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_cards(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
pile: &KlondikePile,
|
||||
) -> Vec<solitaire_core::card::Card> {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
let stock = game.stock_cards();
|
||||
if stock.is_empty() {
|
||||
game.waste_cards()
|
||||
} else {
|
||||
stock
|
||||
}
|
||||
}
|
||||
_ => 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,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -510,14 +543,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_plugin_spawns_thirteen_pile_markers() {
|
||||
fn table_plugin_spawns_twelve_pile_markers() {
|
||||
let mut app = headless_app();
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&PileMarker>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 13);
|
||||
assert_eq!(count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -540,7 +573,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_pile_marker_has_unique_type() {
|
||||
let mut app = headless_app();
|
||||
let mut types: Vec<PileType> = app
|
||||
let mut types: Vec<KlondikePile> = app
|
||||
.world_mut()
|
||||
.query::<&PileMarker>()
|
||||
.iter(app.world())
|
||||
@@ -548,15 +581,15 @@ mod tests {
|
||||
.collect();
|
||||
types.sort_by_key(|p| format!("{p:?}"));
|
||||
types.dedup();
|
||||
assert_eq!(types.len(), 13);
|
||||
assert_eq!(types.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_markers_hide_when_pile_is_occupied() {
|
||||
// After a fresh deal: the 7 tableau piles + the stock pile are
|
||||
// all occupied; the 4 foundation piles + the waste pile are
|
||||
// empty. The visibility-by-occupancy system must hide the
|
||||
// first 8 markers and keep the last 5 visible. This implements
|
||||
// occupied; the 4 foundation piles are empty. The visibility-by-
|
||||
// occupancy system must hide the first 8 markers and keep the
|
||||
// last 4 visible. This implements
|
||||
// the "remain visible only where a pile is empty" invariant
|
||||
// in the module-level doc comment that was previously
|
||||
// declared but not enforced — pile markers used to always
|
||||
@@ -570,8 +603,8 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
|
||||
let mut hidden_piles: Vec<PileType> = Vec::new();
|
||||
let mut visible_piles: Vec<PileType> = Vec::new();
|
||||
let mut hidden_piles: Vec<KlondikePile> = Vec::new();
|
||||
let mut visible_piles: Vec<KlondikePile> = Vec::new();
|
||||
for (marker, visibility) in q.iter(app.world()) {
|
||||
if matches!(visibility, Visibility::Hidden) {
|
||||
hidden_piles.push(marker.0.clone());
|
||||
@@ -586,19 +619,31 @@ mod tests {
|
||||
8,
|
||||
"stock + 7 tableau piles should hide their markers post-deal",
|
||||
);
|
||||
assert!(hidden_piles.contains(&PileType::Stock));
|
||||
for i in 0..7 {
|
||||
assert!(hidden_piles.contains(&KlondikePile::Stock));
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
assert!(
|
||||
hidden_piles.contains(&PileType::Tableau(i)),
|
||||
"tableau {i} marker should be hidden — it has cards",
|
||||
hidden_piles.contains(&KlondikePile::Tableau(tableau)),
|
||||
"{tableau:?} marker should be hidden — it has cards",
|
||||
);
|
||||
}
|
||||
|
||||
// 5 empty piles: waste + 4 foundations.
|
||||
assert_eq!(visible_piles.len(), 5);
|
||||
assert!(visible_piles.contains(&PileType::Waste));
|
||||
for i in 0..4_u8 {
|
||||
assert!(visible_piles.contains(&PileType::Foundation(i)));
|
||||
// 4 empty piles: foundations only.
|
||||
assert_eq!(visible_piles.len(), 4);
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
use klondike::KlondikePile;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::StateChangedEvent;
|
||||
@@ -50,7 +50,7 @@ use crate::ui_theme::ACCENT_PRIMARY;
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct TouchSelectionState {
|
||||
/// Currently selected source pile and the card ids to move (bottom-to-top).
|
||||
pub selected: Option<(PileType, Vec<u32>)>,
|
||||
pub selected: Option<(KlondikePile, Vec<u32>)>,
|
||||
}
|
||||
|
||||
impl TouchSelectionState {
|
||||
@@ -60,12 +60,12 @@ impl TouchSelectionState {
|
||||
}
|
||||
|
||||
/// Takes the current selection, leaving `selected` as `None`.
|
||||
pub fn take(&mut self) -> Option<(PileType, Vec<u32>)> {
|
||||
pub fn take(&mut self) -> Option<(KlondikePile, Vec<u32>)> {
|
||||
self.selected.take()
|
||||
}
|
||||
|
||||
/// Sets the current selection.
|
||||
pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
|
||||
pub fn set(&mut self, pile: KlondikePile, cards: Vec<u32>) {
|
||||
self.selected = Some((pile, cards));
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ fn spawn_touch_highlight(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use klondike::Tableau;
|
||||
|
||||
#[test]
|
||||
fn selection_state_default_is_idle() {
|
||||
@@ -197,12 +198,12 @@ mod tests {
|
||||
#[test]
|
||||
fn set_and_take_roundtrip() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Tableau(0), vec![1, 2, 3]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]);
|
||||
assert!(state.has_selection());
|
||||
let taken = state.take();
|
||||
assert!(taken.is_some());
|
||||
let (pile, cards) = taken.unwrap();
|
||||
assert_eq!(pile, PileType::Tableau(0));
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(cards, vec![1, 2, 3]);
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
@@ -210,7 +211,7 @@ mod tests {
|
||||
#[test]
|
||||
fn clear_removes_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Waste, vec![42]);
|
||||
state.set(KlondikePile::Stock, vec![42]);
|
||||
state.clear();
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
@@ -225,10 +226,10 @@ mod tests {
|
||||
#[test]
|
||||
fn set_overwrites_previous_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Tableau(0), vec![1]);
|
||||
state.set(PileType::Tableau(3), vec![7, 8]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]);
|
||||
state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]);
|
||||
let (pile, cards) = state.take().unwrap();
|
||||
assert_eq!(pile, PileType::Tableau(3));
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
|
||||
assert_eq!(cards, vec![7, 8]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export class ReplayPlayer {
|
||||
* once the move list is exhausted.
|
||||
*
|
||||
* Returns `null` (not an exception) when the replay is finished.
|
||||
* Throws `"replay_desync"` when the next recorded move is illegal for
|
||||
* the current state, and logs the underlying core error to the JS console.
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
@@ -253,6 +255,9 @@ function __wbg_get_imports() {
|
||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_error_48655ee7e4756f8b: function(arg0) {
|
||||
console.error(arg0);
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
|
||||
Binary file not shown.
@@ -12,6 +12,7 @@ solitaire_core = { path = "../solitaire_core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
+62
-359
@@ -19,11 +19,12 @@
|
||||
//! is the contract.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::error::MoveError;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
||||
@@ -32,8 +33,8 @@ use wasm_bindgen::prelude::*;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReplayMove {
|
||||
Move {
|
||||
from: PileType,
|
||||
to: PileType,
|
||||
from: SavedKlondikePile,
|
||||
to: SavedKlondikePile,
|
||||
count: usize,
|
||||
},
|
||||
StockClick,
|
||||
@@ -142,7 +143,13 @@ impl ReplayPlayer {
|
||||
}
|
||||
let mv = self.moves[self.step_idx].clone();
|
||||
match mv {
|
||||
ReplayMove::Move { from, to, count } => self.game.move_cards(from, to, count)?,
|
||||
ReplayMove::Move { from, to, count } => self.game.move_cards(
|
||||
from.try_into()
|
||||
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
|
||||
to.try_into()
|
||||
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
|
||||
count,
|
||||
)?,
|
||||
ReplayMove::StockClick => self.game.draw()?,
|
||||
}
|
||||
self.step_idx += 1;
|
||||
@@ -150,27 +157,22 @@ impl ReplayPlayer {
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> StateSnapshot {
|
||||
let pile_cards = |t: PileType| -> Vec<CardSnapshot> {
|
||||
self.game
|
||||
.piles
|
||||
.get(&t)
|
||||
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let pile_cards =
|
||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
||||
let foundations: [Vec<CardSnapshot>; 4] = [
|
||||
pile_cards(PileType::Foundation(0)),
|
||||
pile_cards(PileType::Foundation(1)),
|
||||
pile_cards(PileType::Foundation(2)),
|
||||
pile_cards(PileType::Foundation(3)),
|
||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
||||
];
|
||||
let tableaus: [Vec<CardSnapshot>; 7] = [
|
||||
pile_cards(PileType::Tableau(0)),
|
||||
pile_cards(PileType::Tableau(1)),
|
||||
pile_cards(PileType::Tableau(2)),
|
||||
pile_cards(PileType::Tableau(3)),
|
||||
pile_cards(PileType::Tableau(4)),
|
||||
pile_cards(PileType::Tableau(5)),
|
||||
pile_cards(PileType::Tableau(6)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
|
||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
|
||||
];
|
||||
StateSnapshot {
|
||||
step_idx: self.step_idx,
|
||||
@@ -178,8 +180,8 @@ impl ReplayPlayer {
|
||||
score: self.game.score,
|
||||
move_count: self.game.move_count,
|
||||
is_won: self.game.is_won,
|
||||
stock: pile_cards(PileType::Stock),
|
||||
waste: pile_cards(PileType::Waste),
|
||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
||||
foundations,
|
||||
tableaus,
|
||||
}
|
||||
@@ -289,24 +291,11 @@ pub struct SolitaireGame {
|
||||
|
||||
impl SolitaireGame {
|
||||
fn snap(&self) -> GameSnapshot {
|
||||
let cards = |t: PileType| -> Vec<CardSnapshot> {
|
||||
self.game
|
||||
.piles
|
||||
.get(&t)
|
||||
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let cards =
|
||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
||||
let has_moves = {
|
||||
let stock_empty = self
|
||||
.game
|
||||
.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_none_or(|p| p.cards.is_empty());
|
||||
let waste_empty = self
|
||||
.game
|
||||
.piles
|
||||
.get(&PileType::Waste)
|
||||
.is_none_or(|p| p.cards.is_empty());
|
||||
let stock_empty = self.game.stock_cards().is_empty();
|
||||
let waste_empty = self.game.waste_cards().is_empty();
|
||||
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
|
||||
};
|
||||
GameSnapshot {
|
||||
@@ -317,30 +306,29 @@ impl SolitaireGame {
|
||||
has_moves,
|
||||
undo_count: self.game.undo_count,
|
||||
undo_stack_len: self.game.undo_stack_len(),
|
||||
stock: cards(PileType::Stock),
|
||||
waste: cards(PileType::Waste),
|
||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
||||
foundations: [
|
||||
cards(PileType::Foundation(0)),
|
||||
cards(PileType::Foundation(1)),
|
||||
cards(PileType::Foundation(2)),
|
||||
cards(PileType::Foundation(3)),
|
||||
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||
cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
||||
cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
||||
],
|
||||
tableaus: [
|
||||
cards(PileType::Tableau(0)),
|
||||
cards(PileType::Tableau(1)),
|
||||
cards(PileType::Tableau(2)),
|
||||
cards(PileType::Tableau(3)),
|
||||
cards(PileType::Tableau(4)),
|
||||
cards(PileType::Tableau(5)),
|
||||
cards(PileType::Tableau(6)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau1)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau2)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau3)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau4)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau5)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau6)),
|
||||
cards(KlondikePile::Tableau(Tableau::Tableau7)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn pile_from_str(s: &str) -> Result<PileType, String> {
|
||||
fn pile_from_str(s: &str) -> Result<KlondikePile, String> {
|
||||
match s {
|
||||
"stock" => Ok(PileType::Stock),
|
||||
"waste" => Ok(PileType::Waste),
|
||||
"stock" | "waste" => Ok(KlondikePile::Stock),
|
||||
_ if s.starts_with("foundation-") => {
|
||||
let slot: u8 = s["foundation-".len()..]
|
||||
.parse()
|
||||
@@ -348,7 +336,13 @@ impl SolitaireGame {
|
||||
if slot >= 4 {
|
||||
return Err(format!("foundation slot out of range: {slot}"));
|
||||
}
|
||||
Ok(PileType::Foundation(slot))
|
||||
Ok(KlondikePile::Foundation(match slot {
|
||||
0 => Foundation::Foundation1,
|
||||
1 => Foundation::Foundation2,
|
||||
2 => Foundation::Foundation3,
|
||||
3 => Foundation::Foundation4,
|
||||
_ => return Err(format!("foundation slot out of range: {slot}")),
|
||||
}))
|
||||
}
|
||||
_ if s.starts_with("tableau-") => {
|
||||
let col: usize = s["tableau-".len()..]
|
||||
@@ -357,7 +351,16 @@ impl SolitaireGame {
|
||||
if col >= 7 {
|
||||
return Err(format!("tableau col out of range: {col}"));
|
||||
}
|
||||
Ok(PileType::Tableau(col))
|
||||
Ok(KlondikePile::Tableau(match col {
|
||||
0 => Tableau::Tableau1,
|
||||
1 => Tableau::Tableau2,
|
||||
2 => Tableau::Tableau3,
|
||||
3 => Tableau::Tableau4,
|
||||
4 => Tableau::Tableau5,
|
||||
5 => Tableau::Tableau6,
|
||||
6 => Tableau::Tableau7,
|
||||
_ => return Err(format!("tableau col out of range: {col}")),
|
||||
}))
|
||||
}
|
||||
_ => Err(format!("unknown pile: {s}")),
|
||||
}
|
||||
@@ -495,303 +498,3 @@ impl SolitaireGame {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_replay_json() -> String {
|
||||
// Minimal v2 replay: seed 42, two stock clicks. Real winning
|
||||
// replays will have many more moves; for the test we just
|
||||
// verify deserialization + step() advances correctly.
|
||||
r#"{
|
||||
"schema_version": 2,
|
||||
"seed": 42,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": ["StockClick", "StockClick"]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Constructing from a valid v2 replay JSON must succeed and
|
||||
/// initialise step_idx to 0.
|
||||
#[test]
|
||||
fn new_initialises_step_idx_zero() {
|
||||
let player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
|
||||
assert_eq!(player.step_idx, 0);
|
||||
assert_eq!(player.moves.len(), 2);
|
||||
}
|
||||
|
||||
/// Each step advances the index; once exhausted, step_native returns None.
|
||||
#[test]
|
||||
fn steps_advance_then_terminate() {
|
||||
let mut player = ReplayPlayer::from_json(&sample_replay_json()).expect("valid JSON");
|
||||
assert!(
|
||||
player
|
||||
.step_native()
|
||||
.expect("first move should apply")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(player.step_idx, 1);
|
||||
assert!(
|
||||
player
|
||||
.step_native()
|
||||
.expect("second move should apply")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(player.step_idx, 2);
|
||||
assert!(
|
||||
player
|
||||
.step_native()
|
||||
.expect("replay should be exhausted")
|
||||
.is_none(),
|
||||
"no further steps"
|
||||
);
|
||||
}
|
||||
|
||||
/// Malformed JSON returns an error rather than panicking.
|
||||
#[test]
|
||||
fn invalid_json_returns_error() {
|
||||
let result = ReplayPlayer::from_json("not valid json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_replay_move_returns_error_without_advancing() {
|
||||
let replay = Replay {
|
||||
schema_version: 2,
|
||||
seed: 42,
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
mode: GameMode::Classic,
|
||||
time_seconds: 60,
|
||||
final_score: 100,
|
||||
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
moves: vec![ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Foundation(0),
|
||||
count: 1,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&replay).expect("replay serialises");
|
||||
let mut player = ReplayPlayer::from_json(&json).expect("valid JSON");
|
||||
|
||||
let err = player
|
||||
.step_native()
|
||||
.expect_err("illegal replay move must surface an error");
|
||||
assert_eq!(err, MoveError::EmptySource);
|
||||
assert_eq!(
|
||||
player.step_idx, 0,
|
||||
"desync must not advance the replay cursor"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Winning-sequence step-through
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Greedy Klondike solver for DrawOne Classic.
|
||||
///
|
||||
/// Returns a `ReplayMove` list that wins the game from `seed`, or `None`
|
||||
/// when the greedy heuristic gets stuck within the move budget.
|
||||
///
|
||||
/// Priority order (highest first):
|
||||
/// 1. Waste → Foundation
|
||||
/// 2. Tableau top → Foundation
|
||||
/// 3. Tableau stack → Tableau, only if the move uncovers a face-down card
|
||||
/// 4. Waste → Tableau
|
||||
/// 5. Draw from stock (recycle is automatic inside `GameState::draw`)
|
||||
fn greedy_solve(seed: u64) -> Option<Vec<ReplayMove>> {
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
let mut game = GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic);
|
||||
let mut moves: Vec<ReplayMove> = Vec::new();
|
||||
const MAX_MOVES: usize = 10_000;
|
||||
|
||||
'outer: loop {
|
||||
if game.is_won {
|
||||
return Some(moves);
|
||||
}
|
||||
if moves.len() >= MAX_MOVES {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Auto-complete: drive to win without further player input.
|
||||
if game.is_auto_completable {
|
||||
while let Some((from, to)) = game.next_auto_complete_move() {
|
||||
if game.move_cards(from.clone(), to.clone(), 1).is_err() {
|
||||
return None;
|
||||
}
|
||||
moves.push(ReplayMove::Move { from, to, count: 1 });
|
||||
}
|
||||
return if game.is_won { Some(moves) } else { None };
|
||||
}
|
||||
|
||||
// P1: Waste → Foundation.
|
||||
for slot in 0..4_u8 {
|
||||
if game
|
||||
.move_cards(PileType::Waste, PileType::Foundation(slot), 1)
|
||||
.is_ok()
|
||||
{
|
||||
moves.push(ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Foundation(slot),
|
||||
count: 1,
|
||||
});
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
// P2: Tableau top → Foundation.
|
||||
for i in 0..7_usize {
|
||||
for slot in 0..4_u8 {
|
||||
if game
|
||||
.move_cards(PileType::Tableau(i), PileType::Foundation(slot), 1)
|
||||
.is_ok()
|
||||
{
|
||||
moves.push(ReplayMove::Move {
|
||||
from: PileType::Tableau(i),
|
||||
to: PileType::Foundation(slot),
|
||||
count: 1,
|
||||
});
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P3: Tableau stack → Tableau only when it uncovers a face-down card.
|
||||
let mut made_move = false;
|
||||
'p3: for i in 0..7_usize {
|
||||
let pile_len = game.piles[&PileType::Tableau(i)].cards.len();
|
||||
for count in 1..=pile_len {
|
||||
let start = pile_len - count;
|
||||
// Only worth moving if a face-down card sits just below.
|
||||
let would_uncover =
|
||||
start > 0 && !game.piles[&PileType::Tableau(i)].cards[start - 1].face_up;
|
||||
if !would_uncover {
|
||||
continue;
|
||||
}
|
||||
for j in 0..7_usize {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
if game
|
||||
.move_cards(PileType::Tableau(i), PileType::Tableau(j), count)
|
||||
.is_ok()
|
||||
{
|
||||
moves.push(ReplayMove::Move {
|
||||
from: PileType::Tableau(i),
|
||||
to: PileType::Tableau(j),
|
||||
count,
|
||||
});
|
||||
made_move = true;
|
||||
break 'p3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if made_move {
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// P4: Waste → Tableau.
|
||||
for j in 0..7_usize {
|
||||
if game
|
||||
.move_cards(PileType::Waste, PileType::Tableau(j), 1)
|
||||
.is_ok()
|
||||
{
|
||||
moves.push(ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(j),
|
||||
count: 1,
|
||||
});
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
// P5: Draw from stock (handles recycle automatically).
|
||||
if game.draw().is_ok() {
|
||||
moves.push(ReplayMove::StockClick);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// No moves available — greedy solver is stuck on this seed.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Full end-to-end winning-sequence regression test.
|
||||
///
|
||||
/// 1. Runs the greedy solver on seeds 1–200 to find the first
|
||||
/// deterministically winnable game.
|
||||
/// 2. Serialises the winning move list as a `Replay` JSON string.
|
||||
/// 3. Feeds the JSON to `ReplayPlayer::from_json`.
|
||||
/// 4. Steps through every move via `step_native` and asserts `is_won`
|
||||
/// on the final snapshot.
|
||||
///
|
||||
/// Regression target: a `GameState` or `ReplayMove` change that breaks
|
||||
/// an historically valid move sequence will cause `is_won` to be `false`
|
||||
/// at the end of the replay, failing this test before any release.
|
||||
#[test]
|
||||
fn replay_player_completes_full_winning_sequence() {
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
let (seed, winning_moves) = (1_u64..=200)
|
||||
.find_map(|s| greedy_solve(s).map(|m| (s, m)))
|
||||
.expect("at least one seed in 1..=200 must be solvable by the greedy strategy");
|
||||
|
||||
let replay = Replay {
|
||||
schema_version: 2,
|
||||
seed,
|
||||
draw_mode: DrawMode::DrawOne,
|
||||
mode: GameMode::Classic,
|
||||
time_seconds: 300,
|
||||
final_score: 0,
|
||||
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 12).expect("2026-05-12 is a valid date"),
|
||||
moves: winning_moves.clone(),
|
||||
};
|
||||
let json = serde_json::to_string(&replay).expect("replay serialises to JSON cleanly");
|
||||
|
||||
let mut player =
|
||||
ReplayPlayer::from_json(&json).expect("solver-generated replay JSON must be valid");
|
||||
assert_eq!(player.step_idx, 0, "player must start at step 0");
|
||||
assert_eq!(
|
||||
player.moves.len(),
|
||||
winning_moves.len(),
|
||||
"player must hold the complete move list"
|
||||
);
|
||||
|
||||
let mut last_snap: Option<StateSnapshot> = None;
|
||||
while let Some(snap) = player
|
||||
.step_native()
|
||||
.expect("solver-generated replay must stay in sync")
|
||||
{
|
||||
last_snap = Some(snap);
|
||||
}
|
||||
|
||||
let snap = last_snap.expect("winning sequence must contain at least one move");
|
||||
assert!(
|
||||
snap.is_won,
|
||||
"seed {seed}: final snapshot after full replay must have is_won = true \
|
||||
({} moves applied)",
|
||||
winning_moves.len()
|
||||
);
|
||||
assert_eq!(
|
||||
snap.step_idx,
|
||||
winning_moves.len(),
|
||||
"step_idx after the last move must equal the total move count"
|
||||
);
|
||||
assert!(
|
||||
player
|
||||
.step_native()
|
||||
.expect("winning replay should still be exhausted")
|
||||
.is_none(),
|
||||
"step_native must return None once all moves are exhausted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user