refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s

- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 13:13:35 -07:00
parent ca612f51f1
commit 9260ca7994
36 changed files with 7429 additions and 7064 deletions
@@ -0,0 +1,269 @@
use super::ReplayPlaybackState;
use chrono::Datelike;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_data::ReplayMove;
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
/// consumed when transitioning out of `Playing`, so the identifier
/// isn't recoverable from state in those branches); spawn-time
/// callers fall back to an empty string.
///
/// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact
/// monotonically-increasing identifier shaped like `2026-127` — same
/// shape as the mockup's `GAME #2024-127` motif.
pub(crate) fn format_game_caption(state: &ReplayPlaybackState) -> Option<String> {
match state {
ReplayPlaybackState::Playing { replay, .. } => Some(format!(
"GAME #{}-{:03}",
replay.recorded_at.year(),
replay.recorded_at.ordinal()
)),
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None,
}
}
/// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string.
pub(crate) fn format_progress(state: &ReplayPlaybackState) -> String {
match state.progress() {
// `MOVE N/M` (uppercase + slash) reads as a Terminal output
// line and matches the floating-chip motif in the mockup at
// `docs/ui-mockups/replay-overlay-mobile.html`.
Some((cursor, total)) => format!("MOVE {cursor}/{total}"),
None if state.is_completed() => "REPLAY COMPLETE".to_string(),
None => String::new(),
}
}
/// Pure helper — formats a [`KlondikePile`] as a short, lowercase,
/// 1-indexed display string for the move-log row. `Foundation(2)`
/// renders as `"foundation 3"` rather than `"foundation 2"` so
/// players see human-friendly numbers; the underlying enum
/// remains 0-indexed.
///
/// Returns `String` rather than `&'static str` because the
/// `Foundation` / `Tableau` variants need formatting; the static
/// variants (`Stock`, `Waste`) still allocate but the cost is
/// trivial against the per-frame update cadence.
pub(crate) fn format_pile(p: &KlondikePile) -> String {
match p {
KlondikePile::Stock => "waste".to_string(),
KlondikePile::Foundation(foundation) => {
format!("foundation {}", foundation_number(*foundation))
}
KlondikePile::Tableau(tableau) => format!("tableau {}", tableau_number(*tableau)),
}
}
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
KlondikePile::try_from(*p)
.map(|pile| format_pile(&pile))
.unwrap_or_else(|_| "unknown pile".to_string())
}
fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
}
}
/// Pure helper — formats a [`ReplayMove`] as the body of a
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
/// reads as `"{from} → {to}"` using [`format_pile`] for both
/// endpoints. The `count` field is omitted from the row body —
/// at row scale it adds visual noise without meaningful
/// information for the typical 1-card moves.
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
match m {
ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => {
format!("{} \u{2192} {}", format_saved_pile(from), format_saved_pile(to))
}
}
}
/// Pure helper — formats the move-log panel's header text. Reads
/// `▌ MOVE LOG · N/M` while playing, where `N` is the count of
/// moves applied so far and `M` is the total in the replay. The
/// cursor-block prefix (`▌`) matches the splash and replay-banner
/// motifs. Empty in `Inactive` (no replay attached); reads
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!(
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
}
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(),
}
}
/// Pure helper — formats the kth-most-recently-applied move's row
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (no kth-most-recent for k=0; the active is k=1).
/// - `k > cursor` (not enough history — e.g. cursor=2 has rows
/// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard).
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if k == 0 || k > *cursor {
return String::new();
}
let zero_idx = *cursor - k;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor - k + 1;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
/// is the move that will apply next (`replay.moves[cursor]`,
/// displayed as `cursor + 1`); `k = 2` is the move after that,
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (degenerate; the active is k=1 of *recent*, not
/// *next*).
/// - `cursor + k - 1 >= moves.len()` (not enough remaining
/// replay — late in the move list, the trailing next rows
/// stay empty).
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if k == 0 {
return String::new();
}
let zero_idx = *cursor + k - 1;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor + k;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the active-row text for the move-log
/// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends
/// a `▶` focus marker so the active row reads visually distinct
/// from prev rows even before the highlight background lands.
/// Returns empty when there's no row to render (cursor=0 or
/// non-`Playing` state) — never `"▶ "` alone, which would paint
/// a stray prefix.
pub(crate) fn format_active_move_row(state: &ReplayPlaybackState) -> String {
let body = format_kth_recent_row(state, 1);
if body.is_empty() {
return String::new();
}
format!("\u{25B6} {body}") // ▶
}
// ---------------------------------------------------------------------------
// Mini-tableau format helpers and update system
// ---------------------------------------------------------------------------
/// Pure helper — short rank symbol. Single character for all ranks
/// except Ten which uses "T" (keeps every card a consistent 2-char
/// wide render: rank-char + suit-glyph). Players familiar with
/// solitaire shorthand read "T" instantly; the suit glyph immediately
/// follows and disambiguates from an ambiguous "T".
pub(crate) fn format_rank_short(rank: Rank) -> &'static str {
match rank {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "T",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
}
}
/// Pure helper — Unicode suit glyph from FiraMono's covered range
/// (U+2660U+2666). These four code points are confirmed present in
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
match suit {
Suit::Spades => "\u{2660}", // ♠
Suit::Hearts => "\u{2665}", // ♥
Suit::Diamonds => "\u{2666}", // ♦
Suit::Clubs => "\u{2663}", // ♣
}
}
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
/// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&Card>) -> String {
match card {
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
None => "--".to_string(),
}
}
/// Pure helper — one-line summary of the four foundation tops.
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
/// Foundation slots are displayed in their natural 0-3 order
/// (matching the visual left-to-right order on screen).
pub(crate) fn format_foundations_row(game: &GameState) -> String {
let slots = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.map(|foundation| {
let cards = game.pile(KlondikePile::Foundation(foundation));
format_card_short(cards.last())
});
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
}
/// Pure helper — one-line stock / waste summary.
/// Renders as `STK:N WST:X♠` where N is the stock card count and
/// X♠ is the top waste card (or `--` when the waste pile is empty).
pub(crate) fn format_stock_waste_row(game: &GameState) -> String {
let stock_cards = game.stock_cards();
let waste_cards = game.waste_cards();
let stock_count = stock_cards.len();
let waste_top = waste_cards.last();
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
}