feat(engine,core,data): add Challenge mode with seed list and level-5 gate

Phase 6 part 4b (partial):
- GameMode::Challenge variant in solitaire_core. undo() returns
  RuleViolation when mode is Challenge so the player commits to each
  decision.
- solitaire_data::challenge defines a stable CHALLENGE_SEEDS list with
  challenge_seed_for(index) wrapping modulo length.
- PlayerProgress.challenge_index (serde-default for older saves) tracks
  how far the player has progressed.
- ChallengePlugin advances the cursor on Challenge-mode wins, persists,
  and emits ChallengeAdvancedEvent. Pressing X starts a Challenge-mode
  game with the current seed; gated to level >= CHALLENGE_UNLOCK_LEVEL (5).
- InputPlugin's Z key (Zen mode) is now also gated to level >= 5.

Time Attack and unlock UI still deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-25 17:18:32 -07:00
parent 09d62f4255
commit 788ac9f65a
8 changed files with 346 additions and 9 deletions
+40 -3
View File
@@ -16,15 +16,18 @@ pub enum DrawMode {
DrawThree,
}
/// Top-level game mode. Affects scoring and (eventually) timer behaviour.
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
///
/// - `Classic`: standard Klondike scoring and timer.
/// - `Zen`: scoring suppressed (stays at 0); intended for relaxed play.
/// - `Classic`: standard Klondike scoring, undo allowed.
/// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play.
/// - `Challenge`: standard scoring, **undo disabled** (returns
/// `MoveError::RuleViolation`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode {
#[default]
Classic,
Zen,
Challenge,
}
/// Snapshot of game state used for undo.
@@ -261,10 +264,16 @@ impl GameState {
}
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
/// Disabled in `GameMode::Challenge` — returns `MoveError::RuleViolation`.
pub fn undo(&mut self) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
}
if self.mode == GameMode::Challenge {
return Err(MoveError::RuleViolation(
"undo is disabled in Challenge mode".into(),
));
}
let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?;
self.piles = snapshot.piles;
self.score = if self.mode == GameMode::Zen {
@@ -559,6 +568,34 @@ mod tests {
assert_eq!(g.draw_mode, DrawMode::DrawThree);
}
// --- GameMode: Challenge ---
#[test]
fn challenge_mode_disables_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
g.draw().unwrap();
let result = g.undo();
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn challenge_mode_still_allows_normal_moves() {
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
// Just verify the game initialises cleanly with Challenge mode.
assert_eq!(g.mode, GameMode::Challenge);
assert_eq!(g.score, 0);
}
#[test]
fn challenge_mode_scoring_applies_normally() {
// Challenge uses Classic scoring; only undo is disabled.
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
assert_eq!(g.score, 0);
// Note: Verifying score increases on actual moves would require
// hand-crafting a legal move from the dealt state. We rely on the
// fact that move_cards' score path is identical to Classic.
}
// --- Auto-complete ---
#[test]