1cdb78caf2
Build and Deploy / build-and-push (push) Successful in 4m46s
- Apply cargo fmt to solitaire_engine, solitaire_server formatting. - solitaire_server/src/lib.rs: add https://analytics.aleshym.co to script-src, img-src, and connect-src so the analytics beacon loads without a CSP violation. - docs and README updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
274 lines
10 KiB
Rust
274 lines
10 KiB
Rust
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))
|
||
}
|