refactor(core): derive score/undo/recycle from upstream session stats
Replace the bespoke WXP scoring engine with the upstream card_game/klondike session stats, eliminating duplicated state that could drift from the single source of truth. score()/undo_count()/recycle_count() now read session.stats(); the -15 undo penalty is configured as SessionConfig::undo_penalty and applied by the upstream score formula. Save schema bumped v4 -> v5 (the three counters are no longer persisted -- they are rebuilt by replaying the forward instruction history on load). - Remove GameState fields score, undo_count, recycle_count (#87) - Remove score_history / is_recycle_history undo journal (#86) - Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers, plus pre_instruction_score_delta / will_flip_tableau_source (#84) These three issues are a single atomic change: each removed field/helper is consumed by the same draw/apply_instruction/undo/serde/PartialEq paths, so they cannot compile or pass tests in isolation. Behaviour changes (intentional): the escalating recycle penalty and per-step score floor are gone (upstream linear scoring, floored once at 0); recycle_count is now cumulative; undo_count resets across save/load. Refs #84, #86, #87 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,6 @@ use klondike::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game_state::GameMode;
|
||||
|
||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DrawMode {
|
||||
@@ -51,116 +49,6 @@ impl KlondikeAdapter {
|
||||
scoring: ScoringConfig::DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoring helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Score delta for a card move.
|
||||
///
|
||||
/// Reads from [`ScoringConfig`] (WXP Standard values):
|
||||
/// - Any pile → Foundation: +10
|
||||
/// - Waste → Tableau: +5
|
||||
/// - Foundation → Tableau: −15
|
||||
/// - All other moves: 0
|
||||
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
||||
let sc = ScoringConfig::DEFAULT;
|
||||
match (from, to) {
|
||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
||||
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for exposing a face-down tableau card: +5.
|
||||
pub fn score_for_flip() -> i32 {
|
||||
ScoringConfig::DEFAULT.flip_up_bonus
|
||||
}
|
||||
|
||||
/// Score delta for undo: −15.
|
||||
///
|
||||
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
|
||||
/// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty
|
||||
/// is applied here by `GameState` on every undo.
|
||||
pub fn score_for_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
/// Score delta for recycling waste → stock.
|
||||
///
|
||||
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
|
||||
/// WXP allows a fixed number of free recycles before charging a penalty,
|
||||
/// which the upstream library cannot express with a single delta:
|
||||
///
|
||||
/// | Mode | Free recycles | Penalty per extra recycle |
|
||||
/// |---|---|---|
|
||||
/// | Draw-1 | 1 | −100 |
|
||||
/// | Draw-3 | 3 | −20 |
|
||||
///
|
||||
/// **Design note:** recycling is *never* blocked — only penalised.
|
||||
/// This is intentional: Draw-1 can be played indefinitely with the score
|
||||
/// dropping toward zero after the first free recycle. A hard cap would
|
||||
/// create unwinnable positions when the solver cannot find a path without
|
||||
/// additional recycling. Zen mode suppresses the penalty entirely.
|
||||
///
|
||||
/// `recycle_count` must be the new total **after** this recycle.
|
||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||
if is_draw_three {
|
||||
if recycle_count > 3 { -20 } else { 0 }
|
||||
} else if recycle_count > 1 {
|
||||
-100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for a card move, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
||||
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_move(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for exposing a face-down card, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`].
|
||||
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_flip()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the new score after an undo, accounting for game mode.
|
||||
///
|
||||
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
|
||||
/// −15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
|
||||
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
(snapshot_score + Self::score_for_undo()).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score delta for recycling, accounting for game mode.
|
||||
///
|
||||
/// Returns 0 in [`GameMode::Zen`].
|
||||
pub fn score_for_recycle_with_mode(
|
||||
recycle_count: u32,
|
||||
is_draw_three: bool,
|
||||
mode: GameMode,
|
||||
) -> i32 {
|
||||
if mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
Self::score_for_recycle(recycle_count, is_draw_three)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||
|
||||
Reference in New Issue
Block a user