Files
Ferrous-Solitaire/solitaire_engine/src/table_plugin.rs
T
funman300 113a933170 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>
2026-07-07 10:59:20 -07:00

951 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Renders the static table: felt background and empty pile markers.
//!
//! Pile markers are translucent rectangles that sit beneath any cards. They
//! remain visible only where a pile is empty, so the player can see where to
//! drop cards. All geometry comes from `LayoutResource`.
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::KlondikePile;
use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::hud_plugin::HudVisibility;
use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::ui_theme::TEXT_PRIMARY;
#[cfg(test)]
use solitaire_data::Theme;
/// Default tint applied to every empty-pile marker sprite. Pure white
/// at 15% alpha — soft enough that the marker reads as a "hint of a
/// slot" rather than a panel, but discernible even against a very dark
/// felt background under bright ambient light (the old 8% alpha vanished
/// on a #151515 felt during on-device Android testing).
///
/// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`,
/// which used to duplicate the literal alongside a "kept in sync" doc
/// comment. Pulling both call sites through this const makes drift a
/// compile error instead of a stale comment.
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.15);
/// Tint applied to the thin outline rectangle sitting behind every
/// empty-pile marker. A slightly brighter white at 28% alpha gives the
/// slot a defined edge — the standard solitaire "empty pile" affordance —
/// without competing with real cards. Rendered as a marginally larger
/// child rectangle one z-step behind the fill, so the fill overlaps it
/// and only a hairline frame remains visible.
const PILE_MARKER_OUTLINE_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.28);
/// Width in logical pixels of the visible outline frame around an empty
/// pile marker (the outline rect is this much larger on each side).
const PILE_MARKER_OUTLINE_WIDTH: f32 = 2.0;
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
///
/// Loaded once at startup by [`load_background_images`]. Index 0 is the
/// default; indices 14 are unlockable.
#[derive(Resource)]
pub struct BackgroundImageSet {
/// One handle per background slot (indices 04).
pub handles: Vec<Handle<Image>>,
}
/// Z-depth used for the background — below everything.
const Z_BACKGROUND: f32 = -10.0;
/// Z-depth used for pile markers — below cards (which start at 0) but above
/// the background.
const Z_PILE_MARKER: f32 = -1.0;
/// Marker component for the table felt background.
#[derive(Component, Debug)]
pub struct TableBackground;
/// Marker component attached to each of the 13 empty-pile placeholders.
#[derive(Component, Debug, Clone)]
pub struct PileMarker(pub KlondikePile);
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite
/// colour so it can be restored when the timer expires.
#[derive(Component, Debug, Clone)]
pub struct HintPileHighlight {
/// Seconds remaining before the pile marker colour is restored.
pub timer: f32,
/// The sprite colour the marker had before the hint tint was applied.
pub original_color: Color,
}
/// Registers the table background and pile-marker rendering.
pub struct TablePlugin;
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
/// systems that touch `Visibility` on UI entities declare themselves
/// ambiguous with it (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MarkerVisuals;
impl Plugin for TablePlugin {
fn build(&self, app: &mut App) {
// Register WindowResized so the plugin works under MinimalPlugins in
// tests. Under DefaultPlugins, bevy_window has already registered it
// and this call is a no-op.
app.add_message::<WindowResized>()
.add_message::<SettingsChangedEvent>()
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_systems(Startup, load_background_images.before(setup_table))
.add_systems(Startup, setup_table)
.add_systems(
Update,
(
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
// Marker painters: deterministic chain after the card
// paint pipeline — markers and cards share Sprite/
// Transform access (#143).
(
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility.after(GameMutation),
)
.chain()
.in_set(MarkerVisuals)
.after(crate::card_plugin::BoardVisuals),
),
);
}
}
/// Loads the 5 background PNG files at startup via the Bevy `AssetServer` and
/// stores their [`Handle<Image>`]s in [`BackgroundImageSet`].
fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
let Some(asset_server) = asset_server else {
// AssetServer absent (e.g. MinimalPlugins in tests) — insert an
// empty set so setup_table can proceed using a default handle.
commands.insert_resource(BackgroundImageSet {
handles: Vec::new(),
});
return;
};
let handles = (0..5)
.map(|i| asset_server.load(format!("backgrounds/bg_{i}.png")))
.collect();
commands.insert_resource(BackgroundImageSet { handles });
}
/// Returns the felt colour for a given theme.
///
/// Only used in tests — the runtime path now picks a PNG image via
/// [`BackgroundImageSet`] rather than a solid colour.
#[cfg(test)]
fn theme_colour(theme: &Theme) -> Color {
match theme {
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
}
}
/// Effective table background colour: unlocked background index overrides the
/// Theme when `selected_background > 0`.
///
/// Only used in tests — the runtime path now picks a PNG image via
/// [`BackgroundImageSet`] rather than a solid colour.
#[cfg(test)]
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
match selected_background {
0 => theme_colour(theme),
1 => Color::srgb(0.25, 0.18, 0.10), // dark wood
2 => Color::srgb(0.05, 0.08, 0.22), // navy
3 => Color::srgb(0.30, 0.05, 0.08), // burgundy
_ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+)
}
}
fn default_window_size(window: &Window) -> Vec2 {
Vec2::new(window.resolution.width(), window.resolution.height())
}
fn setup_table(
mut commands: Commands,
windows: Query<&Window>,
existing_camera: Query<(), With<Camera>>,
settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>,
hud_vis: Option<Res<HudVisibility>>,
) {
// Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests). Use the felt-green clear colour so the
// background reads as green even before the background PNG finishes
// loading (which is asynchronous and can lag by several frames on
// Android).
if existing_camera.is_empty() {
commands.spawn((
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::srgb(
TABLE_COLOUR[0],
TABLE_COLOUR[1],
TABLE_COLOUR[2],
)),
..default()
},
));
}
let (window_size, scale) = windows
.iter()
.next()
.map_or((Vec2::new(1280.0, 800.0), 1.0f32), |w| {
(default_window_size(w), w.scale_factor())
});
// Safe-area insets arrive from JNI asynchronously; they are almost always
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
// arrive and issues a synthetic WindowResized to re-snap all game objects.
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
let image_handle = bg_images
.as_ref()
.and_then(|set| set.handles.get(selected_bg).cloned())
.unwrap_or_default();
spawn_background(&mut commands, window_size, image_handle);
spawn_pile_markers(&mut commands, &layout);
commands.insert_resource(LayoutResource(layout));
}
/// Spawns the felt background sprite using a PNG image handle.
///
/// The sprite covers the window at twice the window size so brief resize gaps
/// are never visible. The image is tinted `Color::WHITE` (no tint) so the PNG
/// pixel data is rendered as-is.
fn spawn_background(commands: &mut Commands, window_size: Vec2, image: Handle<Image>) {
// Spawn a sprite covering the window. We give it the window size plus
// headroom so resizing up doesn't expose edges before the resize handler
// runs.
commands.spawn((
Sprite {
image,
color: Color::WHITE,
custom_size: Some(window_size * 2.0),
..default()
},
Transform::from_xyz(0.0, 0.0, Z_BACKGROUND),
TableBackground,
));
}
/// Reacts to settings changes by updating the background sprite's image handle.
///
/// When [`BackgroundImageSet`] is available the selected PNG handle is applied
/// directly (color is kept at `Color::WHITE` so the PNG pixel data shows
/// unmodified). If the resource is not yet ready the sprite is left unchanged.
fn apply_theme_on_settings_change(
mut events: MessageReader<SettingsChangedEvent>,
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
bg_images: Option<Res<BackgroundImageSet>>,
) {
let Some(ev) = events.read().last() else {
return;
};
let Some(set) = bg_images else {
// BackgroundImageSet not ready yet — leave sprite unchanged.
return;
};
let selected = ev.0.selected_background;
let Some(handle) = set.handles.get(selected).cloned() else {
return;
};
for mut sprite in &mut backgrounds {
sprite.image = handle.clone();
sprite.color = Color::WHITE;
}
}
/// Returns the single-letter suit symbol used on empty foundation markers.
///
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
pub fn suit_symbol(suit: &Suit) -> &'static str {
match suit {
Suit::Spades => "S",
Suit::Hearts => "H",
Suit::Diamonds => "D",
Suit::Clubs => "C",
}
}
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let marker_colour = PILE_MARKER_DEFAULT_COLOUR;
let marker_size = layout.card_size;
let font_size = layout.card_size.x * 0.28;
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
piles.push(KlondikePile::Stock);
for foundation in FOUNDATIONS {
piles.push(KlondikePile::Foundation(foundation));
}
for tableau in TABLEAUS {
piles.push(KlondikePile::Tableau(tableau));
}
for pile in piles {
let pos = layout.pile_positions[&pile];
let mut entity = commands.spawn((
Sprite {
color: marker_colour,
custom_size: Some(marker_size),
..default()
},
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
PileMarker(pile),
));
// Outline frame: a marginally larger rectangle sitting one z-step
// behind the fill. The fill overlaps its centre, leaving only a
// hairline border visible — a defined slot edge without an extra
// asset or 9-slice. Untagged so the `PileMarker` count is unchanged.
let outline_size = marker_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
entity.with_children(|b| {
b.spawn((
Sprite {
color: PILE_MARKER_OUTLINE_COLOUR,
custom_size: Some(outline_size),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.05),
));
});
// Tableau markers show "K" (only a King may start an empty column).
// Foundation markers show "A" (only an Ace may claim an empty slot).
// Neither label carries a suit because any suit may start any slot.
match &pile {
KlondikePile::Tableau(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
TextFont {
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
KlondikePile::Foundation(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("A"),
TextFont {
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
_ => {}
}
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>,
game: Option<Res<GameStateResource>>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
(With<TableBackground>, Without<PileMarker>),
>,
mut markers: Query<
(&PileMarker, &mut Sprite, &mut Transform, Option<&Children>),
Without<TableBackground>,
>,
mut marker_outlines: Query<
&mut Sprite,
(
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
>,
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
) {
let Some(ev) = events.read().last() else {
return;
};
let window_size = Vec2::new(ev.width, ev.height);
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
// Android surface sizes after fold/unfold are unreliable (winit does not
// forward every content-rect change), so make each relayout observable
// in logcat — this is the primary evidence channel for foldable bugs.
#[cfg(target_os = "android")]
info!(
"layout: resize to {:.0}x{:.0} (insets top={:.0} bottom={:.0}, hud={hud_visible})",
window_size.x, window_size.y, insets.top, insets.bottom
);
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
// compute_layout sizes the fan for a worst-case column; refine it to the
// current deal so a resize (incl. the Android safe-area-inset resize that
// fires in the first few frames, and fold/unfold on foldables) keeps the
// tableau filling the viewport instead of snapping back to the sparse
// worst-case fan.
if let Some(game) = game.as_ref() {
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
}
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
}
for (mut sprite, mut transform) in &mut backgrounds {
sprite.custom_size = Some(window_size * 2.0);
transform.translation.x = 0.0;
transform.translation.y = 0.0;
}
for (marker, mut sprite, mut transform, children) in &mut markers {
if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
sprite.custom_size = Some(new_layout.card_size);
transform.translation.x = pos.x;
transform.translation.y = pos.y;
// Children carry the outline frame and the "A"/"K" watermark.
// They are sized at spawn from that layout's card size, so they
// must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey
// frames on empty piles.
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
outline.custom_size = Some(outline_size);
}
if let Ok(mut label) = marker_labels.get_mut(*child) {
label.font_size = font_size;
}
}
}
}
// Card sprites are repositioned by `card_plugin::snap_cards_on_window_resize`
// running `.after(LayoutSystem::UpdateOnResize)` — that system snaps card
// transforms directly to the new layout instead of going through
// `StateChangedEvent → sync_cards → CardAnim` which would retarget the
// slide tween every frame during a corner drag (the visible "snap back
// and forth" jitter).
}
/// Bridges the asynchronous safe-area inset update into the synchronous
/// window-resize pipeline. When Android's JNI delivers the real inset values
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
/// `WindowResized` event carrying the current window size. `on_window_resized`
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
/// will snap card sprites to the corrected positions.
fn on_safe_area_changed(
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
let Some(safe_area) = safe_area else {
return;
};
if !safe_area.is_changed() {
return;
}
let Some((entity, window)) = windows.iter().next() else {
return;
};
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
// ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight
// ---------------------------------------------------------------------------
/// Gold tint applied to a `PileMarker` sprite when it is the current
/// hint destination. Same RGB as the design-system [`STATE_WARNING`]
/// token (`#ddb26f`) so the in-game "look here" colour is the same hue
/// as every other warning/attention signal in the UI. Spelled as a
/// literal because `Alpha::with_alpha` is not yet a `const` trait
/// method on stable; the tracking test below pins the RGB to
/// `STATE_WARNING` so a future palette swap can't drift the two apart.
const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(0.867, 0.698, 0.435);
/// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity
/// gold for 2 s, storing the original colour in `HintPileHighlight` so it can
/// be restored when the timer expires.
///
/// If the pile marker already has a `HintPileHighlight` from a previous hint
/// press, the timer is reset to 2 s without changing `original_color`.
fn apply_hint_pile_highlight(
mut events: MessageReader<HintVisualEvent>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
) {
for ev in events.read() {
for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() {
if pile_marker.0 != ev.dest_pile {
continue;
}
let original_color = existing.map_or(sprite.color, |h| h.original_color);
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
commands.entity(entity).insert(HintPileHighlight {
timer: 2.0,
original_color,
});
}
}
}
/// Counts down `HintPileHighlight::timer` each frame and restores the original
/// pile marker colour when the timer expires.
fn tick_hint_pile_highlights(
mut commands: Commands,
time: Res<Time>,
mut pile_markers: Query<(Entity, &mut Sprite, &mut HintPileHighlight)>,
) {
let dt = time.delta_secs();
for (entity, mut sprite, mut highlight) in pile_markers.iter_mut() {
highlight.timer -= dt;
if highlight.timer <= 0.0 {
sprite.color = highlight.original_color;
commands.entity(entity).remove::<HintPileHighlight>();
}
}
}
/// Hides pile-marker sprites for piles that have a card on top, shows them
/// for empty piles. Implements the "remain visible only where a pile is
/// empty" invariant declared in this module's top-level doc comment but
/// previously not enforced — markers always rendered, and the resulting
/// translucent rectangle bled through the rounded corners of any card sat
/// on top, producing visible "gray L" artifacts at each card corner.
///
/// Runs every Update tick guarded by `game.is_changed()` so the work is
/// skipped on idle frames. Bevy's resource change-detection sets the
/// changed flag on every state mutation (draw, move, undo, recycle, new
/// game), which covers every transition that flips a pile from
/// empty-to-occupied or vice versa.
fn sync_pile_marker_visibility(
game: Option<Res<GameStateResource>>,
mut markers: Query<(&PileMarker, &mut Visibility)>,
) {
let Some(game) = game else {
return;
};
if !game.is_changed() {
return;
}
for (pile_marker, mut visibility) in markers.iter_mut() {
let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
*visibility = if is_empty {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<(solitaire_core::Card, bool)> {
match pile {
KlondikePile::Stock => {
let stock = game.stock_cards();
if stock.is_empty() {
game.waste_cards()
} else {
stock
}
}
_ => game.pile(*pile),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use solitaire_core::{Foundation, Tableau};
/// Minimal headless app — omits windowing so pile markers are spawned with
/// the default 1280×800 layout and no camera is created.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin);
app.update();
app
}
#[test]
fn table_plugin_spawns_twelve_pile_markers() {
let mut app = headless_app();
let count = app
.world_mut()
.query::<&PileMarker>()
.iter(app.world())
.count();
assert_eq!(count, 12);
}
#[test]
fn table_plugin_spawns_one_background() {
let mut app = headless_app();
let count = app
.world_mut()
.query::<&TableBackground>()
.iter(app.world())
.count();
assert_eq!(count, 1);
}
#[test]
fn table_plugin_inserts_layout_resource() {
let app = headless_app();
assert!(app.world().get_resource::<LayoutResource>().is_some());
}
#[test]
fn every_pile_marker_has_unique_type() {
let mut app = headless_app();
let mut types: Vec<KlondikePile> = app
.world_mut()
.query::<&PileMarker>()
.iter(app.world())
.map(|m| m.0)
.collect();
types.sort_by_key(|p| format!("{p:?}"));
types.dedup();
assert_eq!(types.len(), 12);
}
#[test]
fn resize_updates_marker_outline_and_watermark_children() {
// Regression (found on Galaxy Fold 7, v0.41.0): `on_window_resized`
// resized the marker fill sprite but never its children, so after a
// fold/unfold the outline frame and "A"/"K" watermark stayed at
// spawn-time size — rendering as oversized grey slabs on empty piles.
let mut app = headless_app();
// Shrink the window well below the 1280×800 spawn default.
app.world_mut().write_message(WindowResized {
window: Entity::PLACEHOLDER,
width: 640.0,
height: 480.0,
});
app.update();
let card_size = app.world().resource::<LayoutResource>().0.card_size;
let expected_outline = card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let expected_font = card_size.x * 0.28;
let marker_children: Vec<Entity> = {
let mut q = app.world_mut().query::<(&PileMarker, &Children)>();
q.iter(app.world())
.flat_map(|(_, c)| c.iter().collect::<Vec<_>>())
.collect()
};
assert!(
!marker_children.is_empty(),
"pile markers must have outline/watermark children"
);
let mut outlines = 0;
let mut labels = 0;
for child in marker_children {
if let Some(sprite) = app.world().entity(child).get::<Sprite>() {
assert_eq!(
sprite.custom_size,
Some(expected_outline),
"outline child must track the resized card size",
);
outlines += 1;
}
if let Some(font) = app.world().entity(child).get::<TextFont>() {
assert!(
(font.font_size - expected_font).abs() < 0.01,
"watermark font must track the resized card size",
);
labels += 1;
}
}
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
assert!(
labels >= 11,
"tableau + foundation markers carry watermarks"
);
}
#[test]
fn pile_markers_hide_when_pile_is_occupied() {
// After a fresh deal: the 7 tableau piles + the stock pile are
// occupied; the 4 foundation piles are empty. The visibility-by-
// occupancy system must hide the first 8 markers and keep the
// last 4 visible. This implements
// the "remain visible only where a pile is empty" invariant
// in the module-level doc comment that was previously
// declared but not enforced — pile markers used to always
// render, and the resulting translucent rectangle bled through
// the rounded corners of any card sat on top.
let mut app = headless_app();
// headless_app() runs one tick; run another so
// sync_pile_marker_visibility has a chance to fire (it runs
// in Update, after Startup spawns the markers and the game
// state populates).
app.update();
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
let mut hidden_piles: Vec<KlondikePile> = Vec::new();
let mut visible_piles: Vec<KlondikePile> = Vec::new();
for (marker, visibility) in q.iter(app.world()) {
if matches!(visibility, Visibility::Hidden) {
hidden_piles.push(marker.0);
} else {
visible_piles.push(marker.0);
}
}
// 8 occupied piles after a fresh deal: stock + 7 tableau.
assert_eq!(
hidden_piles.len(),
8,
"stock + 7 tableau piles should hide their markers post-deal",
);
assert!(hidden_piles.contains(&KlondikePile::Stock));
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert!(
hidden_piles.contains(&KlondikePile::Tableau(tableau)),
"{tableau:?} marker should be hidden — it has cards",
);
}
// 4 empty piles: foundations only.
assert_eq!(visible_piles.len(), 4);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
}
}
// -----------------------------------------------------------------------
// Pure-function tests (no Bevy app required)
// -----------------------------------------------------------------------
#[test]
fn all_three_themes_produce_distinct_colours() {
let green = theme_colour(&Theme::Green);
let blue = theme_colour(&Theme::Blue);
let dark = theme_colour(&Theme::Dark);
assert_ne!(green, blue, "Green and Blue must differ");
assert_ne!(green, dark, "Green and Dark must differ");
assert_ne!(blue, dark, "Blue and Dark must differ");
}
#[test]
fn effective_background_index_0_matches_theme_colour() {
for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
let expected = theme_colour(&theme);
let actual = effective_background_colour(&theme, 0);
assert_eq!(
expected, actual,
"index 0 must always return the theme colour for {:?}",
theme
);
}
}
#[test]
fn effective_background_indices_1_through_3_are_distinct_from_theme() {
// Non-zero indices override the theme with a fixed colour.
let theme_green = theme_colour(&Theme::Green);
for idx in 1..=3 {
let eff = effective_background_colour(&Theme::Green, idx);
assert_ne!(
eff, theme_green,
"index {idx} must override the theme colour"
);
}
}
#[test]
fn effective_background_index_4_falls_through_to_charcoal() {
// All indices ≥ 4 share the same charcoal fallback.
let c4 = effective_background_colour(&Theme::Green, 4);
let c5 = effective_background_colour(&Theme::Green, 5);
let c99 = effective_background_colour(&Theme::Green, 99);
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
}
// -----------------------------------------------------------------------
// suit_symbol pure-function tests (Task #35)
// -----------------------------------------------------------------------
#[test]
fn suit_symbol_returns_correct_letters() {
assert_eq!(suit_symbol(&Suit::Spades), "S");
assert_eq!(suit_symbol(&Suit::Hearts), "H");
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
assert_eq!(suit_symbol(&Suit::Clubs), "C");
}
// -----------------------------------------------------------------------
// Task #6 — HintPileHighlight timer and colour pure-function tests
// -----------------------------------------------------------------------
/// The HINT_PILE_HIGHLIGHT_COLOUR constant must be visibly distinct from the
/// default pile marker colour so the player can see which pile is highlighted.
#[test]
fn hint_pile_highlight_colour_is_distinct_from_default() {
assert_ne!(
HINT_PILE_HIGHLIGHT_COLOUR, PILE_MARKER_DEFAULT_COLOUR,
"HINT_PILE_HIGHLIGHT_COLOUR must differ from the default pile marker colour"
);
}
/// `HINT_PILE_HIGHLIGHT_COLOUR` is spelled as a literal because
/// `Alpha::with_alpha` is not a `const` trait method on stable.
/// This test pins its RGB to the design-system `STATE_WARNING`
/// token so a future palette swap that updates the token but
/// forgets the hint highlight fails loudly here.
#[test]
fn hint_pile_highlight_rgb_tracks_state_warning_token() {
use crate::ui_theme::STATE_WARNING;
let hint = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
let warning = STATE_WARNING.to_srgba();
assert!((hint.red - warning.red).abs() < 1e-6);
assert!((hint.green - warning.green).abs() < 1e-6);
assert!((hint.blue - warning.blue).abs() < 1e-6);
}
/// A freshly-created HintPileHighlight has a positive timer countdown.
#[test]
fn hint_pile_highlight_timer_starts_positive() {
let h = HintPileHighlight {
timer: 2.0,
original_color: PILE_MARKER_DEFAULT_COLOUR,
};
assert!(
h.timer > 0.0,
"HintPileHighlight timer must start positive, got {}",
h.timer
);
}
/// Ticking the timer past its initial value results in a non-positive (expired)
/// countdown.
#[test]
fn hint_pile_highlight_timer_expires_after_full_duration() {
let mut remaining = 2.0_f32;
remaining -= 2.5; // 2.5 s elapsed on a 2.0 s timer
assert!(
remaining <= 0.0,
"timer must be expired after ticking past its initial value, got {}",
remaining
);
}
/// `original_color` is preserved through the highlight lifecycle so colour
/// can be correctly restored on expiry.
#[test]
fn hint_pile_highlight_preserves_original_color() {
let original = Color::srgb(0.1, 0.3, 0.5);
let h = HintPileHighlight {
timer: 2.0,
original_color: original,
};
assert_eq!(
h.original_color, original,
"original_color must be stored without modification"
);
}
/// The hint colour must read as "gold-ish" — red dominant, green
/// close behind, blue noticeably lower — so a player intuitively
/// associates the highlight with attention/warning. Bounds are
/// loose enough to accommodate the Terminal palette's muted gold
/// (`STATE_WARNING`, `#ddb26f`) while still rejecting a stray
/// red, green, or neutral grey if someone refactors badly.
/// Exact-RGB tracking lives in
/// `hint_pile_highlight_rgb_tracks_state_warning_token`.
#[test]
fn hint_pile_highlight_colour_is_gold() {
let Srgba {
red, green, blue, ..
} = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(
red >= 0.7,
"gold hint colour must have red ≥ 0.7, got {red}"
);
assert!(
green >= 0.5,
"gold hint colour must have green ≥ 0.5, got {green}"
);
assert!(
blue <= 0.6,
"gold hint colour must have blue ≤ 0.6, got {blue}"
);
assert!(
red > blue,
"gold hint colour must be warmer than cool, got r={red} b={blue}"
);
assert!(
green > blue,
"gold hint colour must be warmer than cool, got g={green} b={blue}"
);
}
#[test]
fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
}
}