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:
@@ -32,6 +32,11 @@ pub struct AchievementContext {
|
||||
|
||||
/// Local hour (0–23) at the time of win. `None` if unknown.
|
||||
pub wall_clock_hour: Option<u32>,
|
||||
|
||||
/// Number of times waste was recycled back to stock during the won game.
|
||||
pub last_win_recycle_count: u32,
|
||||
/// `true` if the game was played in Zen mode.
|
||||
pub last_win_is_zen: bool,
|
||||
}
|
||||
|
||||
/// Reward granted when an achievement is first unlocked.
|
||||
@@ -118,6 +123,15 @@ fn speed_and_skill(c: &AchievementContext) -> bool {
|
||||
fn daily_devotee(c: &AchievementContext) -> bool {
|
||||
c.daily_challenge_streak >= 7
|
||||
}
|
||||
fn perfectionist(c: &AchievementContext) -> bool {
|
||||
!c.last_win_used_undo && c.last_win_score >= 5_000
|
||||
}
|
||||
fn comeback(c: &AchievementContext) -> bool {
|
||||
c.last_win_recycle_count >= 3
|
||||
}
|
||||
fn zen_winner(c: &AchievementContext) -> bool {
|
||||
c.last_win_is_zen
|
||||
}
|
||||
|
||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||
/// remain readable across versions (new achievements append).
|
||||
@@ -242,6 +256,30 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
||||
reward: Some(Reward::Background(3)),
|
||||
condition: daily_devotee,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "perfectionist",
|
||||
name: "Perfectionist",
|
||||
description: "Win without undo and score at least 5,000",
|
||||
secret: false,
|
||||
reward: Some(Reward::Badge),
|
||||
condition: perfectionist,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "comeback",
|
||||
name: "???",
|
||||
description: "A secret achievement",
|
||||
secret: true,
|
||||
reward: Some(Reward::Background(4)),
|
||||
condition: comeback,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "zen_winner",
|
||||
name: "???",
|
||||
description: "A secret achievement",
|
||||
secret: true,
|
||||
reward: Some(Reward::Badge),
|
||||
condition: zen_winner,
|
||||
},
|
||||
];
|
||||
|
||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||
@@ -274,6 +312,8 @@ mod tests {
|
||||
last_win_time_seconds: u64::MAX,
|
||||
last_win_used_undo: true,
|
||||
wall_clock_hour: None,
|
||||
last_win_recycle_count: 0,
|
||||
last_win_is_zen: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +407,48 @@ mod tests {
|
||||
assert!(ids.contains(&"daily_devotee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_requires_no_undo_and_high_score() {
|
||||
let mut c = ctx();
|
||||
c.last_win_used_undo = false;
|
||||
c.last_win_score = 5_000;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"perfectionist"));
|
||||
|
||||
c.last_win_used_undo = true;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"perfectionist"));
|
||||
|
||||
c.last_win_used_undo = false;
|
||||
c.last_win_score = 4_999;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"perfectionist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comeback_requires_at_least_three_recycles() {
|
||||
let mut c = ctx();
|
||||
c.last_win_recycle_count = 2;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"comeback"));
|
||||
|
||||
c.last_win_recycle_count = 3;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"comeback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_winner_requires_zen_mode() {
|
||||
let mut c = ctx();
|
||||
c.last_win_is_zen = false;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"zen_winner"));
|
||||
|
||||
c.last_win_is_zen = true;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"zen_winner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
|
||||
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user