feat(core): card/pile conversion utils and GameMode-aware scoring (steps 2-prep, 5)
Build and Deploy / build-and-push (push) Failing after 55s

Step 2 prep — card_game dep + type-conversion utilities:
- Add card_game = "0.3.0" (registry Quaternions) to workspace + core
- suit_to_kl / suit_from_kl, rank_to_kl / rank_from_kl
- card_to_kl (drops id, Deck1), card_from_kl (reconstructs stable id
  from Clubs-first suit×13+rank ordering matching deck.rs)
- Ready to wire into KlondikeState pile projection once upstream
  adds KlondikeState::from_piles()

Step 5 — GameMode-aware scoring in the adapter:
- score_for_move_with_mode, score_for_flip_with_mode (return 0 in Zen)
- apply_undo_score (static, handles Zen + −15 penalty + clamp)
- score_for_recycle_with_mode (return 0 in Zen)
- game_state.rs: all inline GameMode::Zen checks replaced with
  adapter calls; adapter is now the single source of truth for
  "what score does this action give in this mode"

192 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-29 14:38:41 -07:00
parent f1914b4398
commit 57c4b5aacf
5 changed files with 121 additions and 18 deletions
+1
View File
@@ -9,3 +9,4 @@ serde = { workspace = true }
thiserror = { workspace = true }
rand = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
+10 -17
View File
@@ -275,11 +275,12 @@ impl GameState {
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
if self.mode != GameMode::Zen {
let penalty =
KlondikeAdapter::score_for_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree);
self.score = (self.score + penalty).max(0);
}
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
self.recycle_count,
self.draw_mode == DrawMode::DrawThree,
self.mode,
);
self.score = (self.score + penalty).max(0);
self.move_count = self.move_count.saturating_add(1);
return Ok(());
}
@@ -411,11 +412,7 @@ impl GameState {
start
};
let score_delta = if self.mode == GameMode::Zen {
0
} else {
self.adapter.score_for_move(&from, &to)
};
let score_delta = self.adapter.score_for_move_with_mode(&from, &to, self.mode);
self.push_snapshot();
// Execute move
@@ -446,8 +443,8 @@ impl GameState {
.cards
.append(&mut moved);
let flip_bonus = if flipped && self.mode != GameMode::Zen {
self.adapter.score_for_flip()
let flip_bonus = if flipped {
self.adapter.score_for_flip_with_mode(self.mode)
} else {
0
};
@@ -478,11 +475,7 @@ impl GameState {
.pop_back()
.ok_or(MoveError::UndoStackEmpty)?;
self.piles = snapshot.piles;
self.score = if self.mode == GameMode::Zen {
0
} else {
(snapshot.score + KlondikeAdapter::score_for_undo()).max(0)
};
self.score = KlondikeAdapter::apply_undo_score(snapshot.score, self.mode);
self.move_count = snapshot.move_count;
self.is_won = false;
self.is_auto_completable = false;
+108 -1
View File
@@ -13,9 +13,10 @@
//! - Move validation via klondike's rule engine (step 2).
//! - DFS solver via [`klondike::KlondikeState`] (step 6).
use card_game::{Card as KlCard, Deck as KlDeck, Rank as KlRank, Suit as KlSuit};
use klondike::{DrawStockConfig, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig};
use crate::game_state::DrawMode;
use crate::game_state::{DrawMode, GameMode};
use crate::pile::PileType;
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
@@ -136,4 +137,110 @@ impl KlondikeAdapter {
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(&self, from: &PileType, to: &PileType, 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(&self, 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)
}
}
}
// ── Type-conversion utilities (Step 2 — pile mapping) ────────────────────
//
// These are used to translate between solitaire_core's Card/PileType and the
// card_game / klondike types when projecting KlondikeState into the pile
// snapshot that the engine reads. A live KlondikeState shadow requires an
// upstream `KlondikeState::from_piles()` constructor; these utilities are
// ready to wire in once that's available.
/// Convert our [`crate::card::Suit`] to [`card_game::Suit`].
pub fn suit_to_kl(suit: crate::card::Suit) -> KlSuit {
match suit {
crate::card::Suit::Clubs => KlSuit::Clubs,
crate::card::Suit::Diamonds => KlSuit::Diamonds,
crate::card::Suit::Hearts => KlSuit::Hearts,
crate::card::Suit::Spades => KlSuit::Spades,
}
}
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
pub fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
match suit {
KlSuit::Clubs => crate::card::Suit::Clubs,
KlSuit::Diamonds => crate::card::Suit::Diamonds,
KlSuit::Hearts => crate::card::Suit::Hearts,
KlSuit::Spades => crate::card::Suit::Spades,
}
}
/// Convert our [`crate::card::Rank`] to [`card_game::Rank`].
pub fn rank_to_kl(rank: crate::card::Rank) -> KlRank {
KlRank::new(rank.value()).expect("rank value 1-13 always maps to a valid KlRank")
}
/// Convert [`card_game::Rank`] back to our [`crate::card::Rank`].
pub fn rank_from_kl(rank: KlRank) -> crate::card::Rank {
crate::card::Rank::RANKS
.into_iter()
.find(|r| r.value() == rank as u8)
.expect("KlRank 1-13 always maps to a valid Rank")
}
/// Convert our [`crate::card::Card`] to a [`card_game::Card`] (Deck1, same suit/rank).
///
/// The `id` field is dropped; use [`card_to_kl`] only when the klondike engine
/// needs to evaluate the card's logical identity, not its animation entity.
pub fn card_to_kl(card: &crate::card::Card) -> KlCard {
KlCard::new(KlDeck::Deck1, suit_to_kl(card.suit), rank_to_kl(card.rank))
}
/// Convert a [`card_game::Card`] back to our [`crate::card::Card`], assigning
/// a stable `id` derived from the suit and rank (051, Clubs-first ordering).
///
/// This id matches the id assigned in [`crate::deck::Deck::new`] and is
/// consistent for the same logical card across all reconstructions.
pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
let suit = suit_from_kl(card.suit());
let rank = rank_from_kl(card.rank());
let suit_index = crate::card::Suit::SUITS
.iter()
.position(|s| *s == suit)
.expect("suit always in SUITS") as u32;
let id = suit_index * 13 + (rank.value() as u32 - 1);
crate::card::Card { id, suit, rank, face_up: false }
}