chore: cargo fmt across workspace; add analytics domain to CSP
Build and Deploy / build-and-push (push) Successful in 4m46s

- Apply cargo fmt to solitaire_engine, solitaire_server formatting.
- solitaire_server/src/lib.rs: add https://analytics.aleshym.co to
  script-src, img-src, and connect-src so the analytics beacon loads
  without a CSP violation.
- docs and README updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-02 12:21:32 -07:00
parent baf524ec75
commit 1cdb78caf2
25 changed files with 229 additions and 130 deletions
+1 -3
View File
@@ -373,9 +373,7 @@ fn play_on_draw(
// When the stock pile is empty the draw action recycles the waste pile
// back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw.
let stock_len = game
.as_ref()
.map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
let stock_len = game.as_ref().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();
+13 -16
View File
@@ -16,10 +16,9 @@ use bevy::color::Color;
use bevy::prelude::*;
use bevy::sprite::Anchor;
use bevy::window::WindowResized;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
@@ -2355,16 +2354,16 @@ fn update_tableau_fan_frac(
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
game.0
.pile(klondike::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.count()
})
.max()
.unwrap_or(0);
.into_iter()
.map(|tableau| {
game.0
.pile(klondike::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.count()
})
.max()
.unwrap_or(0);
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
@@ -2575,8 +2574,7 @@ mod tests {
"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);
@@ -2628,8 +2626,7 @@ mod tests {
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);
+7 -4
View File
@@ -331,7 +331,11 @@ 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: &KlondikePile, 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, KlondikePile::Tableau(_)) {
let card_count = game.pile(*pile).len();
@@ -619,7 +623,7 @@ mod tests {
drag.committed = true;
}
#[test]
#[test]
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
// — same colour family, illegal. Tableau(2) must NOT be
@@ -658,5 +662,4 @@ mod tests {
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
);
}
}
}
@@ -13,10 +13,10 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::ChallengeGoal;
@@ -354,7 +354,6 @@ fn check_date_rollover(
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
+1 -1
View File
@@ -849,8 +849,8 @@ mod tests {
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::Tableau;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
+66 -48
View File
@@ -13,8 +13,8 @@ use chrono::Utc;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use klondike::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
@@ -521,10 +521,7 @@ fn handle_new_game(
// hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout
.0
.pile_positions
.get(&klondike::KlondikePile::Stock)
&& let Some(stock) = layout.0.pile_positions.get(&klondike::KlondikePile::Stock)
{
for mut tx in &mut card_transforms {
tx.translation.x = stock.x;
@@ -1047,17 +1044,11 @@ fn foundation_slot(foundation: klondike::Foundation) -> Option<u8> {
/// 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 {
// 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
.stock_cards()
.is_empty();
let waste_empty = game
.waste_cards()
.is_empty();
let stock_empty = game.stock_cards().is_empty();
let waste_empty = game.waste_cards().is_empty();
if !stock_empty || !waste_empty {
return true;
}
@@ -1191,7 +1182,10 @@ fn handle_game_over_input(
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent { confirmed: true, ..default() });
new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if keys.just_pressed(KeyCode::KeyU) {
for entity in &screens {
commands.entity(entity).despawn();
@@ -1219,7 +1213,10 @@ fn handle_game_over_button_input(
}
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent { confirmed: true, ..default() });
new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
for entity in &screens {
commands.entity(entity).despawn();
@@ -1388,9 +1385,11 @@ mod tests {
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
Tableau::Tableau1,
))
let before: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.collect();
@@ -1402,9 +1401,11 @@ mod tests {
});
app.update();
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
Tableau::Tableau1,
))
let after: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.collect();
@@ -1415,17 +1416,25 @@ mod tests {
fn settings_changed_updates_take_from_foundation_flag() {
let mut app = test_app(1);
assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation,
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()));
.write_message(crate::settings_plugin::SettingsChangedEvent(
settings.clone(),
));
app.update();
assert!(
!app.world().resource::<GameStateResource>().0.take_from_foundation,
!app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=false into live game state",
);
@@ -1434,7 +1443,10 @@ mod tests {
.write_message(crate::settings_plugin::SettingsChangedEvent(settings));
app.update();
assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation,
app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=true into live game state",
);
}
@@ -1557,7 +1569,7 @@ mod tests {
);
}
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
///
/// The timer is pre-seeded just past the threshold and the test
/// re-arms it before each `app.update()` in a small bounded loop:
@@ -1634,20 +1646,23 @@ mod tests {
// Build a tableau with two face-up cards.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
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::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 {
@@ -1782,7 +1797,7 @@ mod tests {
);
}
#[test]
#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
// Regression: the bug only checked t.cards.last() (top face-up card).
// If the only legal move involves a face-up card that is NOT the top
@@ -1936,16 +1951,16 @@ mod tests {
);
}
/// Verify that the game-over overlay contains the expected header text and
/// Verify that the game-over overlay contains the expected header text and
/// action-hint strings so players understand why the overlay appeared and
/// what keys to press.
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Task #56 — Escape dismisses GameOverScreen and starts new game
// -----------------------------------------------------------------------
/// Pressing Escape while `GameOverScreen` is visible must fire
/// `NewGameRequestEvent` — identical behaviour to pressing N.
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Task #48 — Undo with empty stack fires InfoToastEvent
// -----------------------------------------------------------------------
@@ -1988,7 +2003,7 @@ mod tests {
/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
/// Moving a card to a tableau pile must never produce a
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
@@ -2051,7 +2066,7 @@ mod tests {
/// At 12 cards on a foundation (AceJack on the pile, Queen in
/// flight), the event must NOT fire — the flourish is only for the
/// final 13th completion.
/// A successful undo must NOT fire an `InfoToastEvent`.
/// A successful undo must NOT fire an `InfoToastEvent`.
#[test]
fn undo_after_draw_does_not_fire_info_toast() {
let mut app = test_app(42);
@@ -2086,7 +2101,7 @@ mod tests {
/// Drive a fresh game through a draw + a tableau→foundation move,
/// then assert the recording resource captured both, in order, with
/// the correct shape.
/// Invalid moves must not appear in the recording — the recording is
/// Invalid moves must not appear in the recording — the recording is
/// "what successfully happened", not "what was requested".
#[test]
fn replay_does_not_record_rejected_moves() {
@@ -2359,7 +2374,10 @@ mod tests {
Tableau::Tableau7,
] {
assert_eq!(
app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(tableau)),
app.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(tableau)),
expected.pile(KlondikePile::Tableau(tableau)),
"tableau column {tableau:?} must match the unfiltered seed",
);
+4 -1
View File
@@ -2404,7 +2404,10 @@ 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: Foundation, game: &solitaire_core::game_state::GameState) -> String {
fn foundation_selection_label(
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
+12 -3
View File
@@ -269,7 +269,10 @@ pub fn compute_layout(
5 => Tableau::Tableau6,
_ => Tableau::Tableau7,
};
pile_positions.insert(KlondikePile::Tableau(tableau), Vec2::new(col_x(i), tableau_y));
pile_positions.insert(
KlondikePile::Tableau(tableau),
Vec2::new(col_x(i), tableau_y),
);
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -339,7 +342,9 @@ mod tests {
Tableau::Tableau7,
] {
assert!(
layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)),
layout
.pile_positions
.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}"
);
}
@@ -758,7 +763,11 @@ 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 [KlondikePile::Stock, KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau7)] {
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",
+1 -1
View File
@@ -153,7 +153,6 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use settings_plugin::{
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
@@ -179,6 +178,7 @@ pub use theme::{
pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
};
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
+5 -2
View File
@@ -26,8 +26,8 @@
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile;
use solitaire_core::game_state::GameState;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
@@ -101,7 +101,10 @@ struct HintTask {
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove { from: KlondikePile, to: KlondikePile },
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.
@@ -97,7 +97,11 @@ 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))
format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
)
}
}
}
+2 -4
View File
@@ -25,15 +25,14 @@
mod format;
mod input;
mod update;
#[cfg(test)]
mod tests;
mod update;
pub(crate) use self::format::*;
pub(crate) use self::input::*;
pub(crate) use self::update::*;
use bevy::prelude::*;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::font_plugin::FontResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
@@ -44,6 +43,7 @@ use crate::ui_theme::{
STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
};
use bevy::prelude::*;
// ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
@@ -316,7 +316,6 @@ pub struct ReplayOverlayScrubNotch;
#[derive(Component, Debug)]
pub struct ReplayOverlayScrubNotchLabel;
/// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
@@ -1270,4 +1269,3 @@ fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
Some(frac * 100.0)
}
+17 -6
View File
@@ -854,8 +854,7 @@ fn scrub_notch_labels_carry_helper_strings() {
let mut texts = scrub_notch_label_texts(&mut app);
texts.sort();
let mut expected: Vec<String> =
scrub_notch_labels().iter().map(|s| s.to_string()).collect();
let mut expected: Vec<String> = scrub_notch_labels().iter().map(|s| s.to_string()).collect();
expected.sort();
assert_eq!(
texts, expected,
@@ -1106,10 +1105,22 @@ fn move_log_active_row_text(app: &mut App) -> String {
#[test]
fn format_pile_uses_one_indexed_lowercase_names() {
assert_eq!(format_pile(&KlondikePile::Stock), "waste");
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), "foundation 1");
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), "foundation 3");
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), "tableau 1");
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), "tableau 7");
assert_eq!(
format_pile(&KlondikePile::Foundation(Foundation::Foundation1)),
"foundation 1"
);
assert_eq!(
format_pile(&KlondikePile::Foundation(Foundation::Foundation3)),
"foundation 3"
);
assert_eq!(
format_pile(&KlondikePile::Tableau(Tableau::Tableau1)),
"tableau 1"
);
assert_eq!(
format_pile(&KlondikePile::Tableau(Tableau::Tableau7)),
"tableau 7"
);
}
/// Move-body formatter renders `StockClick` as a label and
@@ -1,10 +1,10 @@
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,
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 super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
+9 -9
View File
@@ -268,11 +268,12 @@ 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);
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;
};
@@ -379,10 +380,9 @@ fn tick_replay_playback(
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) = (
KlondikePile::try_from(*from),
KlondikePile::try_from(*to),
) {
if let (Ok(from), Ok(to)) =
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
{
moves_writer.write(MoveRequestEvent {
from,
to,
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
#[derive(Resource, Debug, Clone)]
+16 -11
View File
@@ -202,7 +202,10 @@ fn cycled_piles() -> Vec<KlondikePile> {
///
/// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile>) -> Option<KlondikePile> {
pub fn cycle_next_pile(
available: &[KlondikePile],
current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
if available.is_empty() {
return None;
}
@@ -235,7 +238,11 @@ pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile
///
/// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`.
fn did_wrap(available: &[KlondikePile], current: Option<&KlondikePile>, next: Option<&KlondikePile>) -> bool {
fn did_wrap(
available: &[KlondikePile],
current: Option<&KlondikePile>,
next: Option<&KlondikePile>,
) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else {
return false;
};
@@ -386,9 +393,7 @@ fn handle_selection_keys(
KlondikePile::Tableau(Tableau::Tableau7),
];
all.into_iter()
.filter(|p| {
pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)
})
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
.collect()
};
@@ -717,10 +722,7 @@ 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(
pile: &KlondikePile,
game: &GameState,
) -> Option<Card> {
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
}
@@ -1162,14 +1164,17 @@ 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(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
);
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
}
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
/// only (foundations and tableaus that pass `can_place_on_*`), and
/// wrap at the end of the list.
/// Test 4 — Enter while `Lifted` with a destination focused fires
/// Test 4 — Enter while `Lifted` with a destination focused fires
/// exactly one `MoveRequestEvent` and resets the state machine to
/// `Idle` with `DragState` cleared.
#[test]
+1 -1
View File
@@ -33,9 +33,9 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
#[cfg(not(target_arch = "wasm32"))]
use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
+1 -1
View File
@@ -22,9 +22,9 @@
use std::path::Path;
use bevy::log::warn;
use bevy::prelude::{App, Plugin, Resource};
#[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use serde::Deserialize;
use super::ThemeMeta;