refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
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:
@@ -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+2660–U+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))
|
||||
}
|
||||
Reference in New Issue
Block a user