fix+refactor+docs: P0–P3 todo list items

P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 22:02:52 +00:00
parent 71c0c273a1
commit ffc79447d4
32 changed files with 1824 additions and 244 deletions
+115 -3
View File
@@ -12,20 +12,25 @@
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)]
pub struct AchievementContext {
// Stats (after this win has been recorded).
/// Total number of games played (after this win has been recorded).
pub games_played: u32,
/// Total number of games won (after this win has been recorded).
pub games_won: u32,
/// Current consecutive win streak (after this win has been recorded).
pub win_streak_current: u32,
/// Highest single-game score ever achieved.
pub best_single_score: u32,
/// Cumulative score across all games ever played.
pub lifetime_score: u64,
/// Total wins completed in Draw 3 mode.
pub draw_three_wins: u32,
// Progression.
/// Current daily-challenge completion streak (consecutive days).
pub daily_challenge_streak: u32,
// Last-win facts (GameWonEvent + GameState at win time).
/// Score achieved in the just-won game.
pub last_win_score: i32,
/// Elapsed seconds for the just-won game.
pub last_win_time_seconds: u64,
/// `true` if `undo()` was called at least once during the won game.
pub last_win_used_undo: bool,
@@ -55,13 +60,17 @@ pub enum Reward {
/// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)]
pub struct AchievementDef {
/// Unique string identifier for this achievement (e.g. `"first_win"`).
pub id: &'static str,
/// Human-readable display name shown in the achievements screen.
pub name: &'static str,
/// Flavour text describing how to unlock the achievement.
pub description: &'static str,
/// Hidden from the achievements screen until unlocked.
pub secret: bool,
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
pub reward: Option<Reward>,
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
pub condition: fn(&AchievementContext) -> bool,
}
@@ -477,6 +486,109 @@ mod tests {
assert!(achievement_by_id("nonexistent").is_none());
}
// -----------------------------------------------------------------------
// Direct predicate tests via ctx_defaults()
// -----------------------------------------------------------------------
/// Baseline context representing a single clean one-minute win in Draw-One mode.
fn ctx_defaults() -> AchievementContext {
AchievementContext {
games_played: 1,
games_won: 1,
win_streak_current: 1,
best_single_score: 0,
lifetime_score: 0,
draw_three_wins: 0,
daily_challenge_streak: 0,
last_win_score: 0,
last_win_time_seconds: 600,
last_win_used_undo: false,
wall_clock_hour: Some(12),
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
#[test]
fn speed_demon_true_when_under_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
}
#[test]
fn speed_demon_false_when_over_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
}
#[test]
fn lightning_true_when_under_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 89;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
}
#[test]
fn lightning_false_at_exactly_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
}
#[test]
fn no_undo_true_when_zero_undos() {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
}
#[test]
fn no_undo_false_when_undo_used() {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
}
#[test]
fn high_scorer_true_when_score_5000_or_more() {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
}
#[test]
fn high_scorer_false_when_below_5000() {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
}
#[test]
fn on_a_roll_true_at_streak_3() {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
}
#[test]
fn comeback_true_when_three_or_more_recycles() {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
}
#[test]
fn on_a_roll_requires_streak_of_3() {
let mut c = ctx();
+4
View File
@@ -63,9 +63,13 @@ impl Rank {
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool,
}
+1
View File
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
/// A standard 52-card deck.
pub struct Deck {
/// All 52 cards in the deck, in deal order.
pub cards: Vec<Card>,
}
+27
View File
@@ -64,18 +64,26 @@ struct StateSnapshot {
/// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState {
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
#[serde(with = "pile_map_serde")]
pub piles: HashMap<PileType, Pile>,
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
/// compatibility with older save files via `#[serde(default)]`.
#[serde(default)]
pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32,
/// Total moves made this game, including draws and stock recycles.
pub move_count: u32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64,
/// True once all 52 cards are on the foundations. No further moves are accepted.
pub is_won: bool,
/// True when the game can be completed without further input (all remaining cards are face-up and in order).
pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`.
@@ -173,6 +181,7 @@ impl GameState {
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1;
return Ok(());
}
@@ -562,6 +571,24 @@ mod tests {
assert_eq!(g.recycle_count, 2);
}
#[test]
fn move_count_increments_on_recycle() {
let mut g = new_game();
// Drain stock to waste, recording how many draws it took.
let mut draws: u32 = 0;
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
draws += 1;
}
let before = g.move_count;
g.draw().unwrap(); // recycle
assert_eq!(
g.move_count,
before + 1,
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
);
}
#[test]
fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are
+2
View File
@@ -17,7 +17,9 @@ pub enum PileType {
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>,
}