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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user