Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2581024f3 | |||
| c60d465711 | |||
| ce2b29f5df |
@@ -6,6 +6,23 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.43.3] — 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Replays are now self-contained (schema v4).** A replay stores the dealt
|
||||
board itself via the upstream `card_game` session serializers instead of
|
||||
re-dealing from the seed at playback time, so replays survive RNG and
|
||||
upstream upgrades that change the seed→deal mapping — the failure that had
|
||||
silently broken every stored replay. The web player and web game now
|
||||
exchange the full payload through the wasm layer (the old JS path hardcoded
|
||||
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
|
||||
`Math.round`), and the replay viewer reports unplayable old-format replays
|
||||
in the caption instead of dying silently. Pre-v4 replays are rejected by a
|
||||
version gate; local histories repopulate with new wins. (#170)
|
||||
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
|
||||
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
|
||||
|
||||
## [0.42.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -461,6 +461,32 @@ impl GameState {
|
||||
self.session.stats().stats().recycle_count()
|
||||
}
|
||||
|
||||
/// Number of cards moved onto foundations this game, read from the
|
||||
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
|
||||
/// not rolled back on undo.
|
||||
pub fn move_to_foundation_count(&self) -> u32 {
|
||||
self.session.stats().stats().move_to_foundation_count()
|
||||
}
|
||||
|
||||
/// Number of stacks moved onto tableaus (from stock or another tableau)
|
||||
/// this game, read from the upstream session stats. Cumulative — not
|
||||
/// rolled back on undo.
|
||||
pub fn move_to_tableau_count(&self) -> u32 {
|
||||
self.session.stats().stats().move_to_tableau_count()
|
||||
}
|
||||
|
||||
/// Number of cards taken back off a foundation this game, read from the
|
||||
/// upstream session stats. Cumulative — not rolled back on undo.
|
||||
pub fn move_from_foundation_count(&self) -> u32 {
|
||||
self.session.stats().stats().move_from_foundation_count()
|
||||
}
|
||||
|
||||
/// Number of face-down cards revealed (flipped up) this game, read from
|
||||
/// the upstream session stats. Cumulative — not rolled back on undo.
|
||||
pub fn flip_up_count(&self) -> u32 {
|
||||
self.session.stats().stats().flip_up_bonus_count()
|
||||
}
|
||||
|
||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||
/// from the session's instruction history length.
|
||||
pub fn move_count(&self) -> u32 {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! shake duration elapses.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::game_state::{GameMode, GameState};
|
||||
use solitaire_core::scoring::compute_time_bonus;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
|
||||
/// score-breakdown reveal can format the mode-multiplier row
|
||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||
pub mode: GameMode,
|
||||
/// Per-move-type recap of the winning game, e.g.
|
||||
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
|
||||
/// Built from the upstream session counters at win time; empty when
|
||||
/// every counter is zero (synthesised test wins).
|
||||
pub move_detail: String,
|
||||
}
|
||||
|
||||
/// Formats the per-move-type recap line for the win modal from the
|
||||
/// upstream session counters. Zero-valued components are omitted so the
|
||||
/// line stays short; returns an empty string when nothing was counted
|
||||
/// (only possible for synthesised test wins).
|
||||
fn build_move_detail(game: &GameState) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let foundation = game.move_to_foundation_count();
|
||||
if foundation > 0 {
|
||||
parts.push(format!("{foundation} to foundation"));
|
||||
}
|
||||
let tableau = game.move_to_tableau_count();
|
||||
if tableau > 0 {
|
||||
parts.push(format!("{tableau} tableau moves"));
|
||||
}
|
||||
let flips = game.flip_up_count();
|
||||
if flips > 0 {
|
||||
parts.push(format!("{flips} flips"));
|
||||
}
|
||||
let recycles = game.recycle_count();
|
||||
if recycles > 0 {
|
||||
parts.push(format!("{recycles} recycles"));
|
||||
}
|
||||
let returns = game.move_from_foundation_count();
|
||||
if returns > 0 {
|
||||
parts.push(format!("{returns} foundation returns"));
|
||||
}
|
||||
parts.join(" \u{00B7} ")
|
||||
}
|
||||
|
||||
/// Builds a human-readable XP breakdown string for the win modal.
|
||||
@@ -492,6 +526,7 @@ fn cache_win_data(
|
||||
pending.challenge_level = challenge_level;
|
||||
pending.undo_count = game.0.undo_count();
|
||||
pending.mode = game.0.mode;
|
||||
pending.move_detail = build_move_detail(&game.0);
|
||||
|
||||
if is_new_record {
|
||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||
@@ -913,6 +948,18 @@ fn spawn_overlay(
|
||||
));
|
||||
}
|
||||
|
||||
// Move-type recap (same quiet styling as the XP breakdown)
|
||||
if !pending.move_detail.is_empty() {
|
||||
card.spawn((
|
||||
Text::new(pending.move_detail.clone()),
|
||||
TextFont {
|
||||
font_size: 15.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||
// excess is summarised with "...and N more".
|
||||
if !session.names.is_empty() {
|
||||
@@ -1288,6 +1335,49 @@ mod tests {
|
||||
assert_eq!(p.mode, GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_move_detail_fresh_game_is_empty() {
|
||||
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||
assert!(
|
||||
build_move_detail(&game).is_empty(),
|
||||
"no moves yet, so the recap line must be empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_move_detail_reports_played_move_types() {
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
// Drive a real deal forward so the upstream counters accumulate:
|
||||
// prefer foundation moves, then anything else, drawing as needed.
|
||||
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||
for _ in 0..80 {
|
||||
let instructions = game.possible_instructions();
|
||||
let next = instructions
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
|
||||
.or_else(|| instructions.into_iter().next());
|
||||
match next {
|
||||
Some(i) => {
|
||||
let _ = game.apply_instruction(i);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let detail = build_move_detail(&game);
|
||||
assert!(
|
||||
detail.contains("to foundation"),
|
||||
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
|
||||
);
|
||||
assert!(
|
||||
!detail.contains("0 "),
|
||||
"zero-valued components must be omitted; got: {detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_slow_win_with_undo() {
|
||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||
|
||||
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
|
||||
}, STEP_INTERVAL_MS);
|
||||
});
|
||||
|
||||
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||
/// renders once so the CSS transition animates each card to its previous
|
||||
/// position.
|
||||
/// Step the player back one move via the wasm-side seek (rewinds to the
|
||||
/// recorded deal and fast-forwards internally), then renders once so the
|
||||
/// CSS transition animates each card to its previous position.
|
||||
function stepBack() {
|
||||
if (!player || player.step_idx() === 0) return;
|
||||
if (playInterval) {
|
||||
@@ -326,12 +325,7 @@ function stepBack() {
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
}
|
||||
const target = player.step_idx() - 1;
|
||||
player = new ReplayPlayer(replayJson);
|
||||
for (let i = 0; i < target; i++) {
|
||||
player.step();
|
||||
}
|
||||
render(player.state());
|
||||
render(player.seek(player.step_idx() - 1));
|
||||
btnPrev.disabled = player.step_idx() === 0;
|
||||
btnRestart.disabled = player.step_idx() === 0;
|
||||
btnStep.disabled = false;
|
||||
|
||||
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
|
||||
#[wasm_bindgen]
|
||||
pub struct ReplayPlayer {
|
||||
game: GameState,
|
||||
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
|
||||
/// can rewind without reparsing the replay JSON.
|
||||
initial: GameState,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
step_idx: usize,
|
||||
}
|
||||
@@ -144,12 +147,30 @@ impl ReplayPlayer {
|
||||
// the current build maps seeds to deals.
|
||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||
Ok(Self {
|
||||
initial: game.clone(),
|
||||
game,
|
||||
moves,
|
||||
step_idx: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Jump to `step` (clamped to the move count): the board state after
|
||||
/// `step` moves have been applied. Rewinds by resetting to the stored
|
||||
/// initial deal, then fast-forwards — a few hundred instruction
|
||||
/// applications, microseconds in practice.
|
||||
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
|
||||
let target = step.min(self.moves.len());
|
||||
if target < self.step_idx {
|
||||
self.game = self.initial.clone();
|
||||
self.step_idx = 0;
|
||||
}
|
||||
while self.step_idx < target {
|
||||
self.game.apply_instruction(self.moves[self.step_idx])?;
|
||||
self.step_idx += 1;
|
||||
}
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||
if self.step_idx >= self.moves.len() {
|
||||
@@ -236,6 +257,25 @@ impl ReplayPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump directly to `step` moves applied (clamped to the move count)
|
||||
/// and return the snapshot there. Backwards seeks rewind to the
|
||||
/// recorded deal and fast-forward, so any position is O(replay length)
|
||||
/// at worst — no JSON reparse, no intermediate renders.
|
||||
///
|
||||
/// Throws `"replay_desync"` if a recorded move is illegal during the
|
||||
/// fast-forward (corrupt recording).
|
||||
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
|
||||
match self.seek_native(step) {
|
||||
Ok(snap) => {
|
||||
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
log_replay_move_error(&e);
|
||||
Err(JsValue::from_str("replay_desync"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of moves the replay contains.
|
||||
pub fn total_steps(&self) -> usize {
|
||||
self.moves.len()
|
||||
@@ -1062,6 +1102,57 @@ mod tests {
|
||||
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
||||
}
|
||||
|
||||
/// `seek` must land on exactly the state produced by stepping — both
|
||||
/// forwards (fast-forward from the current position) and backwards
|
||||
/// (rewind to the recorded deal, then fast-forward).
|
||||
#[test]
|
||||
fn seek_matches_stepping_in_both_directions() {
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||
};
|
||||
for _ in 0..24 {
|
||||
let legal_moves = game.legal_moves_native();
|
||||
if legal_moves.is_empty() {
|
||||
break;
|
||||
}
|
||||
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||
game.apply_legal_move_native(idx).expect("advance game");
|
||||
}
|
||||
let replay_json = game
|
||||
.replay_export_native(60, "2026-07-10")
|
||||
.expect("export replay");
|
||||
|
||||
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
|
||||
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
|
||||
let total = stepped.total_steps();
|
||||
assert!(total >= 4, "test needs a few moves, got {total}");
|
||||
|
||||
// Forward: step A to k, seek B to k, compare snapshots.
|
||||
let k = total / 2;
|
||||
for _ in 0..k {
|
||||
stepped.step_native().expect("step").expect("mid-replay");
|
||||
}
|
||||
let sought = seeker.seek_native(k).expect("seek forward");
|
||||
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
|
||||
|
||||
// Backward: seek B to k - 2 and compare against a fresh stepper.
|
||||
let back = k - 2;
|
||||
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
|
||||
for _ in 0..back {
|
||||
fresh.step_native().expect("step").expect("mid-replay");
|
||||
}
|
||||
let sought_back = seeker.seek_native(back).expect("seek backward");
|
||||
assert_eq!(
|
||||
sought_back,
|
||||
fresh.snapshot(),
|
||||
"backward seek diverged at {back}"
|
||||
);
|
||||
|
||||
// Clamping: past-the-end seeks stop at the final state.
|
||||
let end = seeker.seek_native(usize::MAX).expect("seek to end");
|
||||
assert_eq!(end.step_idx, total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_api_autonomous_seed_batch_smoke() {
|
||||
for seed in 0_u64..128_u64 {
|
||||
|
||||
Reference in New Issue
Block a user