refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s

- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 13:13:35 -07:00
parent ca612f51f1
commit 9260ca7994
36 changed files with 7429 additions and 7064 deletions
+166 -138
View File
@@ -37,8 +37,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use crate::card_plugin::CardEntity;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -59,7 +60,7 @@ use crate::ui_theme::{ACCENT_PRIMARY, STATE_SUCCESS, STATE_WARNING};
#[derive(Resource, Debug, Default)]
pub struct SelectionState {
/// The pile whose top face-up card is currently selected, or `None`.
pub selected_pile: Option<PileType>,
pub selected_pile: Option<KlondikePile>,
}
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
@@ -86,7 +87,7 @@ pub enum KeyboardDragState {
/// `legal_destinations` and `Enter` fires the move.
Lifted {
/// Pile the cards were lifted from.
source_pile: PileType,
source_pile: KlondikePile,
/// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column).
count: usize,
@@ -97,7 +98,7 @@ pub enum KeyboardDragState {
/// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to
/// enter `Lifted` in the first place.
legal_destinations: Vec<PileType>,
legal_destinations: Vec<KlondikePile>,
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
destination_index: usize,
},
@@ -109,7 +110,7 @@ impl KeyboardDragState {
///
/// [`Lifted`]: KeyboardDragState::Lifted
/// [`Idle`]: KeyboardDragState::Idle
pub fn focused_destination(&self) -> Option<&PileType> {
pub fn focused_destination(&self) -> Option<&KlondikePile> {
match self {
Self::Idle => None,
Self::Lifted {
@@ -172,13 +173,26 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling.
///
/// Order: Waste → Foundation slots 03 → Tableau 06.
fn cycled_piles() -> Vec<PileType> {
let mut piles = vec![PileType::Waste];
for slot in 0..4_u8 {
piles.push(PileType::Foundation(slot));
fn cycled_piles() -> Vec<KlondikePile> {
let mut piles = vec![KlondikePile::Stock];
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
piles.push(KlondikePile::Foundation(foundation));
}
for i in 0..7_usize {
piles.push(PileType::Tableau(i));
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
piles.push(KlondikePile::Tableau(tableau));
}
piles
}
@@ -188,7 +202,7 @@ fn cycled_piles() -> Vec<PileType> {
///
/// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Option<PileType> {
pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile>) -> Option<KlondikePile> {
if available.is_empty() {
return None;
}
@@ -209,7 +223,7 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
for offset in 0..n {
let candidate = &order[(start + offset) % n];
if available.contains(candidate) {
return Some(candidate.clone());
return Some(*candidate);
}
}
None
@@ -221,14 +235,14 @@ pub fn cycle_next_pile(available: &[PileType], current: Option<&PileType>) -> Op
///
/// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`.
fn did_wrap(available: &[PileType], current: Option<&PileType>, next: Option<&PileType>) -> bool {
fn did_wrap(available: &[KlondikePile], current: Option<&KlondikePile>, next: Option<&KlondikePile>) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else {
return false;
};
let order = cycled_piles();
// Position of each pile within the *available* subset, ordered by the
// global cycle order.
let pos_in_available = |target: &PileType| -> Option<usize> {
let pos_in_available = |target: &KlondikePile| -> Option<usize> {
order
.iter()
.filter(|p| available.contains(p))
@@ -325,7 +339,7 @@ fn handle_selection_keys(
if keys.just_pressed(KeyCode::Enter) {
if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
moves.write(MoveRequestEvent {
from: source_pile.clone(),
from: *source_pile,
to: dest,
count: *count,
});
@@ -356,28 +370,24 @@ fn handle_selection_keys(
// ---------------------------------------------------------------------
// Build the list of piles that currently have a face-up draggable top card.
let available: Vec<PileType> = {
let available: Vec<KlondikePile> = {
let all = [
PileType::Waste,
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
PileType::Tableau(3),
PileType::Tableau(4),
PileType::Tableau(5),
PileType::Tableau(6),
KlondikePile::Stock,
KlondikePile::Foundation(Foundation::Foundation1),
KlondikePile::Foundation(Foundation::Foundation2),
KlondikePile::Foundation(Foundation::Foundation3),
KlondikePile::Foundation(Foundation::Foundation4),
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau2),
KlondikePile::Tableau(Tableau::Tableau3),
KlondikePile::Tableau(Tableau::Tableau4),
KlondikePile::Tableau(Tableau::Tableau5),
KlondikePile::Tableau(Tableau::Tableau6),
KlondikePile::Tableau(Tableau::Tableau7),
];
all.into_iter()
.filter(|p| {
game.0
.piles
.get(p)
.and_then(|pile| pile.cards.last())
.is_some_and(|c| c.face_up)
pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)
})
.collect()
};
@@ -406,18 +416,16 @@ fn handle_selection_keys(
// tableau stack target. Preserved so the muscle memory built around
// `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
if keys.just_pressed(KeyCode::Space)
&& let Some(ref pile) = selection.selected_pile.clone()
&& let Some(card) = game
.0
.piles
.get(pile)
.and_then(|p| p.cards.last())
.filter(|c| c.face_up)
&& let Some(ref pile) = selection.selected_pile
{
let selected_cards = pile_cards(&game.0, pile);
let Some(card) = selected_cards.last().filter(|c| c.face_up) else {
return;
};
// Priority 1: foundation move (single card).
if let Some(dest) = try_foundation_dest(card, &game.0) {
moves.write(MoveRequestEvent {
from: pile.clone(),
from: *pile,
to: dest,
count: 1,
});
@@ -425,17 +433,16 @@ fn handle_selection_keys(
return;
}
// Priority 2: tableau stack move.
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
let bottom_card = game.0.piles.get(pile).and_then(|p| {
let start = p.cards.len().saturating_sub(run_len);
p.cards.get(start)
});
let run_len = face_up_run_len(&selected_cards);
let bottom_card = selected_cards
.get(selected_cards.len().saturating_sub(run_len))
.cloned();
if let Some(bottom) = bottom_card
&& let Some((dest, count)) =
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
{
moves.write(MoveRequestEvent {
from: pile.clone(),
from: *pile,
to: dest,
count,
});
@@ -445,7 +452,7 @@ fn handle_selection_keys(
// Fallback for non-tableau sources.
if let Some(dest) = best_destination(card, &game.0) {
moves.write(MoveRequestEvent {
from: pile.clone(),
from: *pile,
to: dest,
count: 1,
});
@@ -456,25 +463,23 @@ fn handle_selection_keys(
// Enter — lift the focused pile into destination-pick mode.
if keys.just_pressed(KeyCode::Enter)
&& let Some(ref source) = selection.selected_pile.clone()
&& let Some(ref source) = selection.selected_pile
{
let Some(pile_cards) = game.0.piles.get(source) else {
let source_cards = pile_cards(&game.0, source);
if source_cards.is_empty() {
return;
};
}
// Determine the lift range: tableau lifts the full face-up run, all
// other sources lift only the top card.
let run_len = face_up_run_len(pile_cards.cards.as_slice());
let count = if matches!(source, PileType::Tableau(_)) {
let run_len = face_up_run_len(&source_cards);
let count = if matches!(source, KlondikePile::Tableau(_)) {
run_len.max(1)
} else {
1
};
if pile_cards.cards.is_empty() {
return;
}
let start = pile_cards.cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> = pile_cards.cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = pile_cards.cards.get(start) else {
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = source_cards.get(start) else {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
@@ -486,7 +491,7 @@ fn handle_selection_keys(
// Populate `DragState` with the keyboard sentinel so the existing
// mouse-drag systems treat this as "not their drag".
drag.cards = lifted_cards.clone();
drag.origin_pile = Some(source.clone());
drag.origin_pile = Some(*source);
drag.cursor_offset = Vec2::ZERO;
drag.origin_z = 1.0;
drag.press_pos = Vec2::ZERO;
@@ -494,7 +499,7 @@ fn handle_selection_keys(
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
*kbd_drag = KeyboardDragState::Lifted {
source_pile: source.clone(),
source_pile: *source,
count,
cards: lifted_cards,
legal_destinations: legal,
@@ -520,21 +525,34 @@ fn handle_selection_keys(
/// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for(
_bottom: &solitaire_core::card::Card,
source: &PileType,
source: &KlondikePile,
game: &GameState,
stack_count: usize,
) -> Vec<PileType> {
) -> Vec<KlondikePile> {
let mut out = Vec::new();
if stack_count == 1 {
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source, &dest, 1) {
out.push(dest);
}
}
}
for i in 0..7_usize {
let dest = PileType::Tableau(i);
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source, &dest, stack_count) {
out.push(dest);
}
@@ -572,10 +590,15 @@ fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
fn try_foundation_dest(
card: &solitaire_core::card::Card,
game: &solitaire_core::game_state::GameState,
) -> Option<PileType> {
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.id)?;
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(&source, &dest, 1) {
return Some(dest);
}
@@ -656,9 +679,9 @@ fn update_selection_highlight(
// Resolve the source pile from KeyboardDragState (when lifted) or
// SelectionState (otherwise). Lifted takes precedence so the gold
// outline follows the actual lifted cards.
let source_pile: Option<PileType> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
KeyboardDragState::Idle => selection.selected_pile.clone(),
let source_pile: Option<KlondikePile> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile),
KeyboardDragState::Idle => selection.selected_pile,
};
if let Some(ref pile) = source_pile
@@ -694,14 +717,18 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card<'a>(
pile: &PileType,
game: &'a GameState,
) -> Option<&'a solitaire_core::card::Card> {
game.piles
.get(pile)
.and_then(|p| p.cards.last())
.filter(|c| c.face_up)
fn top_face_up_card(
pile: &KlondikePile,
game: &GameState,
) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
@@ -740,15 +767,15 @@ fn spawn_highlight_on_card(
mod tests {
use super::*;
fn piles_from(names: &[&str]) -> Vec<PileType> {
fn piles_from(names: &[&str]) -> Vec<KlondikePile> {
names
.iter()
.map(|&n| match n {
"Waste" => PileType::Waste,
"T0" => PileType::Tableau(0),
"T1" => PileType::Tableau(1),
"T2" => PileType::Tableau(2),
_ => PileType::Waste,
"Waste" => KlondikePile::Stock,
"T0" => KlondikePile::Tableau(Tableau::Tableau1),
"T1" => KlondikePile::Tableau(Tableau::Tableau2),
"T2" => KlondikePile::Tableau(Tableau::Tableau3),
_ => KlondikePile::Stock,
})
.collect()
}
@@ -762,23 +789,23 @@ mod tests {
// With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, None);
assert_eq!(result, Some(PileType::Waste));
assert_eq!(result, Some(KlondikePile::Stock));
}
#[test]
fn cycle_next_pile_from_waste() {
// Starting from Waste → Tableau(0).
let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, Some(&PileType::Waste));
assert_eq!(result, Some(PileType::Tableau(0)));
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1)));
}
#[test]
fn cycle_next_pile_wraps() {
// Starting from Tableau(1) → Waste (wraps back to start).
let available = piles_from(&["Waste", "T0", "T1"]);
let result = cycle_next_pile(&available, Some(&PileType::Tableau(1)));
assert_eq!(result, Some(PileType::Waste));
let result = cycle_next_pile(&available, Some(&KlondikePile::Tableau(Tableau::Tableau2)));
assert_eq!(result, Some(KlondikePile::Stock));
}
#[test]
@@ -803,7 +830,7 @@ mod tests {
// Press 1: no current selection → first pile, no wrap.
let sel1 = cycle_next_pile(&available, None);
assert_eq!(sel1, Some(PileType::Waste));
assert_eq!(sel1, Some(KlondikePile::Stock));
assert!(
!did_wrap(&available, None, sel1.as_ref()),
"first Tab should not wrap"
@@ -811,7 +838,7 @@ mod tests {
// Press 2: Waste → Tableau(0), no wrap.
let sel2 = cycle_next_pile(&available, sel1.as_ref());
assert_eq!(sel2, Some(PileType::Tableau(0)));
assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert!(
!did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
"second Tab should not wrap"
@@ -819,7 +846,7 @@ mod tests {
// Press 3: Tableau(0) → Tableau(1), still no wrap.
let sel3 = cycle_next_pile(&available, sel2.as_ref());
assert_eq!(sel3, Some(PileType::Tableau(1)));
assert_eq!(sel3, Some(KlondikePile::Tableau(Tableau::Tableau2)));
assert!(
!did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
"third Tab (T0→T1) should not wrap"
@@ -827,7 +854,7 @@ mod tests {
// Press 4: Tableau(1) → Waste, this IS the wrap.
let sel4 = cycle_next_pile(&available, sel3.as_ref());
assert_eq!(sel4, Some(PileType::Waste));
assert_eq!(sel4, Some(KlondikePile::Stock));
assert!(
did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
"fourth Tab should wrap back to Waste"
@@ -836,9 +863,9 @@ mod tests {
#[test]
fn cycle_next_pile_single_element_wraps_to_itself() {
let available = vec![PileType::Waste];
let result = cycle_next_pile(&available, Some(&PileType::Waste));
assert_eq!(result, Some(PileType::Waste));
let available = vec![KlondikePile::Stock];
let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
assert_eq!(result, Some(KlondikePile::Stock));
}
// -----------------------------------------------------------------------
@@ -986,46 +1013,47 @@ mod tests {
fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
// Clear stock, waste, all tableaus.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
g.set_test_tableau_cards(tableau, Vec::new());
}
// Place test cards.
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card {
}],
);
g.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles
.get_mut(&PileType::Tableau(2))
.unwrap()
.cards
.push(Card {
}],
);
g.set_test_tableau_cards(
Tableau::Tableau3,
vec![Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
}],
);
g
}
@@ -1084,7 +1112,7 @@ mod tests {
.clone();
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(PileType::Tableau(0)));
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle
@@ -1104,7 +1132,7 @@ mod tests {
// Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1119,7 +1147,7 @@ mod tests {
legal_destinations,
destination_index,
} => {
assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(
@@ -1134,7 +1162,7 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]);
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
assert_eq!(drag.origin_pile, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
}
@@ -1151,7 +1179,7 @@ mod tests {
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1171,7 +1199,7 @@ mod tests {
let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
assert_eq!(events[0].from, PileType::Tableau(0));
assert_eq!(events[0].from, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(events[0].to, expected_dest);
assert_eq!(events[0].count, 1);
@@ -1196,7 +1224,7 @@ mod tests {
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter);
app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
@@ -1213,7 +1241,7 @@ mod tests {
);
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)),
Some(KlondikePile::Tableau(Tableau::Tableau1)),
"Esc on lifted must keep SelectionState intact (source-pick mode)",
);
assert!(
@@ -1236,7 +1264,7 @@ mod tests {
{
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100];
drag.origin_pile = Some(PileType::Tableau(0));
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
drag.committed = true;
drag.active_touch_id = None;
}
@@ -1269,7 +1297,7 @@ mod tests {
app.update();
app.world_mut()
.resource_mut::<SelectionState>()
.selected_pile = Some(PileType::Tableau(0));
.selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
press_key(&mut app, KeyCode::Enter);
app.update();
@@ -1278,7 +1306,7 @@ mod tests {
app.update();
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)),
Some(KlondikePile::Tableau(Tableau::Tableau1)),
"first Esc only cancels the lift",
);