4cb4212829
Test / test (pull_request) Successful in 36m34s
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.
- core: SessionRecording newtype delegating to Session<Klondike> serde;
GameState::recording() / from_recording(); from_instructions_unchecked
fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
the existing version gate
- engine: win-recording and sync upload freeze game.recording();
playback rebuilds from the recording; Playing carries the extracted
move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
hand-assembles it (the old game.js path hardcoded schema_version: 2
and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
(Difficulty uploads previously 400'd against the String field)
Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
282 lines
11 KiB
Rust
282 lines
11 KiB
Rust
use super::ReplayPlaybackState;
|
||
use chrono::Datelike;
|
||
use solitaire_core::game_state::GameState;
|
||
use solitaire_core::{Card, Rank, Suit};
|
||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||
|
||
/// 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)),
|
||
}
|
||
}
|
||
|
||
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 [`KlondikeInstruction`] as the body of a
|
||
/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
|
||
/// instruction reads as `"{from} → {to}"` using [`format_pile`] for
|
||
/// each nameable endpoint. The card count is omitted from the row
|
||
/// body — at row scale it adds visual noise without meaningful
|
||
/// information for the typical 1-card moves.
|
||
///
|
||
/// The destination pile is always recoverable directly from the
|
||
/// instruction. The source pile is shown when it is statically
|
||
/// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
|
||
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
|
||
/// type — not re-exported across the `solitaire_core` boundary and so
|
||
/// not pattern-matchable here — so its row renders `"→ {to}"` without
|
||
/// a leading source label. Faithful full-coordinate decoding lives in
|
||
/// [`GameState::instruction_to_piles`] on the playback path; the
|
||
/// move-log is a display-only digest.
|
||
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
||
match instruction {
|
||
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
|
||
KlondikeInstruction::DstFoundation(dst) => {
|
||
format!(
|
||
"{} \u{2192} {}",
|
||
format_pile(&dst.src),
|
||
format_pile(&KlondikePile::Foundation(dst.foundation))
|
||
)
|
||
}
|
||
KlondikeInstruction::DstTableau(dst) => {
|
||
format!(
|
||
"\u{2192} {}",
|
||
format_pile(&KlondikePile::Tableau(dst.tableau))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 { moves, cursor, .. } => {
|
||
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, 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 (`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 { moves, cursor, .. } = state else {
|
||
return String::new();
|
||
};
|
||
if k == 0 || k > *cursor {
|
||
return String::new();
|
||
}
|
||
let zero_idx = *cursor - k;
|
||
let Some(m) = 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 (`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 { moves, cursor, .. } = state else {
|
||
return String::new();
|
||
};
|
||
if k == 0 {
|
||
return String::new();
|
||
}
|
||
let zero_idx = *cursor + k - 1;
|
||
let Some(m) = 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, bool)>) -> 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))
|
||
}
|