style: cargo fmt under rustfmt 1.9 and gate formatting in CI

The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-07 10:59:20 -07:00
parent 18bb1fa0be
commit 113a933170
47 changed files with 331 additions and 309 deletions
+4 -1
View File
@@ -46,7 +46,7 @@ jobs:
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: 1.95.0 toolchain: 1.95.0
components: clippy components: clippy, rustfmt
- name: Cache cargo build - name: Cache cargo build
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -60,6 +60,9 @@ jobs:
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \ libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
- name: Format check
run: cargo fmt --check
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB), # SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
# same as the web-e2e workflow's server prebuild. # same as the web-e2e workflow's server prebuild.
- name: Clippy (deny warnings) - name: Clippy (deny warnings)
+26 -22
View File
@@ -439,10 +439,7 @@ impl GameState {
self.session.history().len() self.session.history().len()
} }
fn cards_with_face( fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect() cards.into_iter().map(|card| (card, face_up)).collect()
} }
@@ -507,8 +504,10 @@ impl GameState {
Self::cards_with_face(cards.iter().cloned(), true) Self::cards_with_face(cards.iter().cloned(), true)
} }
KlondikePile::Tableau(tableau) => { KlondikePile::Tableau(tableau) => {
let mut cards = let mut cards = Self::cards_with_face(
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false); state.tableau_face_down_cards(tableau).iter().cloned(),
false,
);
cards.extend(Self::cards_with_face( cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(), state.tableau_face_up_cards(tableau).iter().cloned(),
true, true,
@@ -823,9 +822,7 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> { ) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state(); let state = self.session.state().state().state();
match instruction { match instruction {
KlondikeInstruction::RotateStock => { KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => { KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None; return None;
@@ -928,10 +925,7 @@ impl GameState {
/// ///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over. /// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction( pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
if self.is_won() { if self.is_won() {
return Err(MoveError::GameAlreadyWon); return Err(MoveError::GameAlreadyWon);
} }
@@ -1296,12 +1290,13 @@ mod tests {
game.take_from_foundation = false; game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1)); assert!(!game.can_move_cards(&from, &to, 1));
assert!( assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
legal_pile_moves(&game) f,
.iter() KlondikePile::Foundation(_)
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) ) || !matches!(
|| !matches!(t, KlondikePile::Tableau(_))) t,
); KlondikePile::Tableau(_)
)));
assert!(game.move_cards(from, to, 1).is_err()); assert!(game.move_cards(from, to, 1).is_err());
} }
@@ -1360,9 +1355,18 @@ mod tests {
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the // This seed is Inconclusive at 1k states but Winnable at 5k — proving the
// budget reaches the solver unchanged. // budget reaches the solver unchanged.
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000); let easy = GameState::solve_fresh_deal(
let medium = 0xD1FF_0000_0000_0012,
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000); DrawStockConfig::DrawOne,
1_000,
1_000,
);
let medium = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
5_000,
5_000,
);
assert!(easy.is_err()); assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_)))); assert!(matches!(medium, Ok(Some(_))));
} }
+3 -1
View File
@@ -13,7 +13,9 @@ pub mod scoring;
// when decoding instructions to piles in `instruction_to_piles`) and do not // when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature. // appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; pub use klondike::{
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
};
// Solvability check API (delegates to `card_game::Session::solve`); replaces the // Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module. // former `solitaire_data::solver` wrapper module.
+9 -2
View File
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
); );
} }
for t in &tableaux { for t in &tableaux {
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone())); cards.extend(
game.pile(KlondikePile::Tableau(*t))
.iter()
.map(|(c, _)| c.clone()),
);
} }
cards cards
} }
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> { fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)] prop_oneof![
Just(DrawStockConfig::DrawOne),
Just(DrawStockConfig::DrawThree)
]
} }
/// Apply a sequence of random actions to a game, silently ignoring errors. /// Apply a sequence of random actions to a game, silently ignoring errors.
+7 -3
View File
@@ -553,8 +553,8 @@ mod tests {
"saved file must use schema version 5", "saved file must use schema version 5",
); );
let loaded = load_game_state_from(&path) let loaded =
.expect("a valid in-progress game must load without error"); load_game_state_from(&path).expect("a valid in-progress game must load without error");
// The forward instruction history round-trips, so the reconstructed board // The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON. // re-serialises to byte-identical JSON.
@@ -569,7 +569,11 @@ mod tests {
// Derived board reads match the live game (move count + recycle count are // Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history). // both rebuilt from the replayed forward history).
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips"); assert_eq!(
loaded.move_count(),
gs.move_count(),
"move_count round-trips"
);
assert_eq!( assert_eq!(
loaded.recycle_count(), loaded.recycle_count(),
gs.recycle_count(), gs.recycle_count(),
+1 -2
View File
@@ -911,8 +911,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads // Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen. // GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 0, score: 0,
+6 -2
View File
@@ -174,9 +174,9 @@ mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -214,7 +214,11 @@ mod tests {
} }
g.set_test_tableau_cards( g.set_test_tableau_cards(
Tableau::Tableau1, Tableau::Tableau1,
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], vec![solitaire_core::Card::new(
Deck::Deck1,
Suit::Clubs,
Rank::Ace,
)],
); );
g.set_test_auto_completable(true); g.set_test_auto_completable(true);
let expected = ( let expected = (
@@ -72,9 +72,7 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating. /// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BufferedInput { pub enum BufferedInput {
Move { Move { from: MoveRequestEvent },
from: MoveRequestEvent,
},
Draw, Draw,
Undo, Undo,
} }
+1 -4
View File
@@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::resources::DragState; use crate::resources::DragState;
use crate::ui_theme::{ use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. /// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
/// ///
@@ -197,4 +195,3 @@ pub(super) fn update_card_shadows_on_drag(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system // Task #28 — Hint highlight tick system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -272,5 +271,3 @@ pub(super) fn find_top_card_at(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator // Task #28 — Stock-empty visual indicator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use bevy::sprite::Anchor; use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
@@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems // Task #34 — Card-flip animation systems
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -6
View File
@@ -15,10 +15,7 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker; use crate::table_plugin::PileMarker;
use crate::ui_theme::{ use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
/// Coalesces every `WindowResized` event arriving this frame into the latest /// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`]. /// pending size on [`ResizeThrottle`].
@@ -347,5 +344,3 @@ pub(super) fn fill_tableau_fan_on_startup(
}; };
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0); crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
} }
+3 -7
View File
@@ -14,8 +14,8 @@ use std::collections::HashMap;
use bevy::color::Color; use bevy::color::Color;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
@@ -577,10 +577,7 @@ impl Plugin for CardPlugin {
// the chain each pair is a scheduler ambiguity (#143). All // the chain each pair is a scheduler ambiguity (#143). All
// members are cheap and mostly change-gated; sequential // members are cheap and mostly change-gated; sequential
// execution is not a cost that matters here. // execution is not a cost that matters here.
.configure_sets( .configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
Update,
LayoutSystem::UpdateOnResize.before(BoardVisuals),
)
.add_systems( .add_systems(
Update, Update,
( (
@@ -597,8 +594,7 @@ impl Plugin for CardPlugin {
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
tick_hint_highlight, tick_hint_highlight,
update_stock_empty_indicator, update_stock_empty_indicator,
update_stock_count_badge update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
.run_if(resource_changed::<GameStateResource>),
collect_resize_events, collect_resize_events,
snap_cards_on_window_resize, snap_cards_on_window_resize,
resize_android_corner_labels, resize_android_corner_labels,
+1 -6
View File
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -12,10 +11,7 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{ use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY,
TYPE_BODY, Z_STOCK_BADGE,
};
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty, /// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw. Pure white /// to signal to the player that there are no more cards to draw. Pure white
@@ -288,4 +284,3 @@ pub(super) fn update_stock_count_badge(
} }
} }
} }
+1 -2
View File
@@ -6,9 +6,9 @@ use super::*;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
@@ -700,4 +700,3 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key); commands.entity(entity).insert(new_children_key);
} }
} }
+21 -34
View File
@@ -1,17 +1,16 @@
use super::*; use super::*;
use crate::game_plugin::GamePlugin;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::table_plugin::TablePlugin;
use solitaire_core::Deck;
use bevy::window::WindowResized;
use solitaire_core::{Card, Rank, Suit};
use std::collections::HashSet;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
use crate::game_plugin::GamePlugin;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::resources::DragState; use crate::resources::DragState;
use crate::table_plugin::TablePlugin;
use crate::ui_theme::TEXT_PRIMARY_HC; use crate::ui_theme::TEXT_PRIMARY_HC;
use bevy::window::WindowResized;
use solitaire_core::Deck;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet;
/// Convenience constructor — all unit tests use Deck1. /// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card { fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -163,8 +161,7 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
"need at least 3 waste cards for this test" "need at least 3 waste cards for this test"
); );
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -216,8 +213,7 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
let count = waste_pile.len(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -254,8 +250,7 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions let waste_rendered: Vec<_> = positions
@@ -745,8 +740,7 @@ fn advance_past_resize_throttle(app: &mut App) {
fn fire_window_resize(app: &mut App, width: f32, height: f32) { fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height. // Any Entity will do — the snap system reads only width/height.
let window = Entity::from_raw_u32(0) let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
.expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window, window,
width, width,
@@ -928,8 +922,7 @@ fn resize_in_place_updates_card_label_font_size() {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the // Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the // post-resize card width, so the in-place path is using the
// refreshed Layout. // refreshed Layout.
let expected_layout = let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC; let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!( assert!(
(after - expected).abs() < 1e-3, (after - expected).abs() < 1e-3,
@@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
q.iter(app.world()) q.iter(app.world())
.next() .next()
.expect("fixture should spawn at least one CardEntity") .expect("fixture should spawn at least one CardEntity")
.card.clone() .card
.clone()
}; };
// Pick a *different* card to act as the negative control — // Pick a *different* card to act as the negative control —
@@ -1101,9 +1095,7 @@ fn shadow_offset_increases_during_drag() {
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 { fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card). // Map every CardEntity to its (Entity, card).
let card_entity = { let card_entity = {
let mut q = app let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
.world_mut()
.query::<(Entity, &CardEntity)>();
q.iter(app.world()) q.iter(app.world())
.find(|(_, c)| c.card == *card) .find(|(_, c)| c.card == *card)
.map(|(e, _)| e) .map(|(e, _)| e)
@@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
assert_eq!(stock_badge_text(&mut app), "24"); assert_eq!(stock_badge_text(&mut app), "24");
{ {
let mut game = app.world_mut().resource_mut::<GameStateResource>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> = let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop(); let _ = stock.pop();
game.0.set_test_stock_cards(stock); game.0.set_test_stock_cards(stock);
} }
@@ -1234,8 +1225,7 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
// distinct dummy `Image`. We never render these; we only // distinct dummy `Image`. We never render these; we only
// compare ids. // compare ids.
let mut images = Assets::<Image>::default(); let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] = let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
std::array::from_fn(|_| images.add(Image::default()));
CardImageSet { CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs, backs,
@@ -1493,8 +1483,7 @@ fn waste_pile_cards_have_strictly_increasing_z() {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .iter()
@@ -1543,8 +1532,7 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x; let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout) let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter() .into_iter()
@@ -1573,8 +1561,7 @@ fn waste_pile_draw_one_cards_have_distinct_z() {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .iter()
+8 -8
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::RightClickHighlight; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
let pile_len = game.waste_cards().len(); let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
@@ -581,7 +582,10 @@ mod tests {
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
/// Builds an `App` with `MinimalPlugins` and the overlay system /// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers /// registered, plus the resources the system needs. Callers
@@ -630,11 +634,7 @@ mod tests {
// — same colour family, illegal. Tableau(2) must NOT be // — same colour family, illegal. Tableau(2) must NOT be
// highlighted. // highlighted.
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic); let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top( set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
&mut game,
2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game); let mut app = overlay_test_app(game);
+1 -1
View File
@@ -2,8 +2,8 @@
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse; use solitaire_sync::SyncResponse;
+8 -2
View File
@@ -852,7 +852,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
@@ -906,7 +909,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
+14 -4
View File
@@ -14,8 +14,13 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle; use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction}; DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
#[allow(deprecated)] #[allow(deprecated)]
use solitaire_data::latest_replay_path; use solitaire_data::latest_replay_path;
use solitaire_data::{ use solitaire_data::{
@@ -188,7 +193,9 @@ impl Plugin for GamePlugin {
) )
} else { } else {
( (
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)), saved.unwrap_or_else(|| {
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
}),
None, None,
) )
}; };
@@ -1324,7 +1331,10 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving // or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at // the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk. // startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some() if paused.is_some_and(|p| p.0)
|| game.0.is_won()
|| game.0.move_count() == 0
|| pending.0.is_some()
{ {
return; return;
} }
+15 -8
View File
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
}); });
app.update(); app.update();
let events = app let events = app.world().resource::<Messages<CardFlippedEvent>>();
.world()
.resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
assert!( assert!(
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
1, 1,
"only the draw is recorded; the undo does not erase it nor add a new entry", "only the draw is recorded; the undo does not erase it nor add a new entry",
); );
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock)); assert!(matches!(
recording.moves[0],
KlondikeInstruction::RotateStock
));
} }
/// Starting a new game wipes the recording so the next deal begins /// Starting a new game wipes the recording so the next deal begins
@@ -872,8 +873,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
}); });
app.update(); app.update();
let history = load_replay_history_from(&path) let history =
.expect("a winning replay must be persisted to ReplayPath"); load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
assert_eq!( assert_eq!(
history.replays.len(), history.replays.len(),
1, 1,
@@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
app.update(); app.update();
// Game state was reseeded — move_count is 0 on the new game. // Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0); assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
} }
#[test] #[test]
@@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
// The chosen seed is non-deterministic (system time), // The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly: // but the new game must have been started cleanly:
// move_count back to 0, undo stack empty. // move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0); assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
assert_eq!( assert_eq!(
app.world() app.world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
+3 -1
View File
@@ -432,7 +432,9 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score), zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today, daily_today,
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne), draw_mode: settings
.map(|s| s.0.draw_mode)
.unwrap_or(DrawStockConfig::DrawOne),
font_res, font_res,
difficulty_expanded, difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty), last_difficulty: settings.and_then(|s| s.0.last_difficulty),
+5 -4
View File
@@ -3,8 +3,6 @@
use super::*; use super::*;
/// Auto-fade state for the action button bar. The bar fades out when /// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when /// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video /// the cursor approaches the top of the window — same UX as a video
@@ -49,7 +47,11 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// `target` at a fixed rate so the visual transition is smooth across /// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates. /// variable framerates.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) { pub(super) fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
let Ok(window) = windows.single() else { let Ok(window) = windows.single() else {
return; return;
}; };
@@ -427,4 +429,3 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
from.alpha + (to.alpha - from.alpha) * t, from.alpha + (to.alpha - from.alpha) * t,
) )
} }
@@ -3,8 +3,6 @@
use super::*; use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the /// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button /// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event, /// is held down. Each click handler fires the corresponding request event,
@@ -613,4 +611,3 @@ pub(super) fn toggle_hud_on_tap(
} }
} }
} }
-1
View File
@@ -19,7 +19,6 @@ use interaction::*;
use spawn::*; use spawn::*;
use updates::*; use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the // On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes. // Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None. // The resource is never inserted on wasm, so every call resolves to None.
+3 -3
View File
@@ -2,7 +2,6 @@
use super::*; use super::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
@@ -292,7 +291,9 @@ pub(super) fn spawn_avatar_child(
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) { if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it. // Logged-in with a downloaded avatar: keep the accent disc behind it.
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY)); commands
.entity(parent)
.insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc. // Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
@@ -549,4 +550,3 @@ pub(super) fn spawn_action_button<M: Component>(
} }
}); });
} }
+38 -10
View File
@@ -40,9 +40,16 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
#[test] #[test]
fn score_reflects_game_state() { fn score_reflects_game_state() {
let mut app = headless_app(); let mut app = headless_app();
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20); let score = app
.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(20);
app.update(); app.update();
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}")); assert_eq!(
read_hud_text::<HudScore>(&mut app),
format!("Score: {score}")
);
} }
#[test] #[test]
@@ -192,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
fn challenge_hud_empty_when_no_daily_resource() { fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty. // No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app(); let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -207,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
target_score: None, target_score: None,
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00"); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
} }
@@ -222,7 +233,9 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
target_score: Some(4000), target_score: Some(4000),
max_time_secs: None, max_time_secs: None,
}); });
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts"); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
} }
@@ -238,7 +251,10 @@ fn challenge_hud_clears_on_win() {
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true); app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -384,7 +400,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
set_manual_time_step(&mut app, 0.0); set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold) // Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater. // is the smallest jump that triggers the floater.
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50); app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update(); app.update();
// One floater should now exist. // One floater should now exist.
@@ -405,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
#[test] #[test]
fn score_floater_despawns_after_full_lifetime() { fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50); app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update(); app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1); assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -431,7 +453,10 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
let mut app = headless_app(); let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below // +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant. // the 50-point threshold so the floater path stays dormant.
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5); app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(5);
app.update(); app.update();
assert_eq!( assert_eq!(
count_with::<ScoreFloater>(&mut app), count_with::<ScoreFloater>(&mut app),
@@ -507,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
..Settings::default() ..Settings::default()
})); }));
// +100 would normally create both a ScorePulse and a ScoreFloater. // +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50); app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update(); app.update();
assert_eq!( assert_eq!(
count_with::<ScorePulse>(&mut app), count_with::<ScorePulse>(&mut app),
+1 -2
View File
@@ -3,9 +3,9 @@
use super::*; use super::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -609,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size; font.font_size = new_size;
} }
} }
+16 -15
View File
@@ -27,10 +27,10 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode}; use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{Card, Suit};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
@@ -394,7 +394,10 @@ pub fn emit_hint_visuals(
// Find the top face-up card in the source pile and highlight it. // Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from); let source_cards = pile_cards(game, from);
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone()); let top_card = source_cards
.last()
.filter(|(_, face_up)| *face_up)
.map(|(c, _)| c.clone());
if let Some(card) = top_card { if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() { for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card == card { if card_entity.card == card {
@@ -831,9 +834,7 @@ fn end_drag(
let origin_cards = pile_cards(&game.0, &origin); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
origin_cards.iter().position(|(c, _)| c == card)
else {
continue; continue;
}; };
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
@@ -1070,8 +1071,7 @@ fn touch_end_drag(
let origin_cards = pile_cards(&game.0, &origin); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
origin_cards.iter().position(|(c, _)| c == card)
else { else {
continue; continue;
}; };
@@ -1174,7 +1174,8 @@ fn card_position(
y_offset -= layout.card_size.y * step; y_offset -= layout.card_size.y * step;
} }
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
// In Draw-Three mode the top 3 waste cards are fanned in X to match // In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step` // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a // so clicking the visually rightmost (top) card actually registers — a
@@ -1248,7 +1249,10 @@ fn find_draggable_at(
} }
(i, i + 1) (i, i + 1)
}; };
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect(); let cards: Vec<Card> = pile_cards[start..end]
.iter()
.map(|(c, _)| c.clone())
.collect();
return Some((pile, start, cards)); return Some((pile, start, cards));
} }
} }
@@ -1550,8 +1554,7 @@ fn handle_double_tap(
return; return;
} }
let Some((found_card, found_face_up)) = let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
pile_cards.iter().find(|(c, _)| c == top_card)
else { else {
return; return;
}; };
@@ -1783,8 +1786,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
} }
const fn tableau_number(tableau: Tableau) -> u8 { const fn tableau_number(tableau: Tableau) -> u8 {
match tableau { match tableau {
Tableau::Tableau1 => 1, Tableau::Tableau1 => 1,
+35 -35
View File
@@ -89,9 +89,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let mut game = GameState::new(42, DrawStockConfig::DrawOne); let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game); clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), let waste = vec![
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)]; Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
game.set_test_waste_cards(waste.clone()); game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top let top_index = waste.len() - 1; // 2 = the visible top
@@ -99,7 +101,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable"); let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile"); assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer"); assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only"); assert_eq!(
result.2,
vec![waste[top_index].clone()],
"drags the top card only"
);
} }
#[test] #[test]
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
// face-up card, but the iterator should skip face-down cards and // face-up card, but the iterator should skip face-down cards and
// the cursor sits above the face-up card's AABB, so the result // the cursor sits above the face-up card's AABB, so the result
// is None. // is None.
let face_down_pos = let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout); let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable"); assert!(result.is_none(), "face-down cards should not be draggable");
} }
@@ -195,8 +200,7 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos = let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout) let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag"); .expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
@@ -213,18 +217,14 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
let king = Card::new(D::Deck1, Suit::Spades, Rank::King); let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards( game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
Tableau::Tableau1,
vec![king, queen.clone(), jack.clone()],
);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box // The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top // Queen we click in her visible strip: the 0.25h band above the Jack's top
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
@@ -322,7 +322,11 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
); );
let (pile, _start, ids) = result.unwrap(); let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock); assert_eq!(pile, KlondikePile::Stock);
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste"); assert_eq!(
ids,
vec![four_clubs],
"only the top card is draggable from waste"
);
} }
#[test] #[test]
@@ -558,8 +562,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
fn rejected_drag_animation_targets_origin_resting_position() { fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!( assert!(
(anim.end - target_pos).length() < 1e-6, (anim.end - target_pos).length() < 1e-6,
@@ -577,8 +580,7 @@ fn rejected_drag_animation_targets_origin_resting_position() {
fn rejected_drag_animation_starts_from_drag_position() { fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0); let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0); let target_pos = Vec2::new(80.0, -120.0);
let anim = let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!( assert!(
(anim.start - drag_pos).length() < 1e-6, (anim.start - drag_pos).length() < 1e-6,
@@ -601,12 +603,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
/// the call site honest. /// the call site honest.
#[test] #[test]
fn rejected_drag_animation_uses_correct_duration() { fn rejected_drag_animation_uses_correct_duration() {
let anim = build_drag_reject_animation( let anim =
Vec2::new(640.0, 200.0), build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!( assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6, (anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \ "drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
@@ -620,12 +618,8 @@ fn rejected_drag_animation_uses_correct_duration() {
/// jittery rather than forgiving. /// jittery rather than forgiving.
#[test] #[test]
fn rejected_drag_animation_uses_responsive_curve() { fn rejected_drag_animation_uses_responsive_curve() {
let anim = build_drag_reject_animation( let anim =
Vec2::new(640.0, 200.0), build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!( assert_eq!(
anim.curve, anim.curve,
MotionCurve::Responsive, MotionCurve::Responsive,
@@ -683,10 +677,16 @@ fn pressing_h_spawns_pending_hint_task() {
app.init_resource::<HintSolverConfig>(); app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource( app.insert_resource(LayoutResource(compute_layout(
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), Vec2::new(1280.0, 800.0),
)); 0.0,
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); 0.0,
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.add_systems(Update, handle_keyboard_hint); app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame. // Simulate the H key being pressed this frame.
+2 -4
View File
@@ -862,10 +862,8 @@ fn handle_display_name_confirm(
.leaderboard_display_name .leaderboard_display_name
.clone() .clone()
.unwrap_or_else(|| { .unwrap_or_else(|| {
if let SyncBackend::SolitaireServer { if let SyncBackend::SolitaireServer { ref username, .. } =
ref username, settings.0.sync_backend
..
} = settings.0.sync_backend
{ {
username.chars().take(32).collect() username.chars().take(32).collect()
} else { } else {
+1 -1
View File
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, spawn_modal_header,
@@ -36,7 +37,6 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING}; use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -1
View File
@@ -969,7 +969,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.update(); app.update();
app app
} }
+22 -17
View File
@@ -178,9 +178,9 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches. /// and the resources/messages it touches.
@@ -249,10 +249,7 @@ mod tests {
.into_iter() .into_iter()
.zip(suits.iter()) .zip(suits.iter())
{ {
game.set_test_tableau_cards( game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
} }
game game
} }
@@ -267,9 +264,11 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -306,9 +305,11 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -344,18 +345,22 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn. // First spawn.
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task // Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks // before assigning the new one — at no point are two tasks
// in flight. // in flight.
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // it's been overwritten — what we *can* assert is that the
+6 -7
View File
@@ -47,10 +47,10 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent}; use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -360,9 +360,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
} }
/// Builds the `(destination, anchor)` list for a fresh radial open. /// Builds the `(destination, anchor)` list for a fresh radial open.
/// ///
/// `half_extents` is the window half-size in world space — icons are clamped /// `half_extents` is the window half-size in world space — icons are clamped
@@ -381,8 +378,10 @@ fn build_radial_destinations(
.map(|(i, d)| { .map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX); let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new( let clamped = Vec2::new(
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin), raw.x
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin), .clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y
.clamp(-half_extents.y + margin, half_extents.y - margin),
); );
(d, clamped) (d, clamped)
}) })
@@ -246,7 +246,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// known card, or `"--"` for an absent top card (empty pile). /// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String { pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card { match card {
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())), Some((c, _)) => format!(
"{}{}",
format_rank_short(c.rank()),
format_suit_glyph(c.suit())
),
None => "--".to_string(), None => "--".to_string(),
} }
} }
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+1 -3
View File
@@ -90,9 +90,7 @@ mod tests {
.and_then(|prefix| prefix.split_whitespace().last()) .and_then(|prefix| prefix.split_whitespace().last())
.and_then(|n| n.parse::<usize>().ok()); .and_then(|n| n.parse::<usize>().ok());
parsed.unwrap_or_else(|| { parsed.unwrap_or_else(|| {
panic!( panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
)
}) })
} }
}; };
+11 -33
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex; use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -487,8 +487,10 @@ fn handle_selection_keys(
1 1
}; };
let start = source_cards.len().saturating_sub(count); let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> = let lifted_cards: Vec<Card> = source_cards[start..]
source_cards[start..].iter().map(|(c, _)| c.clone()).collect(); .iter()
.map(|(c, _)| c.clone())
.collect();
let Some((bottom, _)) = source_cards.get(start) else { let Some((bottom, _)) = source_cards.get(start) else {
return; return;
}; };
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter /// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a /// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move. /// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest( fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in [ for foundation in [
Foundation::Foundation1, Foundation::Foundation1,
@@ -697,13 +696,7 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0) && let Some(card) = top_face_up_card(pile, &game.0)
{ {
spawn_highlight_on_card( spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
} }
// Destination highlight while lifted. // Destination highlight while lifted.
@@ -714,13 +707,7 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable // in destination-pick mode and the focused index is observable
// via the resource. // via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) { if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card( spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
} }
} }
} }
@@ -1047,10 +1034,7 @@ mod tests {
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let selected = app let selected = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next // The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected. // available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1216,16 +1200,10 @@ mod tests {
drag.active_touch_id = None; drag.active_touch_id = None;
} }
let before = app let before = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let after = app let after = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
assert_eq!( assert_eq!(
before, after, before, after,
+2 -4
View File
@@ -1500,10 +1500,8 @@ mod tests {
#[test] #[test]
fn zen_win_event_updates_zen_best_score_only() { fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.mode =
.resource_mut::<GameStateResource>() solitaire_core::game_state::GameMode::Zen;
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 1800, score: 1800,
+4 -6
View File
@@ -301,9 +301,9 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let result = rt let result = rt.0.block_on(async {
.0 tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await }); });
match result { match result {
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {} Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(Err(e)) => warn!("sync push on exit failed: {e}"), Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
@@ -667,9 +667,7 @@ mod tests {
); );
// In-memory contract: replays[0].share_url is now Some(url). // In-memory contract: replays[0].share_url is now Some(url).
let live = app let live = app.world().resource::<ReplayHistoryResource>();
.world()
.resource::<ReplayHistoryResource>();
assert_eq!( assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()), live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()), Some(url.clone()),
+12 -11
View File
@@ -7,8 +7,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
@@ -385,7 +385,11 @@ fn on_window_resized(
>, >,
mut marker_outlines: Query< mut marker_outlines: Query<
&mut Sprite, &mut Sprite,
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>), (
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
>, >,
mut marker_labels: Query<&mut TextFont, With<Text2d>>, mut marker_labels: Query<&mut TextFont, With<Text2d>>,
) { ) {
@@ -437,8 +441,7 @@ fn on_window_resized(
// must be re-derived here too or a resize (fold/unfold, rotation) // must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey // leaves them at the stale size — visible as oversized grey
// frames on empty piles. // frames on empty piles.
let outline_size = let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28; let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() { for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) { if let Ok(mut outline) = marker_outlines.get_mut(*child) {
@@ -593,8 +596,6 @@ fn pile_cards(
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -703,7 +704,10 @@ mod tests {
} }
} }
assert_eq!(outlines, 12, "all 12 markers carry an outline child"); assert_eq!(outlines, 12, "all 12 markers carry an outline child");
assert!(labels >= 11, "tableau + foundation markers carry watermarks"); assert!(
labels >= 11,
"tableau + foundation markers carry watermarks"
);
} }
#[test] #[test]
@@ -939,10 +943,7 @@ mod tests {
#[test] #[test]
fn suit_symbol_all_four_are_distinct() { fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = Suit::SUITS let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
.iter()
.map(suit_symbol)
.collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect(); let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct"); assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
} }
-2
View File
@@ -252,8 +252,6 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
image_set.theme_back = Some(theme.back.clone()); image_set.theme_back = Some(theme.back.clone());
} }
/// Switches the active theme to the one served at /// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>` /// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
/// so callers can poll `Assets<CardTheme>` if they want to wait for /// so callers can poll `Assets<CardTheme>` if they want to wait for
@@ -28,8 +28,8 @@
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
+1 -4
View File
@@ -304,10 +304,7 @@ impl HighContrastBackground {
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar /// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather /// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray. /// than to a neutral gray.
pub const fn with_hc( pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
default_color: Color,
hc_color: Color,
) -> Self {
Self { Self {
default_color, default_color,
hc_color, hc_color,
+25 -14
View File
@@ -19,11 +19,14 @@
//! is the contract. //! is the contract.
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{KlondikeInstruction, KlondikePile};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError; use solitaire_core::error::MoveError;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3. /// Mirrors `solitaire_data::Replay` v3.
@@ -145,8 +148,8 @@ impl ReplayPlayer {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> { let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect() self.game.pile(t).iter().map(CardSnapshot::from).collect()
}; };
let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS let foundations: [Vec<CardSnapshot>; 4] =
.map(|f| pile_cards(KlondikePile::Foundation(f))); solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
let tableaus: [Vec<CardSnapshot>; 7] = let tableaus: [Vec<CardSnapshot>; 7] =
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t))); solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
StateSnapshot { StateSnapshot {
@@ -342,8 +345,7 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport { fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards(); let stock = game.stock_cards();
let waste = game.waste_cards(); let waste = game.waste_cards();
let foundations = let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t))); let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
@@ -403,7 +405,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
false false
}); });
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty(); let soft_lock =
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_cards.is_empty() let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty() && missing_cards.is_empty()
@@ -465,8 +468,7 @@ impl SolitaireGame {
.iter() .iter()
.map(CardSnapshot::from) .map(CardSnapshot::from)
.collect(), .collect(),
foundations: solitaire_core::FOUNDATIONS foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
.map(|f| cards(KlondikePile::Foundation(f))),
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))), tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
} }
} }
@@ -888,7 +890,9 @@ mod tests {
} }
let idx = pick_move_index(&legal_moves).unwrap_or_default(); let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) { if let Err(e) = game.apply_legal_move_native(idx) {
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"); panic!(
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
);
} }
} }
@@ -1073,9 +1077,14 @@ mod tests {
let stock_before = game.game.stock_cards().len(); let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len(); let waste_before = game.game.waste_cards().len();
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start"); assert!(
stock_before > 0,
"seed {seed}: stock must be non-empty at start"
);
game.game.draw().expect("draw must succeed when stock is non-empty"); game.game
.draw()
.expect("draw must succeed when stock is non-empty");
assert_eq!( assert_eq!(
game.game.stock_cards().len(), game.game.stock_cards().len(),
@@ -1104,7 +1113,9 @@ mod tests {
"seed {seed}: stock must have at least 3 cards for this test" "seed {seed}: stock must have at least 3 cards for this test"
); );
game.game.draw().expect("draw must succeed when stock has cards"); game.game
.draw()
.expect("draw must succeed when stock has cards");
let expected_drawn = stock_before.min(3); let expected_drawn = stock_before.min(3);
assert_eq!( assert_eq!(
+1 -2
View File
@@ -41,8 +41,7 @@ pub fn start() {
// texture-dimension limit is now taken from the adapter (see // texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf // the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack. // choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default() resolution: WindowResolution::default().with_scale_factor_override(1.0),
.with_scale_factor_override(1.0),
..default() ..default()
}), }),
..default() ..default()