refactor(core): explicit Rank discriminants, checked arithmetic, possible_instructions
Build and Deploy / build-and-push (push) Successful in 3m55s
Android Release / build-apk (push) Successful in 4m37s

- Add Rank=1..13 explicit discriminants so `rank as u8 == rank.value()`; collapse 13-arm value() match to `self as u8`
- Add Rank::RANKS and Suit::SUITS iteration constants
- Add Rank::checked_add / checked_sub (const fn, type-safe boundary enforcement); update rules.rs to use them
- Add GameState::possible_instructions() enumerating all valid move_cards triples (foundation for hints/solver)
- Fix waste buffer card peeking through during draw-slide animation by setting Visibility::Hidden on the buffer entity in sync_cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-18 13:25:13 -07:00
parent e1c8ae0743
commit 456b4d42e3
4 changed files with 314 additions and 44 deletions
+61 -5
View File
@@ -691,6 +691,22 @@ fn sync_cards(
) {
let positions = card_positions(game, layout);
// The waste buffer card exists only to keep its entity alive while the new
// top card's slide animation plays — it must never be visible to the player.
// Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<u32> = {
let visible = match game.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
game.piles
.get(&PileType::Waste)
.filter(|w| w.cards.len() > visible)
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
.map(|c| c.id)
};
// Map card_id -> (Entity, current_translation, has_card_animation) for
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
// skip the snap/slide path on cards that are already being driven by a
@@ -711,17 +727,26 @@ fn sync_cards(
}
}
// For each card in the current state: spawn or update its entity.
// For each card in the current state: spawn or update its entity, then
// apply visibility. The waste buffer card is hidden so it cannot peek
// behind the incoming top card during the draw slide animation.
for (card, position, z) in positions {
match existing.get(&card.id) {
let entity = match existing.get(&card.id) {
Some(&(entity, cur, has_anim)) => {
update_card_entity(
&mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
)
);
entity
}
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
}
};
let visibility = if waste_buffer_id == Some(card.id) {
Visibility::Hidden
} else {
Visibility::Inherited
};
commands.entity(entity).insert(visibility);
}
}
@@ -831,7 +856,7 @@ fn spawn_card_entity(
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
) -> Entity {
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
let mut entity = commands.spawn((
@@ -840,6 +865,7 @@ fn spawn_card_entity(
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
));
let entity_id = entity.id();
// Every card gets a subtle drop-shadow child so the play surface reads
// as physical instead of flat. Spawned in idle state; the drag-tracking
// system retunes its offset / alpha when this card joins the dragged
@@ -880,6 +906,7 @@ fn spawn_card_entity(
// Suppress unused-variable warning when not building for Android.
#[cfg(not(target_os = "android"))]
let _ = font_handle;
entity_id
}
#[allow(clippy::too_many_arguments)]
@@ -2357,6 +2384,35 @@ mod tests {
}
}
/// The waste buffer card (slot below top) must be at the *same* XY as the
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
#[test]
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.piles[&PileType::Waste].cards.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
assert_eq!(waste_rendered.len(), 2, "Draw-One with 3 waste cards must render exactly 2");
// Both must share the same XY so that hiding the buffer leaves no gap.
let (_, pos0, _) = waste_rendered[0];
let (_, pos1, _) = waste_rendered[1];
assert!(
(pos0.x - pos1.x).abs() < 1e-3 && (pos0.y - pos1.y).abs() < 1e-3,
"buffer and top card must be at the same XY; got buffer={pos0:?} top={pos1:?}"
);
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);