feat(engine): wire AnimSpeed to animation, new achievements, leaderboard opt-in, daily goal display

- AnimSpeed setting now drives card slide duration (Normal=0.15s, Fast=0.07s, Instant=snap);
  EffectiveSlideDuration resource updated on SettingsChangedEvent; AnimSpeed row added to Settings panel
- GameState.recycle_count tracks waste recycles; perfectionist/comeback/zen_winner achievements added
  with full unit tests
- SyncProvider gains opt_in_leaderboard(); SolitaireServerClient implements POST /api/leaderboard/opt-in;
  Opt In button added to leaderboard panel
- DailyChallengeResource stores goal_description/target_score/max_time_secs from server;
  pressing C shows goal description as toast (DailyGoalAnnouncementEvent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 01:38:25 +00:00
parent bd48813900
commit f579b96d76
10 changed files with 453 additions and 19 deletions
+24
View File
@@ -80,6 +80,10 @@ pub struct GameState {
/// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`.
pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game.
/// Used by the `comeback` achievement condition.
#[serde(default)]
pub recycle_count: u32,
undo_stack: VecDeque<StateSnapshot>,
}
@@ -116,6 +120,7 @@ impl GameState {
is_won: false,
is_auto_completable: false,
undo_count: 0,
recycle_count: 0,
undo_stack: VecDeque::new(),
}
}
@@ -167,6 +172,7 @@ impl GameState {
card.face_up = false;
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
return Ok(());
}
@@ -481,6 +487,24 @@ mod tests {
assert!(g.piles[&PileType::Waste].cards.is_empty());
}
#[test]
fn recycle_count_increments_on_each_waste_recycle() {
let mut g = new_game();
assert_eq!(g.recycle_count, 0);
// Drain entire stock to waste.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // first recycle
assert_eq!(g.recycle_count, 1);
// Drain again and recycle a second time.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // second recycle
assert_eq!(g.recycle_count, 2);
}
#[test]
fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are