4b9d008be2
Conservative cleanup pass — applied only the high-signal pedantic lints whose fixes either remove genuine waste or read more naturally, skipping anything stylistic that would bloat the diff. - map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to .map_or / .is_some_and / .map_or_else equivalents - uninlined_format_args: 7 production format!/write!/println! sites rewritten to the inline-argument style; assert! sites in test code intentionally untouched - match_same_arms: 2 redundant arms collapsed where the bodies were identical and the merger didn't obscure intent Public API is unchanged. No dependencies added or removed. The pedantic warning count dropped from 840 to 807 (-33). Out-of-scope findings — needless_pass_by_value on Bevy Res params, false-positive explicit_iter_loop on Bevy Query iterators, items_after_statements inside test mods, and the "ask before changing" merge logic in solitaire_sync — were intentionally deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
552 lines
21 KiB
Rust
552 lines
21 KiB
Rust
//! Card feedback animations: shake on invalid move, settle on valid placement,
|
|
//! and animated deal on new game start.
|
|
//!
|
|
//! # Task #54 — Shake animation on invalid move target
|
|
//!
|
|
//! When `MoveRejectedEvent` fires, a `ShakeAnim` component is inserted on every
|
|
//! card entity that belongs to the destination pile (`MoveRejectedEvent::to`).
|
|
//! The component stores the card's original X position and an elapsed counter.
|
|
//! Each frame, `tick_shake_anim` displaces `transform.translation.x` with a
|
|
//! damped sine wave and removes the component after 0.3 s.
|
|
//!
|
|
//! # Task #55 — Settle/bounce on valid placement
|
|
//!
|
|
//! `start_settle_anim` listens for `MoveRequestEvent` and `DrawRequestEvent` so
|
|
//! the bounce is **scoped to the cards that just moved**, not every top card on
|
|
//! the board. For a move it bounces the top `count` cards of the destination
|
|
//! pile; for a draw it bounces the top card of the waste. Undos are skipped so
|
|
//! reverting a move doesn't replay the placement feedback. `tick_settle_anim`
|
|
//! applies a brief Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s)
|
|
//! and removes the component when elapsed ≥ 0.15 s.
|
|
//!
|
|
//! # Task #69 — Animated card deal on new game start
|
|
//!
|
|
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
|
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
|
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
|
//! pile's position to its current (final) position with a per-card stagger
|
|
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
|
//! jitter per card so the deal feels organic instead of mechanical:
|
|
//!
|
|
//! | `AnimSpeed` | Base stagger |
|
|
//! |---------------|-------------------|
|
|
//! | `Normal` | 0.04 s (default) |
|
|
//! | `Fast` | 0.02 s (half) |
|
|
//! | `Instant` | 0.00 s (no delay) |
|
|
//!
|
|
//! `deal_stagger_delay` and `deal_stagger_jitter` are pure helpers exposed for
|
|
//! unit testing.
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::f32::consts::PI;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
use bevy::prelude::*;
|
|
use solitaire_core::pile::PileType;
|
|
use solitaire_data::AnimSpeed;
|
|
|
|
use crate::animation_plugin::CardAnim;
|
|
use crate::card_plugin::CardEntity;
|
|
use crate::events::{
|
|
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
|
};
|
|
use crate::game_plugin::GameMutation;
|
|
use crate::layout::LayoutResource;
|
|
use crate::pause_plugin::PausedResource;
|
|
use crate::resources::GameStateResource;
|
|
use crate::settings_plugin::SettingsResource;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Duration of the shake animation in seconds.
|
|
const SHAKE_SECS: f32 = 0.3;
|
|
/// Angular frequency (radians/s) of the shake sine wave.
|
|
const SHAKE_OMEGA: f32 = 40.0;
|
|
/// Peak displacement of the shake in world units.
|
|
const SHAKE_AMPLITUDE: f32 = 6.0;
|
|
|
|
/// Duration of the settle animation in seconds.
|
|
const SETTLE_SECS: f32 = 0.15;
|
|
/// Maximum Y-scale compression at the midpoint of the settle animation.
|
|
const SETTLE_MIN_SCALE: f32 = 0.92;
|
|
|
|
/// Per-card stagger delay for the deal animation in seconds.
|
|
pub const DEAL_STAGGER_SECS: f32 = 0.04;
|
|
/// Duration of each card's slide during the deal animation in seconds.
|
|
pub const DEAL_SLIDE_SECS: f32 = 0.25;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #54 — Shake animation component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Drives a horizontal shake animation.
|
|
///
|
|
/// Inserted on card entities belonging to the destination pile of a rejected
|
|
/// move. Removed automatically when `elapsed >= SHAKE_SECS`.
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct ShakeAnim {
|
|
/// Seconds elapsed since the shake began.
|
|
pub elapsed: f32,
|
|
/// The card's original X position (restored when the component is removed).
|
|
pub origin_x: f32,
|
|
}
|
|
|
|
/// Computes the horizontal displacement of the shake animation at the given
|
|
/// elapsed time.
|
|
///
|
|
/// Returns `origin_x + sin(elapsed * SHAKE_OMEGA) * SHAKE_AMPLITUDE *
|
|
/// (1.0 - elapsed / SHAKE_SECS)`. At `elapsed == 0.0` the sin term is 0, so
|
|
/// the displacement is 0. At `elapsed == SHAKE_SECS` the envelope is 0, so the
|
|
/// displacement is also 0.
|
|
///
|
|
/// This is a pure function exposed for unit testing without Bevy.
|
|
pub fn shake_offset(elapsed: f32, origin_x: f32) -> f32 {
|
|
let envelope = 1.0 - (elapsed / SHAKE_SECS).min(1.0);
|
|
origin_x + (elapsed * SHAKE_OMEGA).sin() * SHAKE_AMPLITUDE * envelope
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #55 — Settle animation component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Drives a brief Y-scale compression (bounce) animation.
|
|
///
|
|
/// Inserted on the top card entity of every non-empty pile after a successful
|
|
/// move (`StateChangedEvent`). Removed automatically when `elapsed >= SETTLE_SECS`.
|
|
#[derive(Component, Debug, Clone, Default)]
|
|
pub struct SettleAnim {
|
|
/// Seconds elapsed since the settle animation began.
|
|
pub elapsed: f32,
|
|
}
|
|
|
|
/// Computes the Y scale of the settle animation at the given elapsed time.
|
|
///
|
|
/// At `elapsed == 0.0` the scale is 1.0 (no compression). At the midpoint
|
|
/// (`elapsed == SETTLE_SECS / 2`) the scale reaches its minimum (`SETTLE_MIN_SCALE ≈ 0.92`).
|
|
/// At `elapsed == SETTLE_SECS` the scale returns to 1.0.
|
|
///
|
|
/// This is a pure function exposed for unit testing without Bevy.
|
|
pub fn settle_scale(elapsed: f32) -> f32 {
|
|
let t = (elapsed / SETTLE_SECS).min(1.0);
|
|
1.0 - (1.0 - SETTLE_MIN_SCALE) * (t * PI).sin()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #69 — Stagger delay helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Returns the per-card stagger delay in seconds for the given `AnimSpeed`.
|
|
///
|
|
/// | `AnimSpeed` | Returned value |
|
|
/// |---------------|----------------|
|
|
/// | `Normal` | `DEAL_STAGGER_SECS` (0.04 s) |
|
|
/// | `Fast` | `DEAL_STAGGER_SECS / 2` (0.02 s) |
|
|
/// | `Instant` | `0.0` — all cards appear simultaneously |
|
|
///
|
|
/// This is a pure function exposed for unit testing without Bevy.
|
|
pub fn deal_stagger_secs_for_speed(speed: &AnimSpeed) -> f32 {
|
|
match speed {
|
|
AnimSpeed::Normal => DEAL_STAGGER_SECS,
|
|
AnimSpeed::Fast => DEAL_STAGGER_SECS / 2.0,
|
|
AnimSpeed::Instant => 0.0,
|
|
}
|
|
}
|
|
|
|
/// Returns the stagger delay in seconds for card at position `index` during the
|
|
/// deal animation, given a per-card stagger interval.
|
|
///
|
|
/// `delay = index * stagger_secs`
|
|
///
|
|
/// This is a pure function exposed for unit testing without Bevy.
|
|
pub fn deal_stagger_delay(index: usize, stagger_secs: f32) -> f32 {
|
|
index as f32 * stagger_secs
|
|
}
|
|
|
|
/// Returns a deterministic ±10 % jitter factor for `card_id`.
|
|
///
|
|
/// Hashes `card_id` with `DefaultHasher` and maps the low bits into a value in
|
|
/// `0.0..=1.0`, then re-centres into `-0.1..=0.1`. The same card id always
|
|
/// produces the same factor so deals are reproducible (important for
|
|
/// seed-based testing and replay), while a 52-card deal still feels organic
|
|
/// because each card's offset varies.
|
|
///
|
|
/// Multiply a base stagger interval by `1.0 + deal_stagger_jitter(card_id)` to
|
|
/// apply the jitter.
|
|
pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|
let mut hasher = DefaultHasher::new();
|
|
card_id.hash(&mut hasher);
|
|
let jitter_norm = (hasher.finish() % 1000) as f32 / 1000.0; // 0.0..=1.0
|
|
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Registers the shake, settle, and deal animation systems.
|
|
pub struct FeedbackAnimPlugin;
|
|
|
|
impl Plugin for FeedbackAnimPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
// Register the events this plugin consumes so it can run in isolation
|
|
// under `MinimalPlugins` (e.g. unit tests) without depending on other
|
|
// plugins to register them. Double-registration is idempotent in Bevy.
|
|
app.add_message::<MoveRequestEvent>()
|
|
.add_message::<DrawRequestEvent>()
|
|
.add_message::<MoveRejectedEvent>()
|
|
.add_message::<NewGameRequestEvent>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
start_shake_anim.after(GameMutation),
|
|
tick_shake_anim,
|
|
start_settle_anim.after(GameMutation),
|
|
tick_settle_anim,
|
|
start_deal_anim.after(GameMutation),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #54 — Shake systems
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Inserts `ShakeAnim` on all card entities belonging to the destination pile
|
|
/// when a `MoveRejectedEvent` fires.
|
|
fn start_shake_anim(
|
|
mut events: MessageReader<MoveRejectedEvent>,
|
|
game: Res<GameStateResource>,
|
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
|
mut commands: Commands,
|
|
) {
|
|
for ev in events.read() {
|
|
let dest_pile = &ev.to;
|
|
// Collect the card ids that belong to the destination pile.
|
|
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
|
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
|
|
|
|
if dest_card_ids.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
for (entity, card_marker, transform) in card_entities.iter() {
|
|
if dest_card_ids.contains(&card_marker.card_id) {
|
|
commands.entity(entity).insert(ShakeAnim {
|
|
elapsed: 0.0,
|
|
origin_x: transform.translation.x,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Advances `ShakeAnim` each frame and removes it once the animation completes.
|
|
///
|
|
/// Applies `translation.x = shake_offset(elapsed, origin_x)`. When done,
|
|
/// restores `translation.x = origin_x` so the card is left at its correct
|
|
/// position. Skipped while the game is paused.
|
|
fn tick_shake_anim(
|
|
mut commands: Commands,
|
|
time: Res<Time>,
|
|
paused: Option<Res<PausedResource>>,
|
|
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
|
) {
|
|
if paused.is_some_and(|p| p.0) {
|
|
return;
|
|
}
|
|
let dt = time.delta_secs();
|
|
for (entity, mut transform, mut anim) in &mut anims {
|
|
anim.elapsed += dt;
|
|
if anim.elapsed >= SHAKE_SECS {
|
|
transform.translation.x = anim.origin_x;
|
|
commands.entity(entity).remove::<ShakeAnim>();
|
|
} else {
|
|
transform.translation.x = shake_offset(anim.elapsed, anim.origin_x);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #55 — Settle systems
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Inserts `SettleAnim` only on the cards that just moved — the top `count`
|
|
/// cards of the move destination, or the top of the waste pile for a draw.
|
|
///
|
|
/// Triggered by `MoveRequestEvent` and `DrawRequestEvent`. Undo and other
|
|
/// state-mutations are deliberately skipped: replaying the placement bounce on
|
|
/// an undo would feel like the rejected-move shake fired by mistake. Note this
|
|
/// runs before the move resolves in `GameMutation`, so we read the destination
|
|
/// pile **after** the request has been accepted by reading the up-to-date game
|
|
/// state for both readers — the schedule labels the system `.after(GameMutation)`
|
|
/// to ensure that ordering.
|
|
fn start_settle_anim(
|
|
mut moves: MessageReader<MoveRequestEvent>,
|
|
mut draws: MessageReader<DrawRequestEvent>,
|
|
game: Res<GameStateResource>,
|
|
card_entities: Query<(Entity, &CardEntity)>,
|
|
mut commands: Commands,
|
|
) {
|
|
// Build the list of card ids that should bounce this frame from every
|
|
// queued request; multiple events can fire in the same frame (e.g. a move
|
|
// followed by a draw via keyboard accelerators).
|
|
let mut bounce_ids: Vec<u32> = Vec::new();
|
|
|
|
for ev in moves.read() {
|
|
if let Some(pile) = game.0.piles.get(&ev.to) {
|
|
// The moved cards land on top — take the last `count` ids.
|
|
let n = ev.count.min(pile.cards.len());
|
|
if n > 0 {
|
|
let start = pile.cards.len() - n;
|
|
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
if draws.read().next().is_some()
|
|
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
|
&& let Some(top) = pile.cards.last()
|
|
{
|
|
bounce_ids.push(top.id);
|
|
}
|
|
|
|
if bounce_ids.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for (entity, card_marker) in card_entities.iter() {
|
|
if bounce_ids.contains(&card_marker.card_id) {
|
|
commands.entity(entity).insert(SettleAnim::default());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Advances `SettleAnim` each frame and removes it once the animation completes.
|
|
///
|
|
/// Applies `transform.scale.y = settle_scale(elapsed)`. Restores scale to 1.0
|
|
/// when done. Skipped while the game is paused.
|
|
fn tick_settle_anim(
|
|
mut commands: Commands,
|
|
time: Res<Time>,
|
|
paused: Option<Res<PausedResource>>,
|
|
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
|
) {
|
|
if paused.is_some_and(|p| p.0) {
|
|
return;
|
|
}
|
|
let dt = time.delta_secs();
|
|
for (entity, mut transform, mut anim) in &mut anims {
|
|
anim.elapsed += dt;
|
|
if anim.elapsed >= SETTLE_SECS {
|
|
transform.scale.y = 1.0;
|
|
commands.entity(entity).remove::<SettleAnim>();
|
|
} else {
|
|
transform.scale.y = settle_scale(anim.elapsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #69 — Deal animation system
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Inserts `CardAnim` on every card entity when a new game starts, sliding
|
|
/// each card from the stock pile position to its final position with a
|
|
/// per-card stagger derived from the current `AnimSpeed` setting.
|
|
///
|
|
/// Triggered by `NewGameRequestEvent` (when the new game has `move_count == 0`)
|
|
/// and fires the deal animation for every card entity currently in the world.
|
|
/// The stagger is looked up from `SettingsResource` via `deal_stagger_secs_for_speed`.
|
|
fn start_deal_anim(
|
|
mut events: MessageReader<NewGameRequestEvent>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
game: Res<GameStateResource>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
|
mut commands: Commands,
|
|
) {
|
|
if events.read().next().is_none() {
|
|
return;
|
|
}
|
|
// Only animate a fresh deal (no moves made yet).
|
|
if game.0.move_count != 0 {
|
|
return;
|
|
}
|
|
let Some(layout) = layout else { return };
|
|
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { return };
|
|
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
|
|
|
let speed = settings.as_ref().map(|s| &s.0.animation_speed);
|
|
let stagger_secs = speed.map_or(DEAL_STAGGER_SECS, deal_stagger_secs_for_speed);
|
|
|
|
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
|
let final_pos = transform.translation;
|
|
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
|
// without losing reproducibility (a given seed still produces the
|
|
// same per-card stagger pattern across runs).
|
|
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
|
commands.entity(entity).insert((
|
|
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
|
CardAnim {
|
|
start: stock_start.with_z(final_pos.z),
|
|
target: final_pos,
|
|
elapsed: 0.0,
|
|
duration: DEAL_SLIDE_SECS,
|
|
delay: deal_stagger_delay(index, per_card_stagger),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit tests (pure functions only — no Bevy world required)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Task #54 tests
|
|
|
|
#[test]
|
|
fn shake_offset_at_elapsed_zero_returns_origin_x() {
|
|
// sin(0) == 0, so displacement must equal origin_x regardless of
|
|
// SHAKE_AMPLITUDE or envelope.
|
|
let origin_x = 42.0;
|
|
let result = shake_offset(0.0, origin_x);
|
|
assert!(
|
|
(result - origin_x).abs() < 1e-5,
|
|
"shake_offset at elapsed=0 must equal origin_x, got {result}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shake_offset_at_elapsed_shake_secs_returns_origin_x() {
|
|
// At elapsed == SHAKE_SECS the envelope is 0, so the result must equal
|
|
// origin_x regardless of the sine value.
|
|
let origin_x = 100.0;
|
|
let result = shake_offset(SHAKE_SECS, origin_x);
|
|
assert!(
|
|
(result - origin_x).abs() < 1e-5,
|
|
"shake_offset at elapsed=SHAKE_SECS must equal origin_x (envelope=0), got {result}"
|
|
);
|
|
}
|
|
|
|
// Task #55 tests
|
|
|
|
#[test]
|
|
fn settle_scale_at_elapsed_zero_is_one() {
|
|
let scale = settle_scale(0.0);
|
|
assert!(
|
|
(scale - 1.0).abs() < 1e-5,
|
|
"settle_scale at elapsed=0 must be 1.0, got {scale}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settle_scale_at_midpoint_is_approximately_settle_min() {
|
|
// At elapsed == SETTLE_SECS / 2, sin(PI/2) == 1.0, so scale should be
|
|
// at the minimum: 1.0 - (1.0 - SETTLE_MIN_SCALE) = SETTLE_MIN_SCALE.
|
|
let scale = settle_scale(SETTLE_SECS / 2.0);
|
|
assert!(
|
|
(scale - SETTLE_MIN_SCALE).abs() < 1e-4,
|
|
"settle_scale at midpoint must be ~{SETTLE_MIN_SCALE}, got {scale}"
|
|
);
|
|
}
|
|
|
|
// Task #69 tests
|
|
|
|
#[test]
|
|
fn deal_stagger_delay_zero_index_is_zero() {
|
|
assert_eq!(deal_stagger_delay(0, DEAL_STAGGER_SECS), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_delay_returns_index_times_stagger() {
|
|
let stagger = DEAL_STAGGER_SECS;
|
|
for i in 0..52 {
|
|
let expected = i as f32 * stagger;
|
|
let actual = deal_stagger_delay(i, stagger);
|
|
assert!(
|
|
(actual - expected).abs() < 1e-6,
|
|
"deal_stagger_delay({i}, {stagger}) expected {expected}, got {actual}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_secs_normal_is_constant() {
|
|
assert!((deal_stagger_secs_for_speed(&AnimSpeed::Normal) - DEAL_STAGGER_SECS).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_secs_fast_is_half_normal() {
|
|
let fast = deal_stagger_secs_for_speed(&AnimSpeed::Fast);
|
|
let normal = deal_stagger_secs_for_speed(&AnimSpeed::Normal);
|
|
assert!(
|
|
(fast - normal / 2.0).abs() < 1e-6,
|
|
"Fast stagger must be half of Normal, got fast={fast} normal={normal}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_secs_instant_is_zero() {
|
|
assert_eq!(deal_stagger_secs_for_speed(&AnimSpeed::Instant), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_delay_instant_is_always_zero() {
|
|
let stagger = deal_stagger_secs_for_speed(&AnimSpeed::Instant);
|
|
for i in 0..52 {
|
|
assert_eq!(
|
|
deal_stagger_delay(i, stagger),
|
|
0.0,
|
|
"Instant speed must produce zero delay for index {i}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 9 — deal stagger jitter helper
|
|
|
|
#[test]
|
|
fn deal_stagger_jitter_is_within_ten_percent() {
|
|
// Every card id in 0..256 must produce a jitter factor in ±10 %.
|
|
for card_id in 0u32..256 {
|
|
let j = deal_stagger_jitter(card_id);
|
|
assert!(
|
|
(-0.1..=0.1).contains(&j),
|
|
"deal_stagger_jitter({card_id}) = {j} is outside ±10 %"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_jitter_is_deterministic() {
|
|
// Same card id must always produce the same jitter factor.
|
|
for card_id in [0u32, 7, 51, 999_999] {
|
|
assert!(
|
|
(deal_stagger_jitter(card_id) - deal_stagger_jitter(card_id)).abs() < 1e-9,
|
|
"deal_stagger_jitter({card_id}) is not deterministic"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
|
// 52 cards should produce more than a couple distinct jitter factors;
|
|
// a constant function would return one value for all ids.
|
|
use std::collections::HashSet;
|
|
let unique: HashSet<u64> = (0u32..52)
|
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
|
.collect();
|
|
assert!(
|
|
unique.len() > 10,
|
|
"expected > 10 distinct jitter factors for 52 cards, got {}",
|
|
unique.len()
|
|
);
|
|
}
|
|
}
|