feat(core): unlock foundations — Foundation(u8) slots, suit derived from contents

Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.

Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.

can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.

next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.

The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.

Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.

9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
  for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
  distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
  empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
  is unaffected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-01 22:17:17 +00:00
parent fdb6c2ecfe
commit 95df5421c9
14 changed files with 487 additions and 197 deletions
+2 -1
View File
@@ -196,7 +196,8 @@ mod tests {
// At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, PileType::Tableau(0));
assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs));
// First empty foundation slot wins on a fresh nearly-won board.
assert_eq!(fired[0].to, PileType::Foundation(0));
}
#[test]
+6 -6
View File
@@ -436,10 +436,10 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
let piles = [
PileType::Stock,
PileType::Waste,
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
@@ -985,8 +985,8 @@ fn handle_right_click(
let pile_type = &pile_marker.0;
let Some(pile) = game.0.piles.get(pile_type) else { continue };
let legal = match pile_type {
PileType::Foundation(suit) => {
can_place_on_foundation(&card, pile, *suit)
PileType::Foundation(_) => {
can_place_on_foundation(&card, pile)
}
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
_ => false,
+7 -8
View File
@@ -13,7 +13,6 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
@@ -82,10 +81,10 @@ fn update_cursor_icon(
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
let piles = [
PileType::Waste,
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
@@ -158,12 +157,12 @@ fn update_drop_highlights(
for (marker, mut sprite, _rch) in &mut markers {
let valid = match &marker.0 {
PileType::Foundation(suit) => {
PileType::Foundation(slot) => {
if drag_count != 1 {
false
} else {
let pile = game.0.piles.get(&PileType::Foundation(*suit));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit))
let pile = game.0.piles.get(&PileType::Foundation(*slot));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
}
PileType::Tableau(idx) => {
+14 -17
View File
@@ -479,7 +479,6 @@ fn handle_undo(
/// - Any face-up card on Waste or Tableau piles that can legally move to any
/// Foundation or Tableau destination.
pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
@@ -490,8 +489,6 @@ pub fn has_legal_moves(game: &GameState) -> bool {
return true;
}
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
// Check each playable source pile.
let sources: Vec<PileType> = {
let mut v = vec![PileType::Waste];
@@ -505,11 +502,11 @@ pub fn has_legal_moves(game: &GameState) -> bool {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundations.
for &suit in &suits {
let dest = PileType::Foundation(suit);
// Check foundation slots.
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, dest_pile, suit) {
&& can_place_on_foundation(card, dest_pile) {
return true;
}
}
@@ -1116,8 +1113,8 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1139,8 +1136,8 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all foundations and all tableau.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1234,8 +1231,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1273,8 +1270,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1340,8 +1337,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
+26 -4
View File
@@ -1557,6 +1557,7 @@ fn update_hud(
/// indicator stays in sync with the selection resource.
fn update_selection_hud(
selection: Option<Res<SelectionState>>,
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<HudSelection>>,
) {
let Ok(mut t) = q.single_mut() else { return };
@@ -1564,7 +1565,29 @@ fn update_selection_hud(
None => String::new(),
Some(PileType::Waste) => "▶ Waste".to_string(),
Some(PileType::Stock) => "▶ Stock".to_string(),
Some(PileType::Foundation(suit)) => {
Some(PileType::Foundation(slot)) => match game.as_deref() {
Some(g) => foundation_selection_label(*slot, &g.0),
// No game resource means we can't probe claimed_suit; show the
// slot-based placeholder so the HUD still surfaces the selection.
None => format!("▶ Foundation {}", slot + 1),
},
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
};
**t = label;
}
/// Returns the HUD selection label for a foundation slot.
///
/// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
let claimed = game
.piles
.get(&PileType::Foundation(slot))
.and_then(|p| p.claimed_suit());
match claimed {
Some(suit) => {
let s = match suit {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
@@ -1573,9 +1596,8 @@ fn update_selection_hud(
};
format!("{s} Foundation")
}
Some(PileType::Tableau(idx)) => format!("Column {}", idx + 1),
};
**t = label;
None => format!("Foundation {}", slot + 1),
}
}
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
+64 -54
View File
@@ -320,16 +320,23 @@ fn handle_keyboard_hint(
}
// Fire an informational toast describing where the hinted card should
// move so the player always sees the suggestion in text.
// move so the player always sees the suggestion in text. When the
// destination foundation already claims a suit, surface that suit so the
// player keeps thinking in suit terms; otherwise fall back to "foundation".
let msg = match to {
PileType::Foundation(suit) => {
let suit_name = match suit {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
Suit::Hearts => "Hearts",
Suit::Spades => "Spades",
};
format!("Hint: move to {suit_name} foundation")
PileType::Foundation(_) => {
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
if let Some(suit) = claimed {
let suit_name = match suit {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
Suit::Hearts => "Hearts",
Suit::Spades => "Spades",
};
format!("Hint: move to {suit_name} foundation")
} else {
"Hint: move to foundation".to_string()
}
}
PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
_ => "Hint: move card".to_string(),
@@ -634,12 +641,11 @@ fn end_drag(
let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target {
PileType::Foundation(suit) => {
PileType::Foundation(_) => {
count == 1
&& can_place_on_foundation(
&bottom_card,
&game.0.piles[&target],
*suit,
)
}
PileType::Tableau(_) => {
@@ -879,9 +885,9 @@ fn touch_end_drag(
let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target {
PileType::Foundation(suit) => {
PileType::Foundation(_) => {
count == 1
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target], *suit)
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
}
PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
@@ -1016,10 +1022,10 @@ fn find_draggable_at(
// Within a pile, we consider cards top-down because the visual top card is drawn last.
let piles = [
PileType::Waste,
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
@@ -1079,10 +1085,10 @@ fn find_drop_target(
origin: &PileType,
) -> Option<PileType> {
let piles = [
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
@@ -1138,11 +1144,11 @@ const DOUBLE_CLICK_WINDOW: f32 = 0.35;
///
/// Returns `None` if no legal move exists from the card's current location.
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
// Try all four foundations first.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let dest = PileType::Foundation(suit);
// Try all four foundation slots first.
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile, suit) {
&& can_place_on_foundation(card, pile) {
return Some(dest);
}
}
@@ -1298,7 +1304,6 @@ fn handle_double_click(
/// This is the backing data for the cycling hint system: the H key steps
/// through `hints[HintCycleIndex % hints.len()]` on each press.
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let sources: Vec<PileType> = {
let mut s = vec![PileType::Waste];
for i in 0..7_usize {
@@ -1313,12 +1318,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
for &suit in &suits {
let dest = PileType::Foundation(suit);
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, dest_pile, suit) {
&& can_place_on_foundation(card, dest_pile) {
hints.push((from.clone(), dest, 1));
// Each source card can go to at most one foundation suit;
// Each source card can land on at most one foundation slot;
// no need to check the remaining three for this card.
break;
}
@@ -1616,7 +1621,7 @@ mod tests {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
for pile in [
PileType::Waste,
PileType::Foundation(Suit::Hearts),
PileType::Foundation(2),
] {
let (_, size) = pile_drop_rect(&pile, &layout, &game);
assert_eq!(size, layout.card_size);
@@ -1638,13 +1643,15 @@ mod tests {
waste.cards.clear();
waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true });
// Foundation for Clubs is empty — Ace should go there.
let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap();
foundation.cards.clear();
// All four foundation slots empty — the Ace lands in slot 0 (first
// empty slot in iteration order).
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
let dest = best_destination(&card, &game);
assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs)));
assert_eq!(dest, Some(PileType::Foundation(0)));
}
#[test]
@@ -1653,9 +1660,9 @@ mod tests {
use solitaire_core::game_state::GameMode;
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
// Clear all foundations — a Two of Clubs cannot go there.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
// Clear all foundation slots — a Two of Clubs cannot go there.
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
// Put a Two of Clubs as the card.
@@ -1682,8 +1689,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear everything except one card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1704,8 +1711,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear all piles for a clean test.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1737,8 +1744,8 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1768,8 +1775,8 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1806,13 +1813,16 @@ mod tests {
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap().cards.clear();
// All foundation slots empty — Ace lands in slot 0 (first match).
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
let hint = find_hint(&game);
assert!(hint.is_some(), "should find a hint");
let (from, to, count) = hint.unwrap();
assert_eq!(from, PileType::Tableau(0));
assert_eq!(to, PileType::Foundation(Suit::Clubs));
assert_eq!(to, PileType::Foundation(0));
assert_eq!(count, 1);
}
@@ -1822,8 +1832,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne);
// Put only a Two on tableau 0, empty everything else.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1872,8 +1882,8 @@ mod tests {
// Remove all foundation, tableau, and waste cards so no pile-to-pile
// move exists. Leave one card in the stock.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1904,8 +1914,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear every pile, then put a single card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
+11 -16
View File
@@ -7,7 +7,6 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
/// Schedule labels for layout-related systems so cross-plugin ordering is
@@ -138,11 +137,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations.
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
for (i, suit) in foundation_suits.into_iter().enumerate() {
for slot in 0..4_u8 {
pile_positions.insert(
PileType::Foundation(suit),
Vec2::new(col_x(3 + i), top_y),
PileType::Foundation(slot),
Vec2::new(col_x(3 + slot as usize), top_y),
);
}
@@ -167,11 +165,10 @@ mod tests {
fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&PileType::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste));
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for slot in 0..4_u8 {
assert!(
layout.pile_positions.contains_key(&PileType::Foundation(suit)),
"missing foundation for {:?}",
suit
layout.pile_positions.contains_key(&PileType::Foundation(slot)),
"missing foundation slot {slot}",
);
}
for i in 0..7 {
@@ -257,15 +254,13 @@ mod tests {
#[test]
fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
for (i, suit) in foundation_suits.into_iter().enumerate() {
let f_x = layout.pile_positions[&PileType::Foundation(suit)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x;
for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
assert!(
(f_x - t_x).abs() < 1e-5,
"foundation {:?} should align with tableau {}",
suit,
3 + i
"foundation slot {slot} should align with tableau {}",
3 + slot as usize,
);
}
}
+12 -16
View File
@@ -18,7 +18,6 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::card_plugin::CardEntity;
@@ -82,15 +81,12 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling.
///
/// Order: Waste → Foundation×4 → Tableau 06.
/// Order: Waste → Foundation slots 03 → Tableau 06.
fn cycled_piles() -> Vec<PileType> {
let mut piles = vec![
PileType::Waste,
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
];
let mut piles = vec![PileType::Waste];
for slot in 0..4_u8 {
piles.push(PileType::Foundation(slot));
}
for i in 0..7_usize {
piles.push(PileType::Tableau(i));
}
@@ -183,10 +179,10 @@ fn handle_selection_keys(
let available: Vec<PileType> = {
let all = [
PileType::Waste,
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
@@ -325,10 +321,10 @@ fn try_foundation_dest(
game: &solitaire_core::game_state::GameState,
) -> Option<PileType> {
use solitaire_core::rules::can_place_on_foundation;
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let dest = PileType::Foundation(suit);
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile, suit) {
&& can_place_on_foundation(card, pile) {
return Some(dest);
}
}
+5 -14
View File
@@ -225,8 +225,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let mut piles: Vec<PileType> = Vec::with_capacity(13);
piles.push(PileType::Stock);
piles.push(PileType::Waste);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
piles.push(PileType::Foundation(suit));
for slot in 0..4_u8 {
piles.push(PileType::Foundation(slot));
}
for i in 0..7 {
piles.push(PileType::Tableau(i));
@@ -244,18 +244,9 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
PileMarker(pile.clone()),
));
// Task #35 — suit symbol on empty foundation placeholders.
if let PileType::Foundation(suit) = &pile {
let symbol = suit_symbol(suit).to_string();
entity.with_children(|b| {
b.spawn((
Text2d::new(symbol),
TextFont { font_size, ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
// Foundation slots no longer carry a suit letter — any Ace can claim
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
// foundation markers render as plain translucent rectangles.
// Task #43 — King indicator on empty tableau placeholders.
if let PileType::Tableau(_) = &pile {