Files
Ferrous-Solitaire/solitaire_engine/src/table_plugin.rs
T
funman300 4d48cad4e3 fix(engine): hide pile markers under cards — kill the gray-corner artifact
Player feedback after the border-drop fix did NOT close the
"gray corners" complaint: "I do not see anything change." The
border was a real artifact, but the *visible* gray came from a
different source.

Root cause: pile markers are 8%-alpha-white sprites sized to
the card area, sitting at `Z_PILE_MARKER = -1.0` beneath every
card. Composited against the dark play surface, the marker's
effective colour is ≈`#272727` — visibly gray. When a card
(rounded corners, opaque body) sits on top, the marker's
rectangular fill bleeds through the 4 small triangular regions
where the card's rounded corner curves cut away from the card's
bounding rectangle. That bleed-through is the "gray L" the
player saw at each card corner.

Fix: hide pile-marker sprites for any pile that has a card on
top. New `sync_pile_marker_visibility` system runs each Update
tick, guarded by `game.is_changed()` so the work skips on idle
frames. Iterates `(&PileMarker, &mut Visibility)` and sets
`Hidden` for occupied piles, `Inherited` for empty.

This implements the *documented* invariant declared in the
module-level doc comment ("Pile markers ... remain visible only
where a pile is empty") that was previously not enforced —
markers always rendered. Strictly speaking this is a
documentation-vs-implementation drift fix, not a behaviour
change.

### Why the border-drop fix didn't address this

The border drop changed the SVG stroke and removed *one* source
of corner artifacts (anti-aliased red/near-white stroke fading
through gray). It correctly drifted 52 face hashes. But the
visible gray at corners came from a *different* layer — the
pile-marker sprite *behind* the card, not the card stroke
itself. Right test target, wrong visible-artifact target.
Two layers, two fixes; this commit closes the second.

### Test

New `pile_markers_hide_when_pile_is_occupied` pins the
post-deal state: 8 markers hidden (stock + 7 tableau), 5
markers visible (waste + 4 foundations). 1192 passing
(+1 from prior 1191).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:49:13 -07:00

680 lines
26 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::card::Suit;
use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::resources::GameStateResource;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
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 8% alpha — soft enough that the marker reads as a "hint of a
/// slot" rather than a panel, but visible against every felt
/// background.
///
/// 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.08);
/// 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 PileType);
/// 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;
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_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility,
),
);
}
}
/// 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>>,
) {
// Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests).
if existing_camera.is_empty() {
commands.spawn(Camera2d);
}
let window_size = windows
.iter()
.next()
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
let layout = compute_layout(window_size);
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<PileType> = Vec::with_capacity(13);
piles.push(PileType::Stock);
piles.push(PileType::Waste);
for slot in 0..4_u8 {
piles.push(PileType::Foundation(slot));
}
for i in 0..7 {
piles.push(PileType::Tableau(i));
}
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.clone()),
));
// 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 {
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),
));
});
}
}
}
#[allow(clippy::type_complexity)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
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), Without<TableBackground>>,
) {
let Some(ev) = events.read().last() else {
return;
};
let window_size = Vec2::new(ev.width, ev.height);
let new_layout = compute_layout(window_size);
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) 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;
}
}
// 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).
}
// ---------------------------------------------------------------------------
// 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 = game
.0
.piles
.get(&pile_marker.0)
.is_none_or(|pile| pile.cards.is_empty());
*visibility = if is_empty {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
/// 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_thirteen_pile_markers() {
let mut app = headless_app();
let count = app
.world_mut()
.query::<&PileMarker>()
.iter(app.world())
.count();
assert_eq!(count, 13);
}
#[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<PileType> = app
.world_mut()
.query::<&PileMarker>()
.iter(app.world())
.map(|m| m.0.clone())
.collect();
types.sort_by_key(|p| format!("{p:?}"));
types.dedup();
assert_eq!(types.len(), 13);
}
#[test]
fn pile_markers_hide_when_pile_is_occupied() {
// After a fresh deal: the 7 tableau piles + the stock pile are
// all occupied; the 4 foundation piles + the waste pile are
// empty. The visibility-by-occupancy system must hide the
// first 8 markers and keep the last 5 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<PileType> = Vec::new();
let mut visible_piles: Vec<PileType> = Vec::new();
for (marker, visibility) in q.iter(app.world()) {
if matches!(visibility, Visibility::Hidden) {
hidden_piles.push(marker.0.clone());
} else {
visible_piles.push(marker.0.clone());
}
}
// 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(&PileType::Stock));
for i in 0..7 {
assert!(
hidden_piles.contains(&PileType::Tableau(i)),
"tableau {i} marker should be hidden — it has cards",
);
}
// 5 empty piles: waste + 4 foundations.
assert_eq!(visible_piles.len(), 5);
assert!(visible_piles.contains(&PileType::Waste));
for i in 0..4_u8 {
assert!(visible_piles.contains(&PileType::Foundation(i)));
}
}
// -----------------------------------------------------------------------
// 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::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
.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");
}
}