Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e92fb75dd9 |
+3
-1
@@ -145,6 +145,7 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
|
|||||||
Owns:
|
Owns:
|
||||||
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
||||||
- `ChallengeGoal`, `LeaderboardEntry`
|
- `ChallengeGoal`, `LeaderboardEntry`
|
||||||
|
- `ApiError` enum
|
||||||
- Merge logic (pure functions, no I/O)
|
- Merge logic (pure functions, no I/O)
|
||||||
|
|
||||||
### `solitaire_data`
|
### `solitaire_data`
|
||||||
@@ -256,7 +257,7 @@ solitaire_sync::merge(local, remote)
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Write merged result to disk
|
Write merged result to disk
|
||||||
│
|
│ fires SyncCompleteEvent
|
||||||
▼
|
▼
|
||||||
Bevy main thread reads updated StatsResource
|
Bevy main thread reads updated StatsResource
|
||||||
```
|
```
|
||||||
@@ -376,6 +377,7 @@ struct StateChangedEvent;
|
|||||||
struct CardFlippedEvent(u32);
|
struct CardFlippedEvent(u32);
|
||||||
struct GameWonEvent { score: i32, time_seconds: u64 }
|
struct GameWonEvent { score: i32, time_seconds: u64 }
|
||||||
struct AchievementUnlockedEvent(AchievementRecord);
|
struct AchievementUnlockedEvent(AchievementRecord);
|
||||||
|
struct SyncCompleteEvent(Result<SyncResponse, String>);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layout System
|
### Layout System
|
||||||
|
|||||||
@@ -6,23 +6,6 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.42.0] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
-1
@@ -7333,7 +7333,6 @@ dependencies = [
|
|||||||
"proptest",
|
"proptest",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ default = []
|
|||||||
test-support = []
|
test-support = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -277,90 +277,6 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Self-contained recording of one deal plus every instruction applied to it.
|
|
||||||
///
|
|
||||||
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
|
|
||||||
/// format is `{config, initial_state, instructions}` — the dealt board is
|
|
||||||
/// stored **explicitly**, so playback never depends on the seed→deal mapping
|
|
||||||
/// staying stable across RNG or upstream-crate upgrades. This is the payload
|
|
||||||
/// replays must persist; a bare seed is only sufficient for the exact build
|
|
||||||
/// that recorded it.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SessionRecording(Session<Klondike>);
|
|
||||||
|
|
||||||
impl SessionRecording {
|
|
||||||
/// Builds a recording by dealing a fresh board from `seed` and
|
|
||||||
/// force-applying `instructions` **without validation**.
|
|
||||||
///
|
|
||||||
/// Fixture/test aid only — production recordings come from
|
|
||||||
/// [`GameState::recording`], whose history is valid by construction.
|
|
||||||
/// Invalid instructions are absorbed by the upstream session's
|
|
||||||
/// `Option`-based pile pops rather than rejected, so a recording built
|
|
||||||
/// here may not replay cleanly through
|
|
||||||
/// [`GameState::apply_instruction`]'s validation.
|
|
||||||
pub fn from_instructions_unchecked(
|
|
||||||
seed: u64,
|
|
||||||
draw_mode: DrawStockConfig,
|
|
||||||
instructions: impl IntoIterator<Item = KlondikeInstruction>,
|
|
||||||
) -> Self {
|
|
||||||
let mut session = GameState::new_session(seed, draw_mode);
|
|
||||||
for instruction in instructions {
|
|
||||||
session.process_instruction(instruction);
|
|
||||||
}
|
|
||||||
Self(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The dealt board the recording starts from (before any instruction).
|
|
||||||
fn initial_state(&self) -> &Klondike {
|
|
||||||
self.0
|
|
||||||
.history()
|
|
||||||
.first()
|
|
||||||
.map(|snapshot| snapshot.state())
|
|
||||||
.unwrap_or_else(|| self.0.state().state())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ordered instruction list, replayable via
|
|
||||||
/// [`GameState::apply_instruction`] against the game returned by
|
|
||||||
/// [`GameState::from_recording`].
|
|
||||||
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
|
|
||||||
self.0
|
|
||||||
.history()
|
|
||||||
.iter()
|
|
||||||
.map(|snapshot| *snapshot.instruction())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of recorded instructions.
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.0.history().len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `true` when no instructions have been recorded.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.0.history().is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq for SessionRecording {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Eq for SessionRecording {}
|
|
||||||
|
|
||||||
impl Serialize for SessionRecording {
|
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
self.0.serialize(serializer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for SessionRecording {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
Session::deserialize(deserializer).map(Self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||||
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||||
@@ -380,44 +296,6 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of the live session for replay persistence: the dealt board
|
|
||||||
/// plus the forward instruction history (undone moves are absent — the
|
|
||||||
/// session pops them). Serialise the returned [`SessionRecording`] with
|
|
||||||
/// the upstream `card_game` serializers; rebuild playback with
|
|
||||||
/// [`Self::from_recording`].
|
|
||||||
pub fn recording(&self) -> SessionRecording {
|
|
||||||
SessionRecording(self.session.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rebuilds the initial-deal game plus the ordered instruction list from
|
|
||||||
/// a [`SessionRecording`].
|
|
||||||
///
|
|
||||||
/// The board and the session config (including draw mode) come from the
|
|
||||||
/// recording itself, so playback is independent of the current build's
|
|
||||||
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
|
|
||||||
/// alongside the recording by the replay file. Step through the returned
|
|
||||||
/// instructions with [`Self::apply_instruction`], which re-validates each
|
|
||||||
/// one and fails gracefully on corrupt input.
|
|
||||||
pub fn from_recording(
|
|
||||||
recording: &SessionRecording,
|
|
||||||
seed: u64,
|
|
||||||
mode: GameMode,
|
|
||||||
) -> (Self, Vec<KlondikeInstruction>) {
|
|
||||||
let game = Self {
|
|
||||||
mode,
|
|
||||||
elapsed_seconds: 0,
|
|
||||||
seed,
|
|
||||||
take_from_foundation: true,
|
|
||||||
session: Session::new(
|
|
||||||
recording.initial_state().clone(),
|
|
||||||
recording.0.config().clone(),
|
|
||||||
),
|
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
test_pile_state: None,
|
|
||||||
};
|
|
||||||
(game, recording.instructions())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the player draws one or three cards from the stock per turn.
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
/// Derived from the underlying session config (set once at deal time).
|
/// Derived from the underlying session config (set once at deal time).
|
||||||
pub fn draw_mode(&self) -> DrawStockConfig {
|
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||||
@@ -461,32 +339,6 @@ impl GameState {
|
|||||||
self.session.stats().stats().recycle_count()
|
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
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
/// from the session's instruction history length.
|
/// from the session's instruction history length.
|
||||||
pub fn move_count(&self) -> u32 {
|
pub fn move_count(&self) -> u32 {
|
||||||
@@ -1660,77 +1512,4 @@ mod tests {
|
|||||||
assert!(easy.is_err());
|
assert!(easy.is_err());
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play a few real moves on a fresh deal and return the game.
|
|
||||||
fn game_with_some_moves(seed: u64) -> GameState {
|
|
||||||
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
|
||||||
for _ in 0..40 {
|
|
||||||
let instructions = game.possible_instructions();
|
|
||||||
let applied = instructions
|
|
||||||
.into_iter()
|
|
||||||
.find(|i| game.clone().apply_instruction(*i).is_ok())
|
|
||||||
.and_then(|i| game.apply_instruction(i).ok());
|
|
||||||
if applied.is_none() && game.draw().is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
!game.instruction_history().is_empty(),
|
|
||||||
"test needs at least one recorded move"
|
|
||||||
);
|
|
||||||
game
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recording_round_trips_through_upstream_serde() {
|
|
||||||
let game = game_with_some_moves(51);
|
|
||||||
let recording = game.recording();
|
|
||||||
let json = serde_json::to_string(&recording).expect("serialize recording");
|
|
||||||
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
|
||||||
assert_eq!(recording, restored);
|
|
||||||
assert_eq!(recording.instructions(), game.instruction_history());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_recording_replays_to_identical_board_without_seed_dealing() {
|
|
||||||
let game = game_with_some_moves(145);
|
|
||||||
let recording = game.recording();
|
|
||||||
let json = serde_json::to_string(&recording).expect("serialize recording");
|
|
||||||
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
|
||||||
|
|
||||||
// Deliberately pass a DIFFERENT seed: the board must come from the
|
|
||||||
// recording, proving playback no longer depends on seed→deal mapping.
|
|
||||||
let (mut replayed, instructions) =
|
|
||||||
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
|
|
||||||
assert_eq!(replayed.draw_mode(), game.draw_mode());
|
|
||||||
for instruction in instructions {
|
|
||||||
replayed
|
|
||||||
.apply_instruction(instruction)
|
|
||||||
.expect("recorded instruction must replay cleanly");
|
|
||||||
}
|
|
||||||
for pile in [KlondikePile::Stock]
|
|
||||||
.into_iter()
|
|
||||||
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
|
|
||||||
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
|
|
||||||
{
|
|
||||||
assert_eq!(
|
|
||||||
replayed.pile(pile),
|
|
||||||
game.pile(pile),
|
|
||||||
"pile {pile:?} differs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(replayed.waste_cards(), game.waste_cards());
|
|
||||||
assert_eq!(replayed.move_count(), game.move_count());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
|
||||||
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
|
||||||
let recording = game.recording();
|
|
||||||
assert!(recording.is_empty());
|
|
||||||
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
|
|
||||||
assert!(instructions.is_empty());
|
|
||||||
assert_eq!(replayed.stock_cards(), game.stock_cards());
|
|
||||||
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ pub use klondike::{
|
|||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
pub use game_state::{
|
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SessionRecording, SolveOutcome,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||||
// later phase — nothing outside solitaire_core consumes these yet).
|
// later phase — nothing outside solitaire_core consumes these yet).
|
||||||
|
|||||||
@@ -12,24 +12,22 @@
|
|||||||
//! carries any other version so older replays are silently dropped instead
|
//! carries any other version so older replays are silently dropped instead
|
||||||
//! of crashing the loader.
|
//! of crashing the loader.
|
||||||
//!
|
//!
|
||||||
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
|
//! The recording is intentionally minimal — only the
|
||||||
//! the upstream `card_game` session serialisation, which stores the dealt
|
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||||
//! board **explicitly** plus the ordered instruction list. `Undo` is not
|
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||||
//! recorded: a replay represents the canonical path the player ultimately
|
//! represents the canonical path the player ultimately took to win, so
|
||||||
//! took to win, so backed-out missteps simply do not appear (the session
|
//! backed-out missteps simply do not appear in the move list. The starting
|
||||||
//! pops them from its history).
|
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||||
//!
|
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||||
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
|
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||||
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
|
|
||||||
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
|
|
||||||
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
|
|
||||||
//! `mode` remain as presentation/indexing metadata only.
|
|
||||||
//!
|
//!
|
||||||
//! Each recorded move is the player's atomic *input*, not its outcome.
|
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||||
//! stock state during playback, so the same input always produces the same
|
//! stock state during playback, so the same input always produces the same
|
||||||
//! effect on the same starting deal.
|
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||||
|
//! never serialised — the instruction itself serialises via its compact
|
||||||
|
//! upstream serde representation.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -37,7 +35,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
@@ -79,19 +77,13 @@ fn history_schema_v0() -> u32 {
|
|||||||
/// variants which carried the *outcome* of a stock interaction rather
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
/// than the player's atomic input.
|
/// than the player's atomic input.
|
||||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||||
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
|
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||||
/// were stored directly as upstream `KlondikeInstruction` (compact
|
/// are now stored directly as upstream
|
||||||
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
|
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||||
/// runtime-only and are never serialised. The starting deal was still
|
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||||
/// rebuilt from the seed at playback time.
|
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||||
/// - v4 (current): the bare `moves` list was replaced by a
|
/// and are discarded by the loader.
|
||||||
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
|
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||||
/// `card_game` session serialisation carrying the dealt board explicitly
|
|
||||||
/// plus the instruction list. Playback no longer re-deals from the seed,
|
|
||||||
/// so replays survive RNG/upstream upgrades that change the seed→deal
|
|
||||||
/// mapping (which invalidated every v3 replay). v1–v3 files fail the
|
|
||||||
/// version gate and are discarded by the loader.
|
|
||||||
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
|
||||||
|
|
||||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
@@ -102,10 +94,9 @@ fn schema_v0() -> u32 {
|
|||||||
|
|
||||||
/// A complete recording of a single winning game.
|
/// A complete recording of a single winning game.
|
||||||
///
|
///
|
||||||
/// Replays are reconstructed via
|
/// Replays are reconstructed by rebuilding a fresh
|
||||||
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
|
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||||
/// which rebuilds the recorded deal directly and returns the instruction
|
/// [`moves`](Self::moves) in order. The presentation fields
|
||||||
/// list to step through. The presentation fields
|
|
||||||
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||||
/// "Replay (2:14 win on 2026-05-02)".
|
/// "Replay (2:14 win on 2026-05-02)".
|
||||||
@@ -114,9 +105,8 @@ pub struct Replay {
|
|||||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default = "schema_v0")]
|
#[serde(default = "schema_v0")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
/// Seed the recorded game was originally dealt from. Presentation /
|
/// Seed used for the deal — replay rasterises the deck via
|
||||||
/// indexing metadata only — playback rebuilds the board from
|
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||||
/// [`recording`](Self::recording), never by re-dealing this seed.
|
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Draw mode the recorded game was played in.
|
/// Draw mode the recorded game was played in.
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -129,10 +119,11 @@ pub struct Replay {
|
|||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
/// ISO-8601 date the win was recorded.
|
/// ISO-8601 date the win was recorded.
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
/// The dealt board plus the ordered instruction list, serialised via the
|
/// Ordered move list. Each entry is the atomic
|
||||||
/// upstream `card_game` session serializers. Self-contained: playback
|
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||||
/// needs nothing else to reproduce the game move-for-move.
|
/// issued, replayable against a fresh `GameState` constructed from the
|
||||||
pub recording: SessionRecording,
|
/// seed via `GameState::apply_instruction`.
|
||||||
|
pub moves: Vec<KlondikeInstruction>,
|
||||||
/// Public share URL for this replay on the active sync backend, set
|
/// Public share URL for this replay on the active sync backend, set
|
||||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||||
/// task resolves. `None` when the player won on a local-only
|
/// task resolves. `None` when the player won on a local-only
|
||||||
@@ -142,12 +133,11 @@ pub struct Replay {
|
|||||||
/// [`REPLAY_SCHEMA_VERSION`].
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub share_url: Option<String>,
|
pub share_url: Option<String>,
|
||||||
/// Index into the [`recording`](Self::recording)'s instruction list
|
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||||
/// of the move that triggered the win condition (i.e. completed the
|
/// the win condition (i.e. completed the last foundation pile).
|
||||||
/// last foundation pile).
|
|
||||||
///
|
///
|
||||||
/// For replays recorded by the live engine this is always
|
/// For replays recorded by the live engine this is always
|
||||||
/// `Some(recording.len() - 1)` because recording freezes on win — but
|
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||||
/// the field is stored explicitly so the playback UI can read it
|
/// the field is stored explicitly so the playback UI can read it
|
||||||
/// directly without re-deriving "the last move was the win" each
|
/// directly without re-deriving "the last move was the win" each
|
||||||
/// time, and to leave room for future recording semantics that
|
/// time, and to leave room for future recording semantics that
|
||||||
@@ -182,7 +172,7 @@ impl Replay {
|
|||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
recorded_at: NaiveDate,
|
recorded_at: NaiveDate,
|
||||||
recording: SessionRecording,
|
moves: Vec<KlondikeInstruction>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
@@ -192,7 +182,7 @@ impl Replay {
|
|||||||
time_seconds,
|
time_seconds,
|
||||||
final_score,
|
final_score,
|
||||||
recorded_at,
|
recorded_at,
|
||||||
recording,
|
moves,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
win_move_index: None,
|
win_move_index: None,
|
||||||
}
|
}
|
||||||
@@ -203,7 +193,7 @@ impl Replay {
|
|||||||
/// [`Replay::new`]:
|
/// [`Replay::new`]:
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1));
|
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// `None` is a valid input — useful for tests that don't care about
|
/// `None` is a valid input — useful for tests that don't care about
|
||||||
@@ -440,8 +430,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
|
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||||
KlondikePileStack, Tableau,
|
|
||||||
};
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
@@ -458,22 +447,18 @@ mod tests {
|
|||||||
134,
|
134,
|
||||||
5_120,
|
5_120,
|
||||||
date,
|
date,
|
||||||
SessionRecording::from_instructions_unchecked(
|
vec![
|
||||||
12345,
|
KlondikeInstruction::RotateStock,
|
||||||
DrawStockConfig::DrawThree,
|
KlondikeInstruction::DstTableau(DstTableau {
|
||||||
[
|
src: KlondikePileStack::Stock,
|
||||||
KlondikeInstruction::RotateStock,
|
tableau: Tableau::Tableau4,
|
||||||
KlondikeInstruction::DstTableau(DstTableau {
|
}),
|
||||||
src: KlondikePileStack::Stock,
|
KlondikeInstruction::RotateStock,
|
||||||
tableau: Tableau::Tableau4,
|
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
}),
|
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
KlondikeInstruction::RotateStock,
|
foundation: Foundation::Foundation1,
|
||||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
}),
|
||||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
],
|
||||||
foundation: Foundation::Foundation1,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,22 +518,25 @@ mod tests {
|
|||||||
/// rolling history wiped on the v0.19.0 update.
|
/// rolling history wiped on the v0.19.0 update.
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_loads_when_share_url_field_is_absent() {
|
fn replay_loads_when_share_url_field_is_absent() {
|
||||||
// Build a current-schema JSON object, then strip the optional
|
let pre_v019_json = format!(
|
||||||
// fields to simulate a file written before they existed.
|
r#"{{
|
||||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
"schema_version": {schema},
|
||||||
let obj = value.as_object_mut().expect("replay serialises as object");
|
"seed": 1,
|
||||||
obj.remove("share_url");
|
"draw_mode": "DrawOne",
|
||||||
obj.remove("win_move_index");
|
"mode": "Classic",
|
||||||
let parsed: Replay = serde_json::from_value(value)
|
"time_seconds": 60,
|
||||||
.expect("replay JSON without optional fields must still deserialise");
|
"final_score": 100,
|
||||||
|
"recorded_at": "2025-01-01",
|
||||||
|
"moves": []
|
||||||
|
}}"#,
|
||||||
|
schema = REPLAY_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
||||||
|
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
||||||
assert!(
|
assert!(
|
||||||
parsed.share_url.is_none(),
|
parsed.share_url.is_none(),
|
||||||
"missing share_url field must default to None",
|
"missing share_url field must default to None",
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
parsed.win_move_index.is_none(),
|
|
||||||
"missing win_move_index field must default to None",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||||
@@ -600,11 +588,7 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
date,
|
date,
|
||||||
SessionRecording::from_instructions_unchecked(
|
vec![KlondikeInstruction::RotateStock],
|
||||||
id as u64,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
[KlondikeInstruction::RotateStock],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,14 +824,22 @@ mod tests {
|
|||||||
let path = tmp_path("legacy_no_win_move_index");
|
let path = tmp_path("legacy_no_win_move_index");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Current-schema replay JSON with the win_move_index field stripped —
|
// Hand-rolled minimal current-schema replay JSON with no
|
||||||
// the additive field must still default to None.
|
// win_move_index field — the additive field must still default to None.
|
||||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
let no_field = format!(
|
||||||
value
|
r#"{{
|
||||||
.as_object_mut()
|
"schema_version": {schema},
|
||||||
.expect("replay serialises as object")
|
"seed": 1,
|
||||||
.remove("win_move_index");
|
"draw_mode": "DrawOne",
|
||||||
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2026-05-02",
|
||||||
|
"moves": []
|
||||||
|
}}"#,
|
||||||
|
schema = REPLAY_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
fs::write(&path, no_field).expect("write fixture");
|
||||||
|
|
||||||
let loaded = load_latest_replay_from(&path).expect("load");
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
assert_eq!(loaded.win_move_index, None);
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
|||||||
@@ -1343,9 +1343,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
|
|
||||||
};
|
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||||
@@ -1366,11 +1364,7 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
SessionRecording::from_instructions_unchecked(
|
vec![KlondikeInstruction::RotateStock],
|
||||||
1,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
[KlondikeInstruction::RotateStock],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1414,8 +1408,12 @@ mod tests {
|
|||||||
|
|
||||||
// Frame 1: enter Playing. The observer's first sample sees
|
// Frame 1: enter Playing. The observer's first sample sees
|
||||||
// `last_was_playing = false` and `now_playing = true`.
|
// `last_was_playing = false` and `now_playing = true`.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
replay: dummy_replay(),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
assert!(
|
||||||
!cinephile_unlocked(&app),
|
!cinephile_unlocked(&app),
|
||||||
@@ -1444,8 +1442,12 @@ mod tests {
|
|||||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
replay: dummy_replay(),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Direct Playing → Inactive — the path the Stop button takes via
|
// Direct Playing → Inactive — the path the Stop button takes via
|
||||||
@@ -1471,8 +1473,12 @@ mod tests {
|
|||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
// First completion cycle to unlock.
|
// First completion cycle to unlock.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
replay: dummy_replay(),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1490,8 +1496,12 @@ mod tests {
|
|||||||
// Second cycle: Inactive → Playing → Completed once more.
|
// Second cycle: Inactive → Playing → Completed once more.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
replay: dummy_replay(),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1510,8 +1520,12 @@ mod tests {
|
|||||||
fn cinephile_fires_once_across_completed_linger() {
|
fn cinephile_fires_once_across_completed_linger() {
|
||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
replay: dummy_replay(),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -201,6 +201,27 @@ pub(crate) fn advance_card_animations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Win cascade
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Win-cascade scatter targets — 8 points beyond the window edges.
|
||||||
|
///
|
||||||
|
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
|
||||||
|
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
|
||||||
|
let r = radius;
|
||||||
|
[
|
||||||
|
Vec2::new(r, r),
|
||||||
|
Vec2::new(-r, r),
|
||||||
|
Vec2::new(r, -r),
|
||||||
|
Vec2::new(-r, -r),
|
||||||
|
Vec2::new(0.0, r),
|
||||||
|
Vec2::new(0.0, -r),
|
||||||
|
Vec2::new(r, 0.0),
|
||||||
|
Vec2::new(-r, 0.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -283,4 +304,21 @@ mod tests {
|
|||||||
.with_z_lift(12.0);
|
.with_z_lift(12.0);
|
||||||
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_scatter_has_eight_targets() {
|
||||||
|
let targets = win_scatter_targets(800.0);
|
||||||
|
assert_eq!(targets.len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_scatter_targets_are_off_center() {
|
||||||
|
for t in win_scatter_targets(400.0) {
|
||||||
|
let dist = t.length();
|
||||||
|
assert!(
|
||||||
|
dist > 100.0,
|
||||||
|
"scatter target should be well off-center: {t:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
//! ));
|
//! ));
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! # Win cascade with `Expressive` curve
|
||||||
|
//!
|
||||||
|
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||||
|
//! (linear). To use the curve-based cascade instead, disable
|
||||||
|
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
|
||||||
|
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
|
||||||
|
//!
|
||||||
|
//! They **must not both be active** — both write to `Transform` on the same
|
||||||
|
//! 52 entities and will race.
|
||||||
|
//!
|
||||||
//! # Coexistence rules
|
//! # Coexistence rules
|
||||||
//!
|
//!
|
||||||
//! | Condition | Safe? |
|
//! | Condition | Safe? |
|
||||||
@@ -48,21 +58,24 @@ pub mod interaction;
|
|||||||
pub mod timing;
|
pub mod timing;
|
||||||
pub mod tuning;
|
pub mod tuning;
|
||||||
|
|
||||||
pub use animation::CardAnimation;
|
pub use animation::{CardAnimation, win_scatter_targets};
|
||||||
pub use chain::AnimationChain;
|
pub use chain::AnimationChain;
|
||||||
pub use curves::{MotionCurve, sample_curve};
|
pub use curves::{MotionCurve, sample_curve};
|
||||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||||
pub use timing::{
|
pub use timing::{
|
||||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary,
|
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||||
|
cascade_delay, compute_duration, micro_vary,
|
||||||
};
|
};
|
||||||
pub use tuning::{AnimationTuning, InputPlatform};
|
pub use tuning::{AnimationTuning, InputPlatform};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
use crate::resources::DragState;
|
use crate::resources::DragState;
|
||||||
|
|
||||||
use animation::advance_card_animations;
|
use animation::advance_card_animations;
|
||||||
@@ -131,6 +144,63 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Optional: win cascade with Expressive curve
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
|
||||||
|
/// with an `Expressive`-curve cascade.
|
||||||
|
///
|
||||||
|
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
|
||||||
|
/// will race on the same card entities. To use this plugin, prevent
|
||||||
|
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
|
||||||
|
/// win toasts manually).
|
||||||
|
pub struct WinCascadePlugin;
|
||||||
|
|
||||||
|
impl Plugin for WinCascadePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
|
||||||
|
///
|
||||||
|
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
|
||||||
|
/// creates a "burst" effect as cards fly outward.
|
||||||
|
fn trigger_expressive_win_cascade(
|
||||||
|
mut events: MessageReader<GameWonEvent>,
|
||||||
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||||
|
|
||||||
|
let targets = win_scatter_targets(radius);
|
||||||
|
|
||||||
|
for (index, (entity, transform)) in cards.iter().enumerate() {
|
||||||
|
let start_xy = transform.translation.truncate();
|
||||||
|
let start_z = transform.translation.z;
|
||||||
|
let target = targets[index % targets.len()];
|
||||||
|
|
||||||
|
commands.entity(entity).insert(
|
||||||
|
CardAnimation::slide(
|
||||||
|
start_xy,
|
||||||
|
start_z,
|
||||||
|
target,
|
||||||
|
start_z + 60.0,
|
||||||
|
MotionCurve::Expressive,
|
||||||
|
)
|
||||||
|
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
|
||||||
|
.with_duration(0.65)
|
||||||
|
.with_z_lift(25.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -363,4 +433,19 @@ mod tests {
|
|||||||
let state = HoverState::default();
|
let state = HoverState::default();
|
||||||
assert!(state.entity.is_none());
|
assert!(state.entity.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_scatter_produces_eight_distinct_points() {
|
||||||
|
let targets = win_scatter_targets(600.0);
|
||||||
|
assert_eq!(targets.len(), 8);
|
||||||
|
// All must be different.
|
||||||
|
for i in 0..8 {
|
||||||
|
for j in (i + 1)..8 {
|
||||||
|
assert_ne!(
|
||||||
|
targets[i], targets[j],
|
||||||
|
"scatter targets {i} and {j} must be distinct"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
|
|||||||
duration * (1.0 + variation)
|
duration * (1.0 + variation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
|
||||||
|
///
|
||||||
|
/// `delay = index × interval_secs`.
|
||||||
|
#[inline]
|
||||||
|
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
|
||||||
|
index as f32 * interval_secs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommended per-card interval for the win cascade (Normal speed).
|
||||||
|
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
|
||||||
|
|
||||||
/// Recommended per-card interval for deal animations (Normal speed).
|
/// Recommended per-card interval for deal animations (Normal speed).
|
||||||
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
||||||
|
|
||||||
@@ -126,4 +137,22 @@ mod tests {
|
|||||||
"micro_vary should differ for different indices"
|
"micro_vary should differ for different indices"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_delay_zero_index_is_zero() {
|
||||||
|
assert_eq!(cascade_delay(0, 0.018), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_delay_scales_linearly() {
|
||||||
|
let interval = 0.018;
|
||||||
|
for i in 0..52usize {
|
||||||
|
let expected = i as f32 * interval;
|
||||||
|
let actual = cascade_delay(i, interval);
|
||||||
|
assert!(
|
||||||
|
(actual - expected).abs() < 1e-6,
|
||||||
|
"cascade_delay({i}) = {actual}, expected {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use solitaire_core::KlondikePile;
|
|||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
|
use solitaire_sync::SyncResponse;
|
||||||
|
|
||||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||||
/// consumed by `GamePlugin`.
|
/// consumed by `GamePlugin`.
|
||||||
@@ -247,6 +248,16 @@ pub struct ToggleLeaderboardRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ToggleHomeRequestEvent;
|
pub struct ToggleHomeRequestEvent;
|
||||||
|
|
||||||
|
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
||||||
|
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
||||||
|
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
||||||
|
/// human-readable failure message (network, auth, serialization, etc.).
|
||||||
|
///
|
||||||
|
/// UI systems listen for this to refresh views without polling
|
||||||
|
/// `SyncStatusResource`. See [ARCHITECTURE.md §4](../../ARCHITECTURE.md).
|
||||||
|
#[derive(Message, Debug, Clone)]
|
||||||
|
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
||||||
|
|
||||||
/// Generic informational toast message. Any system can fire this to display
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
|
|||||||
@@ -1062,16 +1062,11 @@ pub fn record_replay_on_win(
|
|||||||
if recording.moves.is_empty() {
|
if recording.moves.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The session itself is the authoritative recording: its history
|
|
||||||
// holds the dealt board plus the forward instruction list (undos
|
|
||||||
// already popped), and it serialises via the upstream card_game
|
|
||||||
// serializers so playback never re-deals from the seed.
|
|
||||||
let session_recording = game.0.recording();
|
|
||||||
// Recording freezes on win, so the move that triggered the
|
// Recording freezes on win, so the move that triggered the
|
||||||
// win condition is the last one in the list. Storing the
|
// win condition is the last one in the list. Storing the
|
||||||
// index explicitly lets the playback UI read the WIN MOVE
|
// index explicitly lets the playback UI read the WIN MOVE
|
||||||
// position directly instead of re-deriving it on every render.
|
// position directly instead of re-deriving it on every render.
|
||||||
let win_move_index = session_recording.len().checked_sub(1);
|
let win_move_index = recording.moves.len().checked_sub(1);
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode(),
|
game.0.draw_mode(),
|
||||||
@@ -1079,7 +1074,7 @@ pub fn record_replay_on_win(
|
|||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
Utc::now().date_naive(),
|
Utc::now().date_naive(),
|
||||||
session_recording,
|
recording.moves.clone(),
|
||||||
)
|
)
|
||||||
.with_win_move_index(win_move_index);
|
.with_win_move_index(win_move_index);
|
||||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||||
|
|||||||
@@ -853,15 +853,17 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
|||||||
let mut app = test_app(7654);
|
let mut app = test_app(7654);
|
||||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||||
|
|
||||||
// Drive two real draws so the *session* history (the source the
|
// Push two recorded instructions manually so we can verify they
|
||||||
// freeze now serialises from, via `GameState::recording()`) holds
|
// survive the freeze/save round-trip without having to drive a
|
||||||
// two instructions. `RotateStock` is the only instruction the
|
// real win. Both are `RotateStock` — the only instruction
|
||||||
// engine can drive without the runtime-only `klondike` pile-stack
|
// constructible without the runtime-only `klondike` pile-stack
|
||||||
// types; the round-trip shape is identical for any variant.
|
// types (which the engine intentionally does not depend on); the
|
||||||
app.world_mut().write_message(DrawRequestEvent);
|
// round-trip shape is identical for any instruction variant.
|
||||||
app.update();
|
{
|
||||||
app.world_mut().write_message(DrawRequestEvent);
|
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||||
app.update();
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
|
}
|
||||||
|
|
||||||
// Fire the win event the engine emits when the last foundation
|
// Fire the win event the engine emits when the last foundation
|
||||||
// completes — `record_replay_on_win` listens for it.
|
// completes — `record_replay_on_win` listens for it.
|
||||||
@@ -893,15 +895,9 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
|||||||
loaded.time_seconds, 250,
|
loaded.time_seconds, 250,
|
||||||
"time_seconds must come from the win event"
|
"time_seconds must come from the win event"
|
||||||
);
|
);
|
||||||
let instructions = loaded.recording.instructions();
|
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
|
||||||
assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
|
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
|
||||||
assert!(matches!(instructions[0], KlondikeInstruction::RotateStock));
|
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
|
||||||
assert!(matches!(instructions[1], KlondikeInstruction::RotateStock));
|
|
||||||
assert_eq!(
|
|
||||||
loaded.win_move_index,
|
|
||||||
Some(1),
|
|
||||||
"win move index must point at the last recorded instruction",
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|||||||
@@ -1370,37 +1370,17 @@ pub fn best_tableau_destination_for_stack(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide the auto-move for the face-up run headed by the clicked/tapped card.
|
|
||||||
///
|
|
||||||
/// The move covers **exactly** `run_len` cards — the run from the clicked
|
|
||||||
/// card to the top of its pile. A lone top card goes to its best foundation
|
|
||||||
/// (or tableau) destination; a multi-card run goes whole to the best tableau
|
|
||||||
/// column. Runs larger or smaller than the clicked one are never considered.
|
|
||||||
///
|
|
||||||
/// Returns `(destination, count)`, or `None` when the clicked run has no
|
|
||||||
/// legal destination.
|
|
||||||
pub fn auto_move_for_run(
|
|
||||||
clicked_card: &Card,
|
|
||||||
pile: &KlondikePile,
|
|
||||||
game: &GameState,
|
|
||||||
run_len: usize,
|
|
||||||
) -> Option<(KlondikePile, usize)> {
|
|
||||||
if run_len == 1 {
|
|
||||||
best_destination(clicked_card, game).map(|dest| (dest, 1))
|
|
||||||
} else {
|
|
||||||
best_tableau_destination_for_stack(clicked_card, pile, game, run_len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
|
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
|
||||||
/// to the best legal destination.
|
/// to the best legal destination.
|
||||||
///
|
///
|
||||||
/// The move covers exactly the face-up run headed by the clicked card —
|
/// Move priority:
|
||||||
/// see [`auto_move_for_run`].
|
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
|
||||||
|
/// 2. If no single-card move exists and the clicked card is the base of a
|
||||||
|
/// multi-card face-up stack, move the whole stack to the best tableau column.
|
||||||
///
|
///
|
||||||
/// When the clicked run has no legal destination, fires `MoveRejectedEvent`
|
/// When a multi-card stack double-click finds no legal destination (Priority 2
|
||||||
/// with `from == to == pile` so the invalid-move sound plays and the source
|
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
|
||||||
/// pile cards shake as feedback.
|
/// invalid-move sound plays and the source pile cards shake as feedback.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_double_click(
|
fn handle_double_click(
|
||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
@@ -1431,11 +1411,7 @@ fn handle_double_click(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The clicked card heads the run and keys the double-click: two clicks
|
// The topmost card in the draggable run — used as the double-click key.
|
||||||
// on different cards of the same stack are not a double-click.
|
|
||||||
let Some(clicked_card) = card_ids.first() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(top_card) = card_ids.last() else {
|
let Some(top_card) = card_ids.last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1450,15 +1426,31 @@ fn handle_double_click(
|
|||||||
|
|
||||||
let now = time.elapsed_secs();
|
let now = time.elapsed_secs();
|
||||||
let prev = last_click
|
let prev = last_click
|
||||||
.get(clicked_card)
|
.get(top_card)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(f32::NEG_INFINITY);
|
.unwrap_or(f32::NEG_INFINITY);
|
||||||
|
|
||||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||||
// Double-click confirmed.
|
// Double-click confirmed.
|
||||||
last_click.remove(clicked_card);
|
last_click.remove(top_card);
|
||||||
|
|
||||||
if let Some((dest, count)) = auto_move_for_run(clicked_card, &pile, &game.0, card_ids.len())
|
// Priority 1: move the single top card (foundation preferred, then tableau).
|
||||||
|
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: pile,
|
||||||
|
to: dest,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: if the player clicked the base of a multi-card face-up
|
||||||
|
// stack (card_ids.len() > 1), try moving the whole stack to another
|
||||||
|
// tableau column.
|
||||||
|
if card_ids.len() > 1
|
||||||
|
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
||||||
|
&& let Some((dest, count)) =
|
||||||
|
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
|
||||||
{
|
{
|
||||||
moves.write(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile,
|
from: pile,
|
||||||
@@ -1468,10 +1460,14 @@ fn handle_double_click(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No legal destination for the clicked run — play the invalid-move
|
// Both priorities failed — play the invalid-move sound and shake
|
||||||
// sound and shake the source pile as feedback. `MoveRejectedEvent`
|
// the source pile as feedback. `MoveRejectedEvent` with
|
||||||
// with `from == to` routes the shake to the source pile (which
|
// `from == to` routes the shake to the source pile (which
|
||||||
// `start_shake_anim` reads from `ev.to`).
|
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
|
||||||
|
// only fired for multi-card stacks, so a double-click on a
|
||||||
|
// single card with no legal destination did nothing — no
|
||||||
|
// sound, no shake. Now both single-card and stack misses get
|
||||||
|
// the same feedback.
|
||||||
rejected.write(MoveRejectedEvent {
|
rejected.write(MoveRejectedEvent {
|
||||||
from: pile,
|
from: pile,
|
||||||
to: pile,
|
to: pile,
|
||||||
@@ -1479,7 +1475,7 @@ fn handle_double_click(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Single click — record the time.
|
// Single click — record the time.
|
||||||
last_click.insert(clicked_card.clone(), now);
|
last_click.insert(top_card.clone(), now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1495,9 +1491,10 @@ fn handle_double_click(
|
|||||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||||
/// are cleared and the tap/drag distinction is permanently lost.
|
/// are cleared and the tap/drag distinction is permanently lost.
|
||||||
///
|
///
|
||||||
/// The move covers exactly the face-up run headed by the tapped card —
|
/// Move priority:
|
||||||
/// see [`auto_move_for_run`]. Fires `MoveRejectedEvent` for audio + shake
|
/// 1. Single top card to its best foundation (or tableau).
|
||||||
/// feedback when the tapped run has no legal destination.
|
/// 2. Whole face-up run to best tableau column when no single-card move exists.
|
||||||
|
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_double_tap(
|
fn handle_double_tap(
|
||||||
mut touch_events: MessageReader<TouchInput>,
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
@@ -1557,7 +1554,8 @@ fn handle_double_tap(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((_, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card) else {
|
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !*found_face_up {
|
if !*found_face_up {
|
||||||
@@ -1593,27 +1591,53 @@ fn handle_double_tap(
|
|||||||
|
|
||||||
// --- One-tap auto-move (original behaviour) ---
|
// --- One-tap auto-move (original behaviour) ---
|
||||||
|
|
||||||
// Move exactly the run headed by the tapped card.
|
// Priority 1: move single top card.
|
||||||
if let Some(tapped_card) = drag.cards.first()
|
if let Some(dest) = best_destination(found_card, &game.0) {
|
||||||
&& let Some((dest, count)) =
|
|
||||||
auto_move_for_run(tapped_card, tapped_pile, &game.0, drag.cards.len())
|
|
||||||
{
|
|
||||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||||
if drag.cards.contains(&ce.card) {
|
if ce.card == *top_card {
|
||||||
sprite.color = STATE_SUCCESS;
|
sprite.color = STATE_SUCCESS;
|
||||||
commands.entity(entity).insert(HintHighlight {
|
commands.entity(entity).insert(HintHighlight {
|
||||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moves.write(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: *tapped_pile,
|
from: *tapped_pile,
|
||||||
to: dest,
|
to: dest,
|
||||||
count,
|
count: 1,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 2: move whole face-up stack to best tableau column.
|
||||||
|
if drag.cards.len() > 1 {
|
||||||
|
let stack_index = pile_cards.len() - drag.cards.len();
|
||||||
|
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
||||||
|
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||||
|
bottom_card,
|
||||||
|
tapped_pile,
|
||||||
|
&game.0,
|
||||||
|
drag.cards.len(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||||
|
if drag.cards.contains(&ce.card) {
|
||||||
|
sprite.color = STATE_SUCCESS;
|
||||||
|
commands.entity(entity).insert(HintHighlight {
|
||||||
|
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: *tapped_pile,
|
||||||
|
to: dest,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rejected.write(MoveRejectedEvent {
|
rejected.write(MoveRejectedEvent {
|
||||||
from: *tapped_pile,
|
from: *tapped_pile,
|
||||||
to: *tapped_pile,
|
to: *tapped_pile,
|
||||||
|
|||||||
@@ -429,118 +429,6 @@ fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// auto_move_for_run pure-function tests (issue #158)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// These need real positions — `can_move_cards` validates against the live
|
|
||||||
// session, not the `set_test_*` overlays. Seeds 51 and 145 both deal an
|
|
||||||
// Ace and its Two on tableau tops plus an opposite-color Three elsewhere,
|
|
||||||
// letting two moves build a face-up [Three, Two] run whose top card is
|
|
||||||
// foundation-eligible (the bait the pre-#158 code would take).
|
|
||||||
|
|
||||||
/// Deal `seed`, send the Ace on tableau 1 to its foundation, then stack the
|
|
||||||
/// matching Two from `two_from` onto the opposite-color Three on `run_on`.
|
|
||||||
/// Returns the game with a 2-card face-up run on `run_on`.
|
|
||||||
fn deal_run_with_foundation_bait(seed: u64, two_from: Tableau, run_on: Tableau) -> GameState {
|
|
||||||
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
|
||||||
let (ace, _) = game
|
|
||||||
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
|
||||||
.last()
|
|
||||||
.cloned()
|
|
||||||
.expect("seed deals a card on tableau 1");
|
|
||||||
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
|
||||||
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
|
||||||
.expect("ace moves to foundation");
|
|
||||||
game.move_cards(
|
|
||||||
KlondikePile::Tableau(two_from),
|
|
||||||
KlondikePile::Tableau(run_on),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.expect("two stacks onto three");
|
|
||||||
game
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_move_for_run_moves_exact_clicked_run_not_top_card() {
|
|
||||||
let game = deal_run_with_foundation_bait(51, Tableau::Tableau6, Tableau::Tableau5);
|
|
||||||
let run_pile = KlondikePile::Tableau(Tableau::Tableau5);
|
|
||||||
let cards = game.pile(run_pile);
|
|
||||||
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
|
||||||
let (clicked, _) = cards[cards.len() - 2].clone();
|
|
||||||
|
|
||||||
// The bait: the lone top card has a foundation move available.
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
best_destination(&top, &game),
|
|
||||||
Some(KlondikePile::Foundation(_))
|
|
||||||
),
|
|
||||||
"precondition: run top card must be foundation-eligible"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clicking the run base must move exactly the 2-card run to a tableau.
|
|
||||||
match auto_move_for_run(&clicked, &run_pile, &game, 2) {
|
|
||||||
Some((KlondikePile::Tableau(dest), 2)) => assert_ne!(dest, Tableau::Tableau5),
|
|
||||||
other => panic!("expected a whole-run tableau move, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_move_for_run_rejects_when_clicked_run_cannot_move() {
|
|
||||||
let game = deal_run_with_foundation_bait(145, Tableau::Tableau4, Tableau::Tableau7);
|
|
||||||
let run_pile = KlondikePile::Tableau(Tableau::Tableau7);
|
|
||||||
let cards = game.pile(run_pile);
|
|
||||||
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
|
||||||
let (clicked, _) = cards[cards.len() - 2].clone();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
best_destination(&top, &game),
|
|
||||||
Some(KlondikePile::Foundation(_))
|
|
||||||
),
|
|
||||||
"precondition: run top card must be foundation-eligible"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The 2-card run has no legal home — the top card's foundation move
|
|
||||||
// must NOT be taken as a substitute.
|
|
||||||
assert_eq!(
|
|
||||||
auto_move_for_run(&clicked, &run_pile, &game, 2),
|
|
||||||
None,
|
|
||||||
"an immovable clicked run must not fall back to a top-card move"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_move_for_run_single_card_prefers_foundation() {
|
|
||||||
// Seed 51 after the Ace reaches the foundation: the Two on tableau 6
|
|
||||||
// is a lone face-up card that could go to the foundation OR onto the
|
|
||||||
// Three on tableau 5. Foundation must win.
|
|
||||||
let mut game = GameState::new(51, DrawStockConfig::DrawOne);
|
|
||||||
let (ace, _) = game
|
|
||||||
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
|
||||||
.last()
|
|
||||||
.cloned()
|
|
||||||
.expect("seed 51 deals a card on tableau 1");
|
|
||||||
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
|
||||||
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
|
||||||
.expect("ace moves to foundation");
|
|
||||||
|
|
||||||
let two_pile = KlondikePile::Tableau(Tableau::Tableau6);
|
|
||||||
let (two, _) = game.pile(two_pile).last().cloned().expect("two on top");
|
|
||||||
assert!(
|
|
||||||
game.can_move_cards(&two_pile, &KlondikePile::Tableau(Tableau::Tableau5), 1),
|
|
||||||
"precondition: a tableau destination also exists"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
auto_move_for_run(&two, &two_pile, &game, 1),
|
|
||||||
Some((KlondikePile::Foundation(_), 1))
|
|
||||||
),
|
|
||||||
"a lone top card still prefers the foundation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Task #28 — find_hint pure-function tests
|
// Task #28 — find_hint pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
|||||||
pub use card_animation::{
|
pub use card_animation::{
|
||||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, compute_duration, micro_vary,
|
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
||||||
sample_curve,
|
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
|
||||||
|
win_scatter_targets,
|
||||||
};
|
};
|
||||||
pub use card_plugin::{
|
pub use card_plugin::{
|
||||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||||
@@ -106,7 +107,7 @@ pub use events::{
|
|||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
XpAwardedEvent,
|
XpAwardedEvent,
|
||||||
|
|||||||
@@ -122,8 +122,12 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
|||||||
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
||||||
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||||
match state {
|
match state {
|
||||||
ReplayPlaybackState::Playing { moves, cursor, .. } => {
|
ReplayPlaybackState::Playing { replay, cursor, .. } => {
|
||||||
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
|
format!(
|
||||||
|
"\u{258C} MOVE LOG \u{00B7} {}/{}",
|
||||||
|
cursor,
|
||||||
|
replay.moves.len()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
||||||
ReplayPlaybackState::Inactive => String::new(),
|
ReplayPlaybackState::Inactive => String::new(),
|
||||||
@@ -131,7 +135,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — formats the kth-most-recently-applied move's row
|
/// Pure helper — formats the kth-most-recently-applied move's row
|
||||||
/// text. `k = 1` is the active row (`moves[cursor - 1]`,
|
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
|
||||||
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
||||||
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
||||||
/// and so on.
|
/// and so on.
|
||||||
@@ -143,14 +147,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
|||||||
/// for k=1 and k=2 only, k=3 returns empty).
|
/// for k=1 and k=2 only, k=3 returns empty).
|
||||||
/// - The move list is shorter than expected (defensive guard).
|
/// - The move list is shorter than expected (defensive guard).
|
||||||
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||||
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
if k == 0 || k > *cursor {
|
if k == 0 || k > *cursor {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let zero_idx = *cursor - k;
|
let zero_idx = *cursor - k;
|
||||||
let Some(m) = moves.get(zero_idx) else {
|
let Some(m) = replay.moves.get(zero_idx) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let display_idx = *cursor - k + 1;
|
let display_idx = *cursor - k + 1;
|
||||||
@@ -158,7 +162,7 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
|
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
|
||||||
/// is the move that will apply next (`moves[cursor]`,
|
/// is the move that will apply next (`replay.moves[cursor]`,
|
||||||
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
||||||
/// and so on.
|
/// and so on.
|
||||||
///
|
///
|
||||||
@@ -170,14 +174,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
|||||||
/// replay — late in the move list, the trailing next rows
|
/// replay — late in the move list, the trailing next rows
|
||||||
/// stay empty).
|
/// stay empty).
|
||||||
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||||
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
if k == 0 {
|
if k == 0 {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let zero_idx = *cursor + k - 1;
|
let zero_idx = *cursor + k - 1;
|
||||||
let Some(m) = moves.get(zero_idx) else {
|
let Some(m) = replay.moves.get(zero_idx) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let display_idx = *cursor + k;
|
let display_idx = *cursor + k;
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
|
|||||||
/// Marker on a "next move" row below the active row. `offset`
|
/// Marker on a "next move" row below the active row. `offset`
|
||||||
/// is the 1-based distance forward from the active row:
|
/// is the 1-based distance forward from the active row:
|
||||||
/// `offset = 1` is the move that will apply next
|
/// `offset = 1` is the move that will apply next
|
||||||
/// (`moves[cursor]`, displayed as `cursor + 1`),
|
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
||||||
/// `offset = 2` is the one after that, and so on. Up to
|
/// `offset = 2` is the one after that, and so on. Up to
|
||||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
||||||
///
|
///
|
||||||
@@ -1258,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str {
|
|||||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||||
/// position the marker outside the track.
|
/// position the marker outside the track.
|
||||||
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||||
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
|
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let idx = replay.win_move_index?;
|
let idx = replay.win_move_index?;
|
||||||
let total = moves.len();
|
let total = replay.moves.len();
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
use solitaire_core::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
@@ -17,11 +17,9 @@ fn synthetic_replay(move_count: usize) -> Replay {
|
|||||||
120,
|
120,
|
||||||
1_000,
|
1_000,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||||
SessionRecording::from_instructions_unchecked(
|
(0..move_count)
|
||||||
42,
|
.map(|_| KlondikeInstruction::RotateStock)
|
||||||
DrawStockConfig::DrawOne,
|
.collect(),
|
||||||
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +93,12 @@ fn overlay_spawns_when_playback_starts() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -114,7 +117,12 @@ fn overlay_progress_text_reflects_cursor() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 5,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -130,7 +138,12 @@ fn overlay_stop_button_click_clears_playback() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(overlay_root_count(&mut app), 1);
|
assert_eq!(overlay_root_count(&mut app), 1);
|
||||||
@@ -182,7 +195,12 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -214,7 +232,12 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(3),
|
||||||
|
cursor: 1,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(overlay_root_count(&mut app), 1);
|
assert_eq!(overlay_root_count(&mut app), 1);
|
||||||
@@ -237,7 +260,12 @@ fn overlay_text_changes_on_completed() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(7),
|
||||||
|
cursor: 7,
|
||||||
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
||||||
@@ -286,30 +314,30 @@ fn scrub_pct_covers_state_corners() {
|
|||||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
||||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::playing(
|
scrub_pct(&ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(10),
|
replay: synthetic_replay(10),
|
||||||
0,
|
cursor: 0,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
)),
|
}),
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::playing(
|
scrub_pct(&ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(10),
|
replay: synthetic_replay(10),
|
||||||
5,
|
cursor: 5,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
)),
|
}),
|
||||||
50.0,
|
50.0,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::playing(
|
scrub_pct(&ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(10),
|
replay: synthetic_replay(10),
|
||||||
10,
|
cursor: 10,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
)),
|
}),
|
||||||
100.0,
|
100.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,12 +367,12 @@ fn format_game_caption_covers_state_corners() {
|
|||||||
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
||||||
// uses this date so the assertion is stable.
|
// uses this date so the assertion is stable.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_game_caption(&ReplayPlaybackState::playing(
|
format_game_caption(&ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(10),
|
replay: synthetic_replay(10),
|
||||||
5,
|
cursor: 5,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
)),
|
}),
|
||||||
Some("GAME #2026-122".to_string()),
|
Some("GAME #2026-122".to_string()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -353,7 +381,12 @@ fn format_game_caption_covers_state_corners() {
|
|||||||
let mut early_january = synthetic_replay(10);
|
let mut early_january = synthetic_replay(10);
|
||||||
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
|
format_game_caption(&ReplayPlaybackState::Playing {
|
||||||
|
replay: early_january,
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
}),
|
||||||
Some("GAME #2026-005".to_string()),
|
Some("GAME #2026-005".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -366,7 +399,12 @@ fn overlay_game_caption_shows_replay_date() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
||||||
@@ -393,7 +431,12 @@ fn overlay_scrub_fill_tracks_cursor() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(8),
|
||||||
|
cursor: 2,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -404,7 +447,12 @@ fn overlay_scrub_fill_tracks_cursor() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(8),
|
||||||
|
cursor: 6,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -449,7 +497,12 @@ fn win_move_marker_pct_is_none_for_completed() {
|
|||||||
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
||||||
// Synthetic replay constructor leaves win_move_index as None
|
// Synthetic replay constructor leaves win_move_index as None
|
||||||
// (legacy / pre-`ab857bb` path).
|
// (legacy / pre-`ab857bb` path).
|
||||||
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
let state = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
assert_eq!(win_move_marker_pct(&state), None);
|
assert_eq!(win_move_marker_pct(&state), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,12 +511,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
|||||||
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
||||||
// Matches the recording semantic: cursor reaches the marker
|
// Matches the recording semantic: cursor reaches the marker
|
||||||
// exactly when the about-to-apply move IS the win move.
|
// exactly when the about-to-apply move IS the win move.
|
||||||
let state = ReplayPlaybackState::playing(
|
let state = ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(10).with_win_move_index(Some(9)),
|
replay: synthetic_replay(10).with_win_move_index(Some(9)),
|
||||||
0,
|
cursor: 0,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
);
|
};
|
||||||
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,12 +524,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
|||||||
fn win_move_marker_pct_clamps_to_track_bounds() {
|
fn win_move_marker_pct_clamps_to_track_bounds() {
|
||||||
// Defensive: if a malformed replay carried `win_move_index >=
|
// Defensive: if a malformed replay carried `win_move_index >=
|
||||||
// total`, the marker must still sit on the track, not past it.
|
// total`, the marker must still sit on the track, not past it.
|
||||||
let state = ReplayPlaybackState::playing(
|
let state = ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(5).with_win_move_index(Some(99)),
|
replay: synthetic_replay(5).with_win_move_index(Some(99)),
|
||||||
0,
|
cursor: 0,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
);
|
};
|
||||||
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,12 +538,12 @@ fn marker_spawned_when_replay_has_win_move_index() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(
|
ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
0,
|
cursor: 0,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -506,7 +559,12 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
|
|||||||
// Default constructor → win_move_index: None (legacy replay).
|
// Default constructor → win_move_index: None (legacy replay).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(8),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -521,12 +579,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(
|
ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
0,
|
cursor: 0,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(win_marker_count(&mut app), 1);
|
assert_eq!(win_marker_count(&mut app), 1);
|
||||||
@@ -550,12 +608,12 @@ fn win_move_marker_carries_hc_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(
|
ReplayPlaybackState::Playing {
|
||||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
0,
|
cursor: 0,
|
||||||
0.5,
|
secs_to_next: 0.5,
|
||||||
false,
|
paused: false,
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -611,7 +669,12 @@ fn scrub_notches_spawn_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -630,7 +693,12 @@ fn scrub_notches_carry_high_contrast_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -655,7 +723,12 @@ fn scrub_track_carries_high_contrast_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -686,7 +759,12 @@ fn scrub_notches_despawn_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(scrub_notch_count(&mut app), 5);
|
assert_eq!(scrub_notch_count(&mut app), 5);
|
||||||
@@ -743,7 +821,12 @@ fn scrub_notch_labels_spawn_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -761,7 +844,12 @@ fn scrub_notch_labels_carry_helper_strings() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -782,7 +870,12 @@ fn scrub_notch_labels_despawn_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
||||||
@@ -854,7 +947,12 @@ fn keybind_footer_spawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -872,7 +970,12 @@ fn keybind_footer_paints_helper_strings() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -896,7 +999,12 @@ fn keybind_footer_carries_high_contrast_border_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -917,7 +1025,12 @@ fn keybind_footer_despawns_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(keybind_footer_count(&mut app), 1);
|
assert_eq!(keybind_footer_count(&mut app), 1);
|
||||||
@@ -941,7 +1054,12 @@ fn scrub_notches_spawn_even_without_win_marker() {
|
|||||||
// Default constructor → win_move_index: None.
|
// Default constructor → win_move_index: None.
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(8),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1026,7 +1144,12 @@ fn format_move_body_handles_stock_cycle() {
|
|||||||
/// `Inactive` → empty.
|
/// `Inactive` → empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_move_log_header_covers_state_branches() {
|
fn format_move_log_header_covers_state_branches() {
|
||||||
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
let playing = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_move_log_header(&playing),
|
format_move_log_header(&playing),
|
||||||
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
||||||
@@ -1044,14 +1167,24 @@ fn format_move_log_header_covers_state_branches() {
|
|||||||
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
||||||
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
let cursor_zero = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_active_move_row(&cursor_zero),
|
format_active_move_row(&cursor_zero),
|
||||||
"",
|
"",
|
||||||
"cursor=0 means no move applied yet; row stays empty",
|
"cursor=0 means no move applied yet; row stays empty",
|
||||||
);
|
);
|
||||||
|
|
||||||
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
let cursor_three = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
// synthetic_replay produces all StockClicks, so the body
|
// synthetic_replay produces all StockClicks, so the body
|
||||||
// is "stock cycle". The displayed index is 3 (cursor),
|
// is "stock cycle". The displayed index is 3 (cursor),
|
||||||
// matching the most-recently-applied move at moves[2].
|
// matching the most-recently-applied move at moves[2].
|
||||||
@@ -1075,7 +1208,12 @@ fn move_log_panel_spawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1094,7 +1232,12 @@ fn move_log_panel_header_paints_helper_string() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(8),
|
||||||
|
cursor: 2,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1111,7 +1254,12 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1123,7 +1271,12 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 2,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1139,7 +1292,12 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
/// early in a replay don't paint stale text.
|
/// early in a replay don't paint stale text.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
||||||
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
let state_at_three = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
// k=1 → active (most recent applied). cursor=3 → display=3.
|
// k=1 → active (most recent applied). cursor=3 → display=3.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_kth_recent_row(&state_at_three, 1),
|
format_kth_recent_row(&state_at_three, 1),
|
||||||
@@ -1190,7 +1348,12 @@ fn move_log_prev_rows_spawn_with_panel() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1208,7 +1371,12 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 5,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1233,7 +1401,12 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
|||||||
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
|
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 2,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1249,7 +1422,12 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
|||||||
// Advance to cursor=5 — both offsets now have history.
|
// Advance to cursor=5 — both offsets now have history.
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 5,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1287,7 +1465,12 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
|
|||||||
/// alongside in-range correctness.
|
/// alongside in-range correctness.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
||||||
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
let state_at_three = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
// k=1 → moves[3], display=4
|
// k=1 → moves[3], display=4
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_kth_next_row(&state_at_three, 1),
|
format_kth_next_row(&state_at_three, 1),
|
||||||
@@ -1315,7 +1498,12 @@ fn move_log_next_rows_spawn_with_panel() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1332,7 +1520,12 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 5,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1356,7 +1549,12 @@ fn move_log_next_rows_underfill_at_replay_end() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 9,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1380,7 +1578,12 @@ fn active_row_wrapper_carries_accent_primary_background() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1418,7 +1621,12 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 3,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1439,14 +1647,24 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
|||||||
/// dropping it has to also update this test.
|
/// dropping it has to also update this test.
|
||||||
#[test]
|
#[test]
|
||||||
fn active_row_format_includes_focus_prefix() {
|
fn active_row_format_includes_focus_prefix() {
|
||||||
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
|
let state = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 5,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
let row = format_active_move_row(&state);
|
let row = format_active_move_row(&state);
|
||||||
assert!(
|
assert!(
|
||||||
row.starts_with('\u{25B6}'),
|
row.starts_with('\u{25B6}'),
|
||||||
"active-row format must start with ▶ focus marker; got {row:?}",
|
"active-row format must start with ▶ focus marker; got {row:?}",
|
||||||
);
|
);
|
||||||
// Cursor=0 still returns empty, never just the prefix.
|
// Cursor=0 still returns empty, never just the prefix.
|
||||||
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
let cursor_zero = ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_active_move_row(&cursor_zero),
|
format_active_move_row(&cursor_zero),
|
||||||
"",
|
"",
|
||||||
@@ -1461,7 +1679,12 @@ fn move_log_panel_despawns_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(10),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(move_log_panel_count(&mut app), 1);
|
assert_eq!(move_log_panel_count(&mut app), 1);
|
||||||
@@ -1505,11 +1728,21 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||||
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(replay_len),
|
||||||
|
cursor,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||||
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(replay_len),
|
||||||
|
cursor,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1958,7 +2191,12 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2017,7 +2255,12 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
|
|||||||
// any). `cursor` is the index of the *next* move to apply, so
|
// any). `cursor` is the index of the *next* move to apply, so
|
||||||
// the most-recently-applied move sits at `cursor - 1`.
|
// the most-recently-applied move sits at `cursor - 1`.
|
||||||
let dest_pile = match state.as_ref() {
|
let dest_pile = match state.as_ref() {
|
||||||
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => {
|
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
||||||
// The destination pile is recoverable directly from the
|
// The destination pile is recoverable directly from the
|
||||||
// instruction — no live state needed. `RotateStock` has no
|
// instruction — no live state needed. `RotateStock` has no
|
||||||
// destination (the chip hides over the stock pile).
|
// destination (the chip hides over the stock pile).
|
||||||
match &moves[cursor - 1] {
|
match &replay.moves[cursor - 1] {
|
||||||
KlondikeInstruction::DstFoundation(dst) => {
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
Some(KlondikePile::Foundation(dst.foundation))
|
Some(KlondikePile::Foundation(dst.foundation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
//! In-engine replay playback core.
|
//! In-engine replay playback core.
|
||||||
//!
|
//!
|
||||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||||
//! game state is reset to the recorded deal — rebuilt directly from
|
//! game state is reset to the deal seeded from the replay's `seed` /
|
||||||
//! `replay.recording` via `GameState::from_recording`, never by
|
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
||||||
//! re-dealing the seed — and the engine ticks through the recording's
|
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
||||||
//! instruction list at a steady cadence, firing the canonical
|
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
||||||
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
|
//! plays back identically to a live game.
|
||||||
//! existing animation pipeline plays back identically to a live game.
|
|
||||||
//!
|
//!
|
||||||
//! ## Public surface
|
//! ## Public surface
|
||||||
//!
|
//!
|
||||||
@@ -96,7 +95,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
|||||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||||
/// for each [`KlondikeInstruction`].
|
/// for each [`KlondikeInstruction`].
|
||||||
/// 4. When `cursor == moves.len()`, the state transitions to
|
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||||
/// [`Completed`](Self::Completed). It lingers for
|
/// [`Completed`](Self::Completed). It lingers for
|
||||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||||
/// [`auto_clear_completed_replay`]) before returning to
|
/// [`auto_clear_completed_replay`]) before returning to
|
||||||
@@ -110,20 +109,14 @@ pub enum ReplayPlaybackState {
|
|||||||
#[default]
|
#[default]
|
||||||
Inactive,
|
Inactive,
|
||||||
/// A replay is currently being played back. The overlay reads
|
/// A replay is currently being played back. The overlay reads
|
||||||
/// `moves.len()` for the denominator of the progress
|
/// `replay.moves.len()` for the denominator of the progress
|
||||||
/// indicator and `cursor` for the numerator.
|
/// indicator and `cursor` for the numerator.
|
||||||
Playing {
|
Playing {
|
||||||
/// The replay being played back. Owned so the state is the
|
/// The replay being played back. Owned so the state is the
|
||||||
/// only place playback metadata lives — no separate resource
|
/// only place playback metadata lives — no separate resource
|
||||||
/// needed. Boxed: the v4 recording embeds the dealt board and
|
/// needed.
|
||||||
/// would otherwise dwarf the other variants
|
replay: Replay,
|
||||||
/// (`clippy::large_enum_variant`).
|
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
||||||
replay: Box<Replay>,
|
|
||||||
/// Instruction list extracted once from `replay.recording` at
|
|
||||||
/// [`start_replay_playback`] time, so the per-frame tick indexes
|
|
||||||
/// a plain slice instead of re-walking the recording.
|
|
||||||
moves: Vec<KlondikeInstruction>,
|
|
||||||
/// Index of the next move to apply, in `[0, moves.len()]`.
|
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
/// Seconds remaining until the next move is dispatched.
|
/// Seconds remaining until the next move is dispatched.
|
||||||
secs_to_next: f32,
|
secs_to_next: f32,
|
||||||
@@ -145,19 +138,6 @@ pub enum ReplayPlaybackState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayPlaybackState {
|
impl ReplayPlaybackState {
|
||||||
/// Construct a [`Self::Playing`] state, extracting the instruction
|
|
||||||
/// list from `replay.recording` once up front.
|
|
||||||
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
|
|
||||||
let moves = replay.recording.instructions();
|
|
||||||
Self::Playing {
|
|
||||||
replay: Box::new(replay),
|
|
||||||
moves,
|
|
||||||
cursor,
|
|
||||||
secs_to_next,
|
|
||||||
paused,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` when a replay is currently being played back.
|
/// Returns `true` when a replay is currently being played back.
|
||||||
pub fn is_playing(&self) -> bool {
|
pub fn is_playing(&self) -> bool {
|
||||||
matches!(self, Self::Playing { .. })
|
matches!(self, Self::Playing { .. })
|
||||||
@@ -176,7 +156,7 @@ impl ReplayPlaybackState {
|
|||||||
/// the total is no longer available in `Completed`.
|
/// the total is no longer available in `Completed`.
|
||||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
|
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
||||||
Self::Inactive | Self::Completed => None,
|
Self::Inactive | Self::Completed => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,11 +191,7 @@ pub fn start_replay_playback(
|
|||||||
) {
|
) {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
// The recording carries the dealt board and the session config
|
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||||
// (including draw mode), so the fresh game is bit-identical to the
|
|
||||||
// recorded deal regardless of how the current build maps seeds to
|
|
||||||
// deals — `replay.seed` is presentation metadata only.
|
|
||||||
let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
|
||||||
commands.insert_resource(GameStateResource(fresh));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
// Initial `secs_to_next` uses the constant rather than reading
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
@@ -225,8 +201,7 @@ pub fn start_replay_playback(
|
|||||||
// unusually short setting; subsequent ticks read the live setting
|
// unusually short setting; subsequent ticks read the live setting
|
||||||
// every frame via [`tick_replay_playback`].
|
// every frame via [`tick_replay_playback`].
|
||||||
**state = ReplayPlaybackState::Playing {
|
**state = ReplayPlaybackState::Playing {
|
||||||
replay: Box::new(replay),
|
replay,
|
||||||
moves,
|
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -281,7 +256,7 @@ pub fn step_replay_playback(
|
|||||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let ReplayPlaybackState::Playing {
|
let ReplayPlaybackState::Playing {
|
||||||
moves,
|
replay,
|
||||||
cursor,
|
cursor,
|
||||||
paused: true,
|
paused: true,
|
||||||
..
|
..
|
||||||
@@ -289,10 +264,10 @@ pub fn step_replay_playback(
|
|||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if *cursor >= moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let instruction = moves[*cursor];
|
let instruction = replay.moves[*cursor];
|
||||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
true
|
true
|
||||||
@@ -387,7 +362,7 @@ pub fn step_backwards_replay_playback(
|
|||||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||||
/// expires, fires the canonical event for the move at `cursor`,
|
/// expires, fires the canonical event for the move at `cursor`,
|
||||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||||
/// reaches `moves.len()`, transitions to
|
/// reaches `replay.moves.len()`, transitions to
|
||||||
/// [`ReplayPlaybackState::Completed`].
|
/// [`ReplayPlaybackState::Completed`].
|
||||||
///
|
///
|
||||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||||
@@ -409,11 +384,10 @@ fn tick_replay_playback(
|
|||||||
let mut transition_to_completed = false;
|
let mut transition_to_completed = false;
|
||||||
|
|
||||||
if let ReplayPlaybackState::Playing {
|
if let ReplayPlaybackState::Playing {
|
||||||
moves,
|
replay,
|
||||||
cursor,
|
cursor,
|
||||||
secs_to_next,
|
secs_to_next,
|
||||||
paused,
|
paused,
|
||||||
..
|
|
||||||
} = state.as_mut()
|
} = state.as_mut()
|
||||||
{
|
{
|
||||||
// While paused, the cursor and the timer freeze together —
|
// While paused, the cursor and the timer freeze together —
|
||||||
@@ -423,8 +397,8 @@ fn tick_replay_playback(
|
|||||||
// path.
|
// path.
|
||||||
if !*paused {
|
if !*paused {
|
||||||
*secs_to_next -= dt;
|
*secs_to_next -= dt;
|
||||||
while *secs_to_next <= 0.0 && *cursor < moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||||
let instruction = moves[*cursor];
|
let instruction = replay.moves[*cursor];
|
||||||
dispatch_instruction(
|
dispatch_instruction(
|
||||||
instruction,
|
instruction,
|
||||||
*cursor,
|
*cursor,
|
||||||
@@ -436,7 +410,7 @@ fn tick_replay_playback(
|
|||||||
*secs_to_next += interval;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if *cursor >= moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
transition_to_completed = true;
|
transition_to_completed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,7 +562,8 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use bevy::time::TimeUpdateStrategy;
|
use bevy::time::TimeUpdateStrategy;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
use solitaire_core::KlondikeInstruction;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||||
@@ -637,11 +612,11 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
SessionRecording::from_instructions_unchecked(
|
vec![
|
||||||
12345,
|
KlondikeInstruction::RotateStock,
|
||||||
DrawStockConfig::DrawOne,
|
KlondikeInstruction::RotateStock,
|
||||||
[KlondikeInstruction::RotateStock; 3],
|
KlondikeInstruction::RotateStock,
|
||||||
),
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,14 +673,11 @@ mod tests {
|
|||||||
let state = app.world().resource::<ReplayPlaybackState>();
|
let state = app.world().resource::<ReplayPlaybackState>();
|
||||||
match state {
|
match state {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::Playing {
|
||||||
cursor,
|
cursor, replay: r, ..
|
||||||
replay: r,
|
|
||||||
moves,
|
|
||||||
..
|
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*cursor, 0);
|
assert_eq!(*cursor, 0);
|
||||||
assert_eq!(r.seed, replay.seed);
|
assert_eq!(r.seed, replay.seed);
|
||||||
assert_eq!(moves.len(), 3);
|
assert_eq!(r.moves.len(), 3);
|
||||||
}
|
}
|
||||||
other => panic!("expected Playing, got {other:?}"),
|
other => panic!("expected Playing, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -806,11 +778,7 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
SessionRecording::from_instructions_unchecked(
|
vec![KlondikeInstruction::RotateStock],
|
||||||
42,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
[KlondikeInstruction::RotateStock],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
start_playback(&mut app, one_move);
|
start_playback(&mut app, one_move);
|
||||||
app.update();
|
app.update();
|
||||||
@@ -919,11 +887,7 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
SessionRecording::from_instructions_unchecked(
|
vec![KlondikeInstruction::RotateStock; 10],
|
||||||
7,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
[KlondikeInstruction::RotateStock; 10],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1939,11 +1939,7 @@ mod tests {
|
|||||||
time_seconds,
|
time_seconds,
|
||||||
0,
|
0,
|
||||||
date,
|
date,
|
||||||
solitaire_core::SessionRecording::from_instructions_unchecked(
|
vec![],
|
||||||
1,
|
|
||||||
solitaire_core::DrawStockConfig::DrawOne,
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
r.share_url = share_url;
|
r.share_url = share_url;
|
||||||
r
|
r
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ use solitaire_data::{
|
|||||||
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
||||||
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
|
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
|
||||||
};
|
};
|
||||||
use solitaire_sync::{SyncPayload, merge};
|
use solitaire_sync::{SyncPayload, SyncResponse, merge};
|
||||||
|
|
||||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
|
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||||
|
WarningToastEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
@@ -107,6 +108,7 @@ impl Plugin for SyncPlugin {
|
|||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
.init_resource::<PendingReplayUpload>()
|
.init_resource::<PendingReplayUpload>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
|
.add_message::<SyncCompleteEvent>()
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>();
|
.add_message::<WarningToastEvent>();
|
||||||
|
|
||||||
@@ -196,6 +198,7 @@ fn poll_pull_result(
|
|||||||
achievements_path: Res<AchievementsStoragePath>,
|
achievements_path: Res<AchievementsStoragePath>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
|
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -210,7 +213,7 @@ fn poll_pull_result(
|
|||||||
match result {
|
match result {
|
||||||
Ok(remote) => {
|
Ok(remote) => {
|
||||||
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let (merged, _conflicts) = merge(&local, &remote);
|
let (merged, conflicts) = merge(&local, &remote);
|
||||||
|
|
||||||
// Persist merged state atomically.
|
// Persist merged state atomically.
|
||||||
if let Some(p) = &stats_path.0
|
if let Some(p) = &stats_path.0
|
||||||
@@ -230,10 +233,17 @@ fn poll_pull_result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update in-world resources.
|
// Update in-world resources.
|
||||||
stats.0 = merged.stats;
|
let now = Utc::now();
|
||||||
achievements.0 = merged.achievements;
|
stats.0 = merged.stats.clone();
|
||||||
progress.0 = merged.progress;
|
achievements.0 = merged.achievements.clone();
|
||||||
status.0 = SyncStatus::LastSynced(Utc::now());
|
progress.0 = merged.progress.clone();
|
||||||
|
status.0 = SyncStatus::LastSynced(now);
|
||||||
|
|
||||||
|
complete_writer.write(SyncCompleteEvent(Ok(SyncResponse {
|
||||||
|
merged,
|
||||||
|
server_time: now,
|
||||||
|
conflicts,
|
||||||
|
})));
|
||||||
}
|
}
|
||||||
Err(SyncError::UnsupportedPlatform) => {
|
Err(SyncError::UnsupportedPlatform) => {
|
||||||
// No backend configured — not an error, just leave status as Idle.
|
// No backend configured — not an error, just leave status as Idle.
|
||||||
@@ -256,7 +266,8 @@ fn poll_pull_result(
|
|||||||
if matches!(e, SyncError::Auth(_)) {
|
if matches!(e, SyncError::Auth(_)) {
|
||||||
configure_sync.write(SyncConfigureRequestEvent);
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
}
|
}
|
||||||
status.0 = SyncStatus::Error(msg);
|
status.0 = SyncStatus::Error(msg.clone());
|
||||||
|
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,11 +346,7 @@ fn push_replay_on_win(
|
|||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
Utc::now().date_naive(),
|
Utc::now().date_naive(),
|
||||||
// The live session is the authoritative recording; it
|
recording.moves.clone(),
|
||||||
// serialises via the upstream card_game serializers so web
|
|
||||||
// playback rebuilds the exact deal instead of re-dealing
|
|
||||||
// the seed.
|
|
||||||
game.0.recording(),
|
|
||||||
);
|
);
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
let rt = rt.0.clone();
|
let rt = rt.0.clone();
|
||||||
@@ -625,11 +632,7 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
||||||
solitaire_core::SessionRecording::from_instructions_unchecked(
|
vec![],
|
||||||
7,
|
|
||||||
DrawStockConfig::DrawOne,
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
let history = ReplayHistory {
|
let history = ReplayHistory {
|
||||||
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
//! shake duration elapses.
|
//! shake duration elapses.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{GameMode, GameState};
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::scoring::compute_time_bonus;
|
use solitaire_core::scoring::compute_time_bonus;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
@@ -93,40 +93,6 @@ pub struct WinSummaryPending {
|
|||||||
/// score-breakdown reveal can format the mode-multiplier row
|
/// score-breakdown reveal can format the mode-multiplier row
|
||||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||||
pub mode: GameMode,
|
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.
|
/// Builds a human-readable XP breakdown string for the win modal.
|
||||||
@@ -526,7 +492,6 @@ fn cache_win_data(
|
|||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
pending.undo_count = game.0.undo_count();
|
pending.undo_count = game.0.undo_count();
|
||||||
pending.mode = game.0.mode;
|
pending.mode = game.0.mode;
|
||||||
pending.move_detail = build_move_detail(&game.0);
|
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||||
@@ -948,18 +913,6 @@ 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;
|
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||||
// excess is summarised with "...and N more".
|
// excess is summarised with "...and N more".
|
||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
@@ -1335,49 +1288,6 @@ mod tests {
|
|||||||
assert_eq!(p.mode, GameMode::Classic);
|
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]
|
#[test]
|
||||||
fn build_xp_detail_slow_win_with_undo() {
|
fn build_xp_detail_slow_win_with_undo() {
|
||||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||||
|
|||||||
@@ -31,37 +31,17 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
|
|||||||
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
|
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
|
||||||
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
|
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
|
||||||
|
|
||||||
/// Extract the mode label from the client's serde representation.
|
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
|
||||||
///
|
|
||||||
/// Unit `GameMode` variants serialise as plain strings (`"Classic"`),
|
|
||||||
/// but data-carrying variants serialise as single-key objects
|
|
||||||
/// (`{"Difficulty": "Easy"}`) — a plain-`String` field rejected those
|
|
||||||
/// uploads with a 400 even though `"Difficulty"` sits in `KNOWN_MODES`.
|
|
||||||
fn mode_label(mode: &serde_json::Value) -> Result<String, AppError> {
|
|
||||||
if let Some(s) = mode.as_str() {
|
|
||||||
return Ok(s.to_string());
|
|
||||||
}
|
|
||||||
if let Some(obj) = mode.as_object()
|
|
||||||
&& obj.len() == 1
|
|
||||||
&& let Some(key) = obj.keys().next()
|
|
||||||
{
|
|
||||||
return Ok(key.clone());
|
|
||||||
}
|
|
||||||
Err(AppError::BadRequest(format!(
|
|
||||||
"invalid mode '{mode}'; expected a mode string or single-variant object"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> {
|
|
||||||
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
|
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"invalid draw_mode '{}'; expected one of {:?}",
|
"invalid draw_mode '{}'; expected one of {:?}",
|
||||||
h.draw_mode, KNOWN_DRAW_MODES
|
h.draw_mode, KNOWN_DRAW_MODES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !KNOWN_MODES.contains(&mode) {
|
if !KNOWN_MODES.contains(&h.mode.as_str()) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"invalid mode '{mode}'; expected one of {KNOWN_MODES:?}"
|
"invalid mode '{}'; expected one of {:?}",
|
||||||
|
h.mode, KNOWN_MODES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
|
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
|
||||||
@@ -92,11 +72,7 @@ fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> {
|
|||||||
struct ReplayHeader {
|
struct ReplayHeader {
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: String,
|
draw_mode: String,
|
||||||
/// Kept as raw JSON: unit variants arrive as strings, data-carrying
|
mode: String,
|
||||||
/// variants (`GameMode::Difficulty(level)`) as single-key objects.
|
|
||||||
/// [`mode_label`] projects both onto the label stored in the `mode`
|
|
||||||
/// column.
|
|
||||||
mode: serde_json::Value,
|
|
||||||
time_seconds: i64,
|
time_seconds: i64,
|
||||||
final_score: i64,
|
final_score: i64,
|
||||||
recorded_at: String,
|
recorded_at: String,
|
||||||
@@ -150,8 +126,7 @@ pub async fn upload(
|
|||||||
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
||||||
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
||||||
|
|
||||||
let mode = mode_label(&header.mode)?;
|
validate_header(&header)?;
|
||||||
validate_header(&header, &mode)?;
|
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let received_at = Utc::now().to_rfc3339();
|
let received_at = Utc::now().to_rfc3339();
|
||||||
@@ -169,7 +144,7 @@ pub async fn upload(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
seed_i64,
|
seed_i64,
|
||||||
header.draw_mode,
|
header.draw_mode,
|
||||||
mode,
|
header.mode,
|
||||||
header.time_seconds,
|
header.time_seconds,
|
||||||
header.final_score,
|
header.final_score,
|
||||||
header.recorded_at,
|
header.recorded_at,
|
||||||
@@ -183,7 +158,7 @@ pub async fn upload(
|
|||||||
// beats their existing best. Only classic mode counts for the leaderboard.
|
// beats their existing best. Only classic mode counts for the leaderboard.
|
||||||
// Use `received_at` (server-computed) rather than `header.recorded_at`
|
// Use `received_at` (server-computed) rather than `header.recorded_at`
|
||||||
// (client-supplied) so clients cannot spoof the timestamp.
|
// (client-supplied) so clients cannot spoof the timestamp.
|
||||||
if mode == "Classic" {
|
if header.mode == "Classic" {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"UPDATE leaderboard
|
r#"UPDATE leaderboard
|
||||||
SET best_score = ?,
|
SET best_score = ?,
|
||||||
|
|||||||
@@ -445,30 +445,33 @@ function showWin(s) {
|
|||||||
submitReplay(s);
|
submitReplay(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplayPayload() {
|
function buildReplayPayload(s) {
|
||||||
if (!game) return null;
|
if (!game || !s) return null;
|
||||||
// The wasm side assembles the entire schema-v4 payload (including the
|
let moves;
|
||||||
// session recording and the u64 seed, which JS numbers can't hold);
|
|
||||||
// JS only supplies the wall-clock time and today's date.
|
|
||||||
try {
|
try {
|
||||||
const json = game.replay_export(
|
moves = game.replay_moves();
|
||||||
Math.max(1, elapsedSecs),
|
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||||
new Date().toISOString().slice(0, 10),
|
|
||||||
);
|
|
||||||
const payload = JSON.parse(json);
|
|
||||||
if (!Array.isArray(payload?.recording?.instructions)
|
|
||||||
|| payload.recording.instructions.length === 0) return null;
|
|
||||||
return payload;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("fs: replay export failed", e);
|
console.warn("fs: replay export failed", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
schema_version: 2,
|
||||||
|
seed: Math.round(game.seed()),
|
||||||
|
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
||||||
|
mode: "Classic",
|
||||||
|
time_seconds: Math.max(1, elapsedSecs),
|
||||||
|
final_score: s.score,
|
||||||
|
recorded_at: new Date().toISOString().slice(0, 10),
|
||||||
|
moves,
|
||||||
|
win_move_index: moves.length - 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReplay(s) {
|
async function submitReplay(s) {
|
||||||
const token = localStorage.getItem('fs_token');
|
const token = localStorage.getItem('fs_token');
|
||||||
if (!token || !game) return;
|
if (!token || !game) return;
|
||||||
const payload = buildReplayPayload();
|
const payload = buildReplayPayload(s);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/replays', {
|
await fetch('/api/replays', {
|
||||||
@@ -1026,7 +1029,7 @@ window.__FERROUS_DEBUG__ = {
|
|||||||
},
|
},
|
||||||
replayPayload() {
|
replayPayload() {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
return buildReplayPayload();
|
return buildReplayPayload(snap ?? game.state());
|
||||||
},
|
},
|
||||||
runAutoplay(options) {
|
runAutoplay(options) {
|
||||||
return runDebugAutoplay(options);
|
return runDebugAutoplay(options);
|
||||||
|
|||||||
@@ -159,17 +159,16 @@
|
|||||||
|
|
||||||
function buildReplayPayload() {
|
function buildReplayPayload() {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
// Schema v4: the wasm side assembles the full payload,
|
|
||||||
// including the session recording and the u64 seed.
|
|
||||||
try {
|
try {
|
||||||
const json = game.replay_export(
|
const moves = game.replay_moves();
|
||||||
1,
|
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||||
new Date().toISOString().slice(0, 10),
|
return {
|
||||||
);
|
schema_version: 2,
|
||||||
const payload = JSON.parse(json);
|
seed: Math.round(game.seed()),
|
||||||
if (!Array.isArray(payload?.recording?.instructions)
|
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
|
||||||
|| payload.recording.instructions.length === 0) return null;
|
mode: "Classic",
|
||||||
return payload;
|
moves,
|
||||||
|
};
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,18 +121,7 @@ function resetPlayer() {
|
|||||||
playInterval = null;
|
playInterval = null;
|
||||||
btnPlay.textContent = "▶ Play";
|
btnPlay.textContent = "▶ Play";
|
||||||
}
|
}
|
||||||
// Old replays (schema < 4) are rejected by the wasm player with a
|
player = new ReplayPlayer(replayJson);
|
||||||
// descriptive error — surface it instead of leaving a dead board.
|
|
||||||
try {
|
|
||||||
player = new ReplayPlayer(replayJson);
|
|
||||||
} catch (e) {
|
|
||||||
captionEl.textContent = `Cannot play this replay: ${e}`;
|
|
||||||
btnStep.disabled = true;
|
|
||||||
btnPlay.disabled = true;
|
|
||||||
btnPrev.disabled = true;
|
|
||||||
btnRestart.disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btnPrev.disabled = true;
|
btnPrev.disabled = true;
|
||||||
btnRestart.disabled = true;
|
btnRestart.disabled = true;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
@@ -315,9 +304,10 @@ btnPlay.addEventListener("click", () => {
|
|||||||
}, STEP_INTERVAL_MS);
|
}, STEP_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Step the player back one move via the wasm-side seek (rewinds to the
|
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||||
/// recorded deal and fast-forwards internally), then renders once so the
|
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||||
/// CSS transition animates each card to its previous position.
|
/// renders once so the CSS transition animates each card to its previous
|
||||||
|
/// position.
|
||||||
function stepBack() {
|
function stepBack() {
|
||||||
if (!player || player.step_idx() === 0) return;
|
if (!player || player.step_idx() === 0) return;
|
||||||
if (playInterval) {
|
if (playInterval) {
|
||||||
@@ -325,7 +315,12 @@ function stepBack() {
|
|||||||
playInterval = null;
|
playInterval = null;
|
||||||
btnPlay.textContent = "▶ Play";
|
btnPlay.textContent = "▶ Play";
|
||||||
}
|
}
|
||||||
render(player.seek(player.step_idx() - 1));
|
const target = player.step_idx() - 1;
|
||||||
|
player = new ReplayPlayer(replayJson);
|
||||||
|
for (let i = 0; i < target; i++) {
|
||||||
|
player.step();
|
||||||
|
}
|
||||||
|
render(player.state());
|
||||||
btnPrev.disabled = player.step_idx() === 0;
|
btnPrev.disabled = player.step_idx() === 0;
|
||||||
btnRestart.disabled = player.step_idx() === 0;
|
btnRestart.disabled = player.step_idx() === 0;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub mod stats;
|
|||||||
pub mod theme_store;
|
pub mod theme_store;
|
||||||
|
|
||||||
pub use achievements::AchievementRecord;
|
pub use achievements::AchievementRecord;
|
||||||
pub use merge::merge;
|
pub use merge::{merge, merge_at};
|
||||||
pub use progress::{PlayerProgress, level_for_xp};
|
pub use progress::{PlayerProgress, level_for_xp};
|
||||||
pub use stats::StatsSnapshot;
|
pub use stats::StatsSnapshot;
|
||||||
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
@@ -98,3 +98,30 @@ pub struct LeaderboardEntry {
|
|||||||
/// When this entry was last recorded.
|
/// When this entry was last recorded.
|
||||||
pub recorded_at: DateTime<Utc>,
|
pub recorded_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Errors returned by the sync server in `application/json` error bodies.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||||
|
pub enum ApiError {
|
||||||
|
/// The request could not be authenticated (missing or invalid JWT).
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
/// The supplied credentials were incorrect.
|
||||||
|
#[error("invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
|
/// A username that was requested for registration is already taken.
|
||||||
|
#[error("username already taken")]
|
||||||
|
UsernameTaken,
|
||||||
|
/// The request payload was too large (> 1 MB).
|
||||||
|
#[error("payload too large")]
|
||||||
|
PayloadTooLarge,
|
||||||
|
/// The request body could not be parsed.
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
/// An unexpected server-side error occurred.
|
||||||
|
#[error("internal server error")]
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|||||||
+43
-234
@@ -26,21 +26,16 @@ use solitaire_core::{
|
|||||||
DrawStockConfig,
|
DrawStockConfig,
|
||||||
game_state::{GameMode, GameState},
|
game_state::{GameMode, GameState},
|
||||||
};
|
};
|
||||||
use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
|
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Replay schema version this player understands. Mirrors
|
/// Mirrors `solitaire_data::Replay` v3.
|
||||||
/// `solitaire_data::REPLAY_SCHEMA_VERSION`; the loader rejects any
|
|
||||||
/// other version with a descriptive error instead of desyncing.
|
|
||||||
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
|
||||||
|
|
||||||
/// Mirrors `solitaire_data::Replay` v4.
|
|
||||||
///
|
///
|
||||||
/// `recording` is the upstream `card_game` session serialisation
|
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
|
||||||
/// (`{config, initial_state, instructions}`): the dealt board is stored
|
/// move-currency `solitaire_core` persists. A stock click is
|
||||||
/// explicitly, so playback rebuilds the exact deal instead of re-dealing
|
/// `KlondikeInstruction::RotateStock`; a card move is a
|
||||||
/// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
|
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
|
||||||
/// an RNG or upstream upgrade changed the seed→deal mapping.
|
/// runtime-only and intentionally not part of the wire format.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Replay {
|
pub struct Replay {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -51,9 +46,7 @@ pub struct Replay {
|
|||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
pub recording: SessionRecording,
|
pub moves: Vec<KlondikeInstruction>,
|
||||||
#[serde(default)]
|
|
||||||
pub win_move_index: Option<usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
||||||
@@ -110,9 +103,6 @@ impl From<&(Card, bool)> for CardSnapshot {
|
|||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct ReplayPlayer {
|
pub struct ReplayPlayer {
|
||||||
game: GameState,
|
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>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
step_idx: usize,
|
step_idx: usize,
|
||||||
}
|
}
|
||||||
@@ -135,42 +125,14 @@ impl ReplayPlayer {
|
|||||||
pub fn from_json(replay_json: &str) -> Result<Self, String> {
|
pub fn from_json(replay_json: &str) -> Result<Self, String> {
|
||||||
let replay: Replay =
|
let replay: Replay =
|
||||||
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
||||||
if replay.schema_version != REPLAY_SCHEMA_VERSION {
|
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||||
return Err(format!(
|
|
||||||
"unsupported replay schema_version {} (this player requires {}); \
|
|
||||||
replays recorded by older clients cannot be replayed",
|
|
||||||
replay.schema_version, REPLAY_SCHEMA_VERSION
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// The recording carries the dealt board and session config, so the
|
|
||||||
// rebuilt game is bit-identical to the recorded deal no matter how
|
|
||||||
// the current build maps seeds to deals.
|
|
||||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
initial: game.clone(),
|
|
||||||
game,
|
game,
|
||||||
moves,
|
moves: replay.moves,
|
||||||
step_idx: 0,
|
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.
|
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
||||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||||
if self.step_idx >= self.moves.len() {
|
if self.step_idx >= self.moves.len() {
|
||||||
@@ -257,25 +219,6 @@ 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.
|
/// Total number of moves the replay contains.
|
||||||
pub fn total_steps(&self) -> usize {
|
pub fn total_steps(&self) -> usize {
|
||||||
self.moves.len()
|
self.moves.len()
|
||||||
@@ -567,36 +510,6 @@ impl SolitaireGame {
|
|||||||
self.game.instruction_history()
|
self.game.instruction_history()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the complete schema-v4 replay upload payload for the live
|
|
||||||
/// game as a JSON string, ready to `POST /api/replays` verbatim.
|
|
||||||
///
|
|
||||||
/// The JS layer must not assemble this payload itself: the recording
|
|
||||||
/// serialises through the upstream `card_game` serializers, and the
|
|
||||||
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
|
|
||||||
/// silently corrupts it).
|
|
||||||
///
|
|
||||||
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
|
|
||||||
/// supplies it because the wasm build has no reliable local clock.
|
|
||||||
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
|
|
||||||
let recorded_at: NaiveDate = recorded_at
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
|
|
||||||
let recording = self.game.recording();
|
|
||||||
let win_move_index = recording.len().checked_sub(1);
|
|
||||||
let replay = Replay {
|
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
|
||||||
seed: self.game.seed,
|
|
||||||
draw_mode: self.game.draw_mode(),
|
|
||||||
mode: self.game.mode,
|
|
||||||
time_seconds,
|
|
||||||
final_score: self.game.score(),
|
|
||||||
recorded_at,
|
|
||||||
recording,
|
|
||||||
win_move_index,
|
|
||||||
};
|
|
||||||
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
||||||
let legal_moves = self.legal_moves_native();
|
let legal_moves = self.legal_moves_native();
|
||||||
let invariants = invariant_report_for_game(&self.game, &legal_moves);
|
let invariants = invariant_report_for_game(&self.game, &legal_moves);
|
||||||
@@ -781,17 +694,6 @@ impl SolitaireGame {
|
|||||||
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete schema-v4 replay payload for the live game, as a JSON
|
|
||||||
/// string ready to `POST /api/replays` verbatim. See
|
|
||||||
/// [`Self::replay_export_native`] for why JS must not assemble the
|
|
||||||
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
|
|
||||||
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
|
|
||||||
/// would demand a `BigInt`).
|
|
||||||
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
|
|
||||||
self.replay_export_native(u64::from(time_seconds), &recorded_at)
|
|
||||||
.map_err(|e| JsValue::from_str(&e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns all currently-legal debug moves as a JS array.
|
/// Returns all currently-legal debug moves as a JS array.
|
||||||
///
|
///
|
||||||
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||||
@@ -995,26 +897,45 @@ mod tests {
|
|||||||
"progressed game must export a non-empty replay move list"
|
"progressed game must export a non-empty replay move list"
|
||||||
);
|
);
|
||||||
|
|
||||||
let replay_json = match game.replay_export_native(120, "2026-06-01") {
|
let moves_json = match serde_json::to_value(&exported_moves) {
|
||||||
Ok(json) => json,
|
Ok(value) => value,
|
||||||
Err(err) => panic!("failed to export replay JSON: {err}"),
|
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
||||||
};
|
};
|
||||||
let parsed: Replay = match serde_json::from_str(&replay_json) {
|
assert!(
|
||||||
|
moves_json.is_array(),
|
||||||
|
"exported replay moves must serialise as a JSON array"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(err) => panic!("exported replay JSON must parse back as Replay: {err}"),
|
Err(err) => {
|
||||||
|
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
assert_eq!(parsed.schema_version, REPLAY_SCHEMA_VERSION);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.recording.instructions(),
|
parsed_back, exported_moves,
|
||||||
exported_moves,
|
"replay move JSON must round-trip through KlondikeInstruction"
|
||||||
"exported recording must carry the exact instruction history"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parsed.win_move_index,
|
|
||||||
Some(exported_moves.len() - 1),
|
|
||||||
"win_move_index must point at the last instruction"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
||||||
|
Some(date) => date,
|
||||||
|
None => panic!("invalid recorded_at date in test"),
|
||||||
|
};
|
||||||
|
let replay = Replay {
|
||||||
|
schema_version: 3,
|
||||||
|
seed,
|
||||||
|
draw_mode,
|
||||||
|
mode: GameMode::Classic,
|
||||||
|
time_seconds: 120,
|
||||||
|
final_score: game.game.score(),
|
||||||
|
recorded_at,
|
||||||
|
moves: exported_moves,
|
||||||
|
};
|
||||||
|
let replay_json = match serde_json::to_string(&replay) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(err) => panic!("failed to serialise replay JSON: {err}"),
|
||||||
|
};
|
||||||
|
|
||||||
let mut player = match ReplayPlayer::from_json(&replay_json) {
|
let mut player = match ReplayPlayer::from_json(&replay_json) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => panic!("failed to construct replay player: {err}"),
|
Err(err) => panic!("failed to construct replay player: {err}"),
|
||||||
@@ -1041,118 +962,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-v4 replays re-dealt from the seed at playback time — the exact
|
|
||||||
/// mechanism that broke when the seed→deal mapping changed. The player
|
|
||||||
/// must refuse them with a version error, never desync silently.
|
|
||||||
#[test]
|
|
||||||
fn replay_player_rejects_pre_v4_schema_versions() {
|
|
||||||
let v3_json = r#"{
|
|
||||||
"schema_version": 3,
|
|
||||||
"seed": 7,
|
|
||||||
"draw_mode": "DrawOne",
|
|
||||||
"mode": "Classic",
|
|
||||||
"time_seconds": 60,
|
|
||||||
"final_score": 100,
|
|
||||||
"recorded_at": "2026-05-01",
|
|
||||||
"recording": null,
|
|
||||||
"moves": []
|
|
||||||
}"#;
|
|
||||||
// v3 files carry `moves`, not `recording`; either way the version
|
|
||||||
// gate (or the missing field) must produce an error, not a player.
|
|
||||||
let err = match ReplayPlayer::from_json(v3_json) {
|
|
||||||
Err(err) => err,
|
|
||||||
Ok(_) => panic!("v3 replay must be rejected"),
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
err.contains("schema_version") || err.contains("invalid replay JSON"),
|
|
||||||
"error must name the version/format problem, got: {err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The whole point of v4: playback rebuilds the deal from the
|
|
||||||
/// recording, so a replay stays correct even when the top-level
|
|
||||||
/// `seed` no longer maps to the same deal (RNG upgrades, or a
|
|
||||||
/// corrupted seed from the old JS `Math.round` path).
|
|
||||||
#[test]
|
|
||||||
fn replay_playback_ignores_seed_for_dealing() {
|
|
||||||
let game = SolitaireGame {
|
|
||||||
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
|
||||||
};
|
|
||||||
let replay_json = game
|
|
||||||
.replay_export_native(60, "2026-06-01")
|
|
||||||
.expect("export must succeed");
|
|
||||||
// Corrupt the seed field only — playback must be unaffected.
|
|
||||||
let mut value: serde_json::Value =
|
|
||||||
serde_json::from_str(&replay_json).expect("parse exported JSON");
|
|
||||||
value["seed"] = serde_json::Value::from(0_u64);
|
|
||||||
let corrupted = serde_json::to_string(&value).expect("reserialise");
|
|
||||||
|
|
||||||
let player = ReplayPlayer::from_json(&corrupted).expect("player must construct");
|
|
||||||
let original_deal = serde_json::to_string(&game.snap()).expect("serialise original deal");
|
|
||||||
let replayed_deal =
|
|
||||||
serde_json::to_string(&player.snapshot()).expect("serialise replayed deal");
|
|
||||||
// Compare the board projections (piles), not the GameState wrapper
|
|
||||||
// (whose serde includes the now-different seed metadata).
|
|
||||||
let orig: serde_json::Value = serde_json::from_str(&original_deal).expect("parse");
|
|
||||||
let repl: serde_json::Value = serde_json::from_str(&replayed_deal).expect("parse");
|
|
||||||
assert_eq!(
|
|
||||||
orig["tableaus"], repl["tableaus"],
|
|
||||||
"tableau deal must come from the recording, not the seed"
|
|
||||||
);
|
|
||||||
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]
|
#[test]
|
||||||
fn debug_api_autonomous_seed_batch_smoke() {
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
for seed in 0_u64..128_u64 {
|
for seed in 0_u64..128_u64 {
|
||||||
|
|||||||
Reference in New Issue
Block a user