Compare commits

...

7 Commits

Author SHA1 Message Date
funman300 e22970f1ef docs(changelog): cut 0.49.0 — sync transparency, local backup, full-cluster ambiguity gate
Android Release / build-apk (push) Successful in 5m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:11:59 -07:00
funman300 020c860cc5 Merge pull request 'feat(engine): Phase M — sync transparency + local data backup' (#189) from feat/sync-transparency into master
Test / fmt (push) Successful in 5s
Test / test (push) Successful in 4m12s
Build and Deploy / build-and-push (push) Failing after 8m9s
Web E2E / web-e2e (push) Successful in 11m6s
2026-07-17 01:11:25 +00:00
funman300 2f373784bf feat(engine): Phase M — sync transparency + local data backup
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 4m22s
Closes out the 13-phase menu redesign. Three parts:

1. Post-sync merge summary: the pull poller now keeps the
   `ConflictReport`s the merge produces (previously discarded) in a new
   `SyncConflictLog` resource and toasts "Synced — N conflicts, kept
   newer values" when a merge wasn't clean. No wire changes — the
   server-side `SyncResponse.conflicts` field already existed.

2. Account tab detail: a per-field conflict list under the sync row
   ("win_streak_current — this device: 3 / server: 5") plus a
   clean-merge caption, so the last merge is always inspectable.

3. Local data export/import: new `solitaire_data::transfer` module —
   one versioned JSON bundle (settings, stats, achievements, progress)
   written atomically next to the other saves, with a version-gated
   loader that surfaces every failure instead of defaulting. Account
   tab grows an Export/Import button pair (desktop+Android only); the
   handler runs in `Last`, off the annotated Update spine. Import
   rewrites the live resources, persists via the existing save fns,
   and fires SettingsChangedEvent so appliers react normally.

Tests: bundle round-trip/version-gate/missing-file in solitaire_data;
ECS-level export→import round trip and failed-import-warns in
settings_plugin. Gate: workspace tests green, clippy -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:06:02 -07:00
funman300 b4845564af Merge pull request 'refactor(engine): ambiguity gate covers the full cluster — batches 1-3' (#188) from refactor/ambiguity-gate-cluster-v2 into master
Test / fmt (push) Successful in 3s
Test / test (push) Successful in 4m19s
Build and Deploy / build-and-push (push) Failing after 6m40s
Web E2E / web-e2e (push) Successful in 10m57s
2026-07-17 00:48:15 +00:00
funman300 838342649c refactor(engine): ambiguity gate covers the full cluster — batches 1-3, 585 pairs burned to zero
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m13s
Port the July 7 gate-extender branch onto current master (the You-hub,
touch action bar, glass tab bar, Phase L, and hint-ghost work all landed
since) and finish batch 3. cluster_app now includes the mode, replay,
input, radial, tooltip, cursor, touch-selection, and safe-area plugins on
top of batches 1-2; the 585 ambiguous pairs the expansion exposed are
annotated down to zero and the gate stays assert_eq!(count, 0).

New ordering machinery:
- ReplayPlayback: playback driver chain on the pre-mutation spine
  (PointerInput < ReplayPlayback < overlay chain < GameMutation)
- ModeStart: mode-start handlers between the stats abandon-recorder and
  GameMutation; ChallengeCompletion < DailyCompletion < WeeklyGoalsEval
  pinned inside ProgressUpdate so `.after(ProgressUpdate)` readers see
  settled progress
- PointerInput: public set wrapping the input chain; AbandonRecord on the
  stats abandon-recorder; PendingHint around the async hint pipeline;
  HintGhostFx for the ghost chain (after the full visual spine)
- Clubs: MoveRequestWriters, WarningToastWriters join the existing
  request/toast writer clubs; UiTextFx grows the overlay/tooltip/safe-area
  chrome painters; SettingsAccess grows the lag-tolerant settings readers

Gate: cargo test --workspace green (993 engine tests), clippy
--all-targets -D warnings clean, rustfmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:42:46 -07:00
funman300 7f604f5df6 Merge pull request 'feat(engine): surface upstream move-type counters and replay seek' (#173) from feat/upstream-stat-counters into master
Test / fmt (push) Successful in 9s
Test / test (push) Successful in 5m28s
Build and Deploy / build-and-push (push) Failing after 9m7s
Web E2E / web-e2e (push) Failing after 20s
2026-07-16 23:53:11 +00:00
funman300 b2581024f3 feat(engine): surface upstream move-type counters and replay seek
Test / test (pull_request) Successful in 36m35s
Two unused-for-free upstream card_game/klondike features:

- GameState now exposes the granular KlondikeStats counters
  (move_to_foundation_count, move_to_tableau_count,
  move_from_foundation_count, flip_up_count) and the win modal shows a
  quiet per-move-type recap line built from them (e.g. "21 to
  foundation - 14 tableau moves - 9 flips")
- wasm ReplayPlayer gains seek(step): clamped jump to any position,
  rewinding via a stored copy of the recorded deal instead of reparsing
  the replay JSON; replay.js Prev now uses it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:59:33 -07:00
51 changed files with 2007 additions and 192 deletions
+23
View File
@@ -6,6 +6,29 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [0.49.0] — 2026-07-16
### Added
- **See what sync did.** After every sync, the Account tab shows exactly
what the merge decided — a "no conflicts" note, or one line per
reconciled field with both values ("this device: 3 / server: 5"). When
a merge wasn't clean, a toast summarises it: "Synced — 2 conflicts,
kept newer values". (#189)
- **One-file backup.** Settings → Account → Local data can now export
every save (settings, stats, achievements, progress) into a single
backup file next to your other saves — and restore from it later or on
another machine. Restores never silently reset anything: a missing or
incompatible backup shows an error and leaves your data untouched.
(#189)
### Changed
- Under the hood, the scheduler-ambiguity gate now covers the full
plugin cluster (22 plugins) — 585 undefined-order system pairs were
pinned or proven independent, eliminating a whole class of
"works-on-my-frame" bugs in modes, replays, input, and chrome. (#188)
## [0.48.1] — 2026-07-15 ## [0.48.1] — 2026-07-15
### Added ### Added
+26
View File
@@ -490,6 +490,32 @@ 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 {
+6
View File
@@ -133,6 +133,12 @@ pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
pub mod difficulty_seeds; pub mod difficulty_seeds;
pub use difficulty_seeds::{DifficultySeeds, seeds_for}; pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod transfer;
pub use transfer::{
BundleError, DATA_BUNDLE_SCHEMA_VERSION, DataBundle, data_bundle_path, load_bundle_from,
save_bundle_to,
};
pub mod settings; pub mod settings;
pub use settings::{ pub use settings::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend, AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
+137
View File
@@ -0,0 +1,137 @@
//! Local data export/import (Phase M — sync transparency).
//!
//! Bundles every player-owned JSON save (settings, stats, achievements,
//! progress) into one versioned backup file so a player can move their
//! data between devices without an account, or keep a manual backup.
//!
//! The bundle deliberately excludes the in-progress game and the replay
//! history: the former is transient, the latter can be large and is
//! shareable through the replay upload path instead.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::settings::Settings;
use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot};
/// Bundle format version. Bump on any breaking shape change; the loader
/// rejects newer versions rather than misreading them.
pub const DATA_BUNDLE_SCHEMA_VERSION: u32 = 1;
/// File name of the default backup location (next to the other saves).
pub const DATA_BUNDLE_FILE_NAME: &str = "ferrous-solitaire-backup.json";
/// One-file backup of every player-owned JSON save.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataBundle {
/// Format version — see [`DATA_BUNDLE_SCHEMA_VERSION`].
pub schema_version: u32,
/// When the bundle was written.
pub exported_at: DateTime<Utc>,
pub settings: Settings,
pub stats: StatsSnapshot,
pub achievements: Vec<AchievementRecord>,
pub progress: PlayerProgress,
}
/// Why a bundle could not be loaded.
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("could not read backup file: {0}")]
Io(#[from] io::Error),
#[error("backup file is not valid JSON: {0}")]
Parse(#[from] serde_json::Error),
#[error("backup was written by a newer app version (schema {found} > {supported})")]
UnsupportedVersion { found: u32, supported: u32 },
}
/// Default backup path: `<data dir>/<app dir>/ferrous-solitaire-backup.json`,
/// or `None` when no data directory is available on this platform.
pub fn data_bundle_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(DATA_BUNDLE_FILE_NAME))
}
/// Write `bundle` to `path` atomically (`.tmp` → rename), creating parent
/// directories as needed. Mirrors the other save-file writers.
pub fn save_bundle_to(path: &Path, bundle: &DataBundle) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(bundle).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Read and validate a bundle from `path`. Unlike the per-file loaders this
/// does NOT default-on-error — an import silently replacing player data
/// with defaults would be destructive, so every failure is surfaced.
pub fn load_bundle_from(path: &Path) -> Result<DataBundle, BundleError> {
let data = fs::read(path)?;
let bundle: DataBundle = serde_json::from_slice(&data)?;
if bundle.schema_version > DATA_BUNDLE_SCHEMA_VERSION {
return Err(BundleError::UnsupportedVersion {
found: bundle.schema_version,
supported: DATA_BUNDLE_SCHEMA_VERSION,
});
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_bundle() -> DataBundle {
DataBundle {
schema_version: DATA_BUNDLE_SCHEMA_VERSION,
exported_at: Utc::now(),
settings: Settings::default(),
stats: StatsSnapshot {
games_played: 7,
..Default::default()
},
achievements: vec![],
progress: PlayerProgress::default(),
}
}
#[test]
fn round_trips_through_disk() {
let dir = std::env::temp_dir().join(format!("bundle_test_{}", std::process::id()));
let path = dir.join(DATA_BUNDLE_FILE_NAME);
let bundle = sample_bundle();
save_bundle_to(&path, &bundle).expect("save should succeed");
let loaded = load_bundle_from(&path).expect("load should succeed");
assert_eq!(loaded.schema_version, DATA_BUNDLE_SCHEMA_VERSION);
assert_eq!(loaded.stats.games_played, 7);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_newer_schema() {
let dir = std::env::temp_dir().join(format!("bundle_test_v_{}", std::process::id()));
let path = dir.join(DATA_BUNDLE_FILE_NAME);
let mut bundle = sample_bundle();
bundle.schema_version = DATA_BUNDLE_SCHEMA_VERSION + 1;
save_bundle_to(&path, &bundle).expect("save should succeed");
assert!(matches!(
load_bundle_from(&path),
Err(BundleError::UnsupportedVersion { .. })
));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_is_an_io_error() {
assert!(matches!(
load_bundle_from(Path::new("/nonexistent/backup.json")),
Err(BundleError::Io(_))
));
}
}
+49 -5
View File
@@ -67,6 +67,16 @@ pub struct AchievementsResource(pub Vec<AchievementRecord>);
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct AchievementsStoragePath(pub Option<PathBuf>); pub struct AchievementsStoragePath(pub Option<PathBuf>);
/// Set wrapping the win-path achievement evaluator. It writes
/// `AchievementsResource`/`ProgressResource` and emits unlock events; UI
/// readers of those (HUD popovers, the stats/achievements panels) order
/// themselves `.after(AchievementEval)`. The replay-completion evaluator is
/// deliberately NOT a member: it runs after `AutoComplete`, and panel chains
/// that need `.after(AchievementEval)` run before `AutoComplete` — putting
/// both in one set would close an ordering cycle (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AchievementEval;
pub struct AchievementPlugin { pub struct AchievementPlugin {
pub storage_path: Option<PathBuf>, pub storage_path: Option<PathBuf>,
} }
@@ -119,9 +129,13 @@ impl Plugin for AchievementPlugin {
.add_systems( .add_systems(
Update, Update,
evaluate_on_win evaluate_on_win
.in_set(AchievementEval)
.after(GameMutation) .after(GameMutation)
.after(StatsUpdate) .after(StatsUpdate)
.after(ProgressUpdate), .after(ProgressUpdate)
// The unlock/XP/level-up events it emits are drained by
// the toast pipeline this frame (#143).
.before(crate::animation_plugin::AnimFx),
) )
// Achievement-onboarding cue: fires once after the player's very // Achievement-onboarding cue: fires once after the player's very
// first win to teach the Achievements panel exists. Must run // first win to teach the Achievements panel exists. Must run
@@ -131,20 +145,50 @@ impl Plugin for AchievementPlugin {
Update, Update,
fire_achievement_onboarding_toast fire_achievement_onboarding_toast
.after(GameMutation) .after(GameMutation)
.after(StatsUpdate), .after(StatsUpdate)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters)
// Writes the one-shot onboarding flag into
// SettingsResource: board painters re-read settings, so
// land the write first; UiTextFx members read disjoint
// settings fields (reduce-motion), so writer/reader
// order there is provably irrelevant (#143).
.before(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
// Open/close/tab handling moved to `you_hub_plugin` // Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll. // (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_achievements_panel)
.add_systems( .add_systems(
Update, Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>, scroll_achievements_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
) )
// Event-driven unlock: observe `ReplayPlaybackState` and unlock // Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion. // `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that // Reads the resource via `Option<Res<_>>` so headless tests that
// omit `ReplayPlaybackPlugin` still build. // omit `ReplayPlaybackPlugin` still build.
.add_systems(Update, evaluate_cinephile_on_replay_completion); .add_systems(
Update,
// Shares the unlock stream and AchievementsResource with
// evaluate_on_win; ordered after it and before the toast
// pipeline that drains the unlock events (#143).
evaluate_cinephile_on_replay_completion
.after(evaluate_on_win)
// The stats overlay's watch-replay button (ordered
// before AutoComplete) writes ReplayPlaybackState this
// system reads.
.after(crate::auto_complete_plugin::AutoComplete)
.before(crate::animation_plugin::AnimFx),
);
} }
} }
+44 -5
View File
@@ -230,6 +230,14 @@ fn adopt_toasts_into_stack(
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence. /// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin; pub struct AnimationPlugin;
/// Set wrapping this plugin's Update chain (card slide/cascade animation and
/// the toast pipeline). Ordered after [`crate::card_animation::CardTweenFx`]
/// on the shared visual spine — members write card `Transform`s, which every
/// other painter set also touches — and after the toast/stat/progress writer
/// sets so the toast handlers drain this frame's events (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AnimFx;
impl Plugin for AnimationPlugin { impl Plugin for AnimationPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register the events this plugin consumes so tests that don't include // Register the events this plugin consumes so tests that don't include
@@ -253,12 +261,36 @@ impl Plugin for AnimationPlugin {
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root)) .add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
.configure_sets(
Update,
AnimFx
// Visual spine: …BoardVisuals < MarkerVisuals <
// CardTweenFx < AnimFx < FeedbackFx (#143). GameMutation
// ordering is inherited transitively.
.after(crate::card_animation::CardTweenFx)
.after(crate::table_plugin::MarkerVisuals)
// Stats/progress must settle before the win/level-up
// toast handlers read their events. (No edge against
// InfoToastWriters: toast writers exist on both sides
// of this set — enqueue_toasts drains via cursor and
// joins the club instead; a toast surfacing one frame
// later is invisible.)
.after(crate::stats_plugin::StatsUpdate)
.after(crate::progress_plugin::ProgressUpdate)
.after(GameMutation)
// HUD text pulses are disjoint from the card entities
// this set animates (see UiTextFx × BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.add_systems( .add_systems(
Update, Update,
( (
advance_card_anims, advance_card_anims,
sync_slide_duration, // Both drain SettingsChangedEvent / read anim-speed
handle_win_cascade, // fields via cursor; a home deal-option edit surfacing
// one frame later is invisible (#143).
sync_slide_duration.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_win_cascade.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_achievement_toast, handle_achievement_toast,
handle_levelup_toast, handle_levelup_toast,
handle_daily_goal_announcement_toast, handle_daily_goal_announcement_toast,
@@ -266,16 +298,23 @@ impl Plugin for AnimationPlugin {
handle_weekly_toast, handle_weekly_toast,
handle_time_attack_toast, handle_time_attack_toast,
handle_challenge_toast, handle_challenge_toast,
handle_settings_toast, handle_settings_toast.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_auto_complete_toast, handle_auto_complete_toast,
handle_xp_awarded_toast, handle_xp_awarded_toast,
handle_move_rejected_toast, handle_move_rejected_toast,
handle_warning_toast, handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (
enqueue_toasts
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
drive_toast_display,
)
.chain(),
adopt_toasts_into_stack, adopt_toasts_into_stack,
) )
.after(GameMutation), .chain()
.in_set(AnimFx),
); );
} }
} }
+32 -3
View File
@@ -83,6 +83,14 @@ use tuning::update_input_platform;
/// long as no single entity carries both `CardAnim` and `CardAnimation`. /// long as no single entity carries both `CardAnim` and `CardAnimation`.
pub struct CardAnimationPlugin; pub struct CardAnimationPlugin;
/// Set wrapping this plugin's Update chain (curve tweens, hover/drag
/// visuals). First anim stage on the visual spine: …BoardVisuals <
/// MarkerVisuals < CardTweenFx < [`crate::animation_plugin::AnimFx`] <
/// FeedbackFx — members write card `Transform`s, which every other painter
/// set also touches (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct CardTweenFx;
impl Plugin for CardAnimationPlugin { impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register events and resources idempotently — double-registration is // Register events and resources idempotently — double-registration is
@@ -115,7 +123,14 @@ impl Plugin for CardAnimationPlugin {
// still present in the same frame it was removed (deferred // still present in the same frame it was removed (deferred
// commands aren't applied until the next ApplyDeferred // commands aren't applied until the next ApplyDeferred
// point), causing a 1-frame gap between every chain step. // point), causing a 1-frame gap between every chain step.
ApplyDeferred, //
// `ambiguous_with_all`: an explicit sync point conflicts
// (on `World`) with every unordered system in the
// schedule. Only the flush of THIS chain's commands needs
// ordering — the chain edges provide that; whether an
// unrelated system's commands flush here or at the next
// sync point is immaterial to it (#143).
ApplyDeferred.ambiguous_with_all(),
// After each animation finishes, pop the next chain segment. // After each animation finishes, pop the next chain segment.
advance_animation_chains, advance_animation_chains,
// Interaction visuals (run after animation for final positions). // Interaction visuals (run after animation for final positions).
@@ -123,10 +138,24 @@ impl Plugin for CardAnimationPlugin {
apply_hover_scale, apply_hover_scale,
apply_drag_visual, apply_drag_visual,
// Drain buffered inputs only when no animations remain. // Drain buffered inputs only when no animations remain.
drain_input_buffer, // It re-emits buffered undo/new-game requests, so it
// joins the self-ambiguous writer clubs (#143).
drain_input_buffer
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
) )
.chain() .chain()
.after(GameMutation), .in_set(CardTweenFx),
)
.configure_sets(
Update,
CardTweenFx
.after(GameMutation)
.after(crate::table_plugin::MarkerVisuals)
// HUD text pulses touch Transform on UI entities only —
// disjoint from the card entities this set animates
// (same rationale as UiTextFx × BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
); );
} }
} }
+3 -1
View File
@@ -592,7 +592,9 @@ impl Plugin for CardPlugin {
tick_right_click_highlights, tick_right_click_highlights,
clear_right_click_highlights_on_state_change, clear_right_click_highlights_on_state_change,
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
tick_hint_highlight, // Reads only the anim-speed/reduce-motion flags; a frame
// of lag is invisible (#143).
tick_hint_highlight.ambiguous_with(crate::settings_plugin::SettingsAccess),
update_stock_empty_indicator, update_stock_empty_indicator,
update_stock_count_badge.run_if(resource_changed::<GameStateResource>), update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
collect_resize_events, collect_resize_events,
+23 -4
View File
@@ -11,8 +11,7 @@ use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
use crate::events::{ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
/// Minimum player level required to start a Challenge run. /// Minimum player level required to start a Challenge run.
@@ -38,8 +37,28 @@ impl Plugin for ChallengePlugin {
.add_message::<StartChallengeRequestEvent>() .add_message::<StartChallengeRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp. // Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate)) // Ordering comes from the mode-completion chain configured in
.add_systems(Update, handle_start_challenge_request.before(GameMutation)); // progress_plugin; the toast it emits joins the writer club
// drained by the toast pipeline this frame (#143).
.add_systems(
Update,
advance_on_challenge_win
.in_set(crate::progress_plugin::ChallengeCompletion)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_challenge_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
);
} }
} }
+20 -1
View File
@@ -76,12 +76,31 @@ pub struct CursorPlugin;
impl Plugin for CursorPlugin { impl Plugin for CursorPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// All three read the DragState/game state the input chain and
// mutation systems settle, so they run after both. The highlight
// painters write card/overlay Sprites and join the visual spine
// ahead of the board painters; the cursor icon touches only the
// window cursor (#143).
app.add_systems( app.add_systems(
Update, Update,
( (
update_cursor_icon, update_cursor_icon
.after(crate::input_plugin::PointerInput)
.after(crate::game_plugin::GameMutation)
.after(crate::selection_plugin::SelectionFx)
.before(crate::card_plugin::BoardVisuals),
(
update_drop_highlights.run_if(resource_changed::<DragState>), update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays, update_drop_target_overlays,
)
.chain()
.after(crate::input_plugin::PointerInput)
.after(crate::game_plugin::GameMutation)
// Selection maintenance clears DragState and the hint
// poll writes highlight sprites — both settle first.
.after(crate::selection_plugin::SelectionFx)
.after(crate::pending_hint::PendingHint)
.before(crate::card_plugin::BoardVisuals),
), ),
); );
} }
+43 -9
View File
@@ -24,8 +24,8 @@ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
WarningToastEvent, XpAwardedEvent, WarningToastEvent, XpAwardedEvent,
}; };
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
@@ -124,17 +124,51 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
// record/award after the base ProgressUpdate so we don't fight // Ordering comes from the mode-completion chain configured in
// ProgressPlugin's add_xp on the same frame. // progress_plugin; the toast it emits joins the writer club
.add_systems(Update, handle_daily_completion.after(ProgressUpdate)) // drained by the toast pipeline this frame (#143).
.add_systems(Update, handle_start_daily_request.before(GameMutation)) .add_systems(
.add_systems(Update, check_daily_expiry_warning) Update,
.add_systems(Update, check_date_rollover); handle_daily_completion
.in_set(crate::progress_plugin::DailyCompletion)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_daily_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// One chain for the DailyChallengeResource writers/readers:
// the local date rollover replaces today's challenge and the
// expiry warning reads it. Before ModeStart so a rollover
// lands before the start handler reads the seed this frame
// (#143).
.add_systems(
Update,
(
check_date_rollover,
check_daily_expiry_warning
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
)
.chain()
.before(crate::game_plugin::ModeStart),
);
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm. // Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
// The poll replaces DailyChallengeResource, so it heads the same
// pre-ModeStart timeline as the rollover/expiry chain above (#143).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, fetch_server_challenge) app.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge); .add_systems(Update, poll_server_challenge.before(check_date_rollover));
} }
} }
+10 -2
View File
@@ -21,7 +21,6 @@ use solitaire_core::game_state::{DifficultyLevel, GameMode};
use solitaire_data::difficulty_seeds::seeds_for; use solitaire_data::difficulty_seeds::seeds_for;
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent}; use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
use crate::game_plugin::GameMutation;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Resources // Resources
@@ -77,7 +76,16 @@ impl Plugin for DifficultyPlugin {
app.init_resource::<DifficultyIndexResource>() app.init_resource::<DifficultyIndexResource>()
.add_message::<StartDifficultyRequestEvent>() .add_message::<StartDifficultyRequestEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_systems(Update, handle_difficulty_request.before(GameMutation)); // ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request stream with the
// other writers (#143).
.add_systems(
Update,
handle_difficulty_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
);
} }
} }
+12
View File
@@ -151,6 +151,18 @@ pub struct SyncLogoutRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct DeleteAccountRequestEvent; pub struct DeleteAccountRequestEvent;
/// Request to export every JSON save into one local backup bundle
/// (Phase M — sync transparency). Fired by the Account tab's "Export
/// data" button; handled in the `Last` schedule by `settings_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DataExportRequestEvent;
/// Request to restore every JSON save from the local backup bundle
/// (Phase M — sync transparency). Fired by the Account tab's "Import
/// data" button; handled in the `Last` schedule by `settings_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DataImportRequestEvent;
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so /// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
/// the same toggle path runs whether the player presses `Esc` or clicks. /// the same toggle path runs whether the player presses `Esc` or clicks.
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag / /// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
+54 -7
View File
@@ -198,6 +198,22 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
/// animation systems. /// animation systems.
pub struct FeedbackAnimPlugin; pub struct FeedbackAnimPlugin;
/// Set wrapping the shake/settle/deal/flourish systems. Last anim stage on
/// the visual spine: …MarkerVisuals < CardTweenFx <
/// [`crate::animation_plugin::AnimFx`] < FeedbackFx. Runs after
/// MarkerVisuals because the foundation flourish tints pile-marker sprites,
/// which the marker painters also write; the tint must win within the frame
/// (#143). GameMutation ordering is inherited transitively.
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct FeedbackFx;
/// Set wrapping the hint-ghost chain (spawn → tick → despawn). Ghost
/// entities are `Without<CardEntity>` sprites owned solely by this chain —
/// systems that animate other entity domains (e.g. the win-summary camera
/// shake) declare themselves `.ambiguous_with(HintGhostFx)` (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct HintGhostFx;
impl Plugin for FeedbackAnimPlugin { impl Plugin for FeedbackAnimPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register the events this plugin consumes so it can run in isolation // Register the events this plugin consumes so it can run in isolation
@@ -211,22 +227,36 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<HintVisualEvent>() .add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>() .add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>() .add_message::<RequestRedraw>()
.configure_sets(
Update,
FeedbackFx
.after(GameMutation)
.after(crate::animation_plugin::AnimFx)
// HUD text pulses are disjoint from the card/marker
// entities this set animates (see UiTextFx ×
// BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.add_systems( .add_systems(
Update, Update,
( (
start_shake_anim.after(GameMutation), // Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
start_shake_anim.ambiguous_with(crate::settings_plugin::SettingsAccess),
tick_shake_anim, tick_shake_anim,
start_settle_anim.after(GameMutation), start_settle_anim,
// tick_foundation_flourish writes the full Transform.scale // tick_foundation_flourish writes the full Transform.scale
// (Vec3); tick_settle_anim writes only scale.y on top of // (Vec3); tick_settle_anim writes only scale.y on top of
// it. Ordering ensures the settle's y-only write always // it. Ordering ensures the settle's y-only write always
// applies last so it wins on the ~0.15 s overlap when both // applies last so it wins on the ~0.15 s overlap when both
// components are present on the same King entity. // components are present on the same King entity.
tick_foundation_flourish.before(tick_settle_anim), tick_foundation_flourish,
tick_settle_anim, tick_settle_anim,
start_deal_anim.after(GameMutation), start_deal_anim,
start_foundation_flourish.after(GameMutation), start_foundation_flourish,
), )
.chain()
.in_set(FeedbackFx),
) )
// Hint ghost (Phase H): the spawn reads card Transform/Sprite, // Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches // so it orders after the board painters; the tick only touches
@@ -240,7 +270,24 @@ impl Plugin for FeedbackAnimPlugin {
tick_hint_ghosts, tick_hint_ghosts,
despawn_hint_ghosts_on_state_change.after(GameMutation), despawn_hint_ghosts_on_state_change.after(GameMutation),
) )
.chain(), .chain()
.in_set(HintGhostFx)
// The spawn reads card Sprites the marker painters (theme
// apply, pile highlights) also write — those settle first.
// Ghost entities are Without<CardEntity> and never HUD
// chrome, and the spawn reads only the reduce-motion flag
// (frame-lag invisible) (#143).
.after(crate::table_plugin::MarkerVisuals)
// …and the card tweens/slides, whose Transform writes
// the spawn snapshot must not race.
.after(crate::card_animation::CardTweenFx)
// FeedbackFx (deal slide, shake) is the last Transform
// writer on the spine — the ghost snapshot reads after
// the whole visual pipeline has settled.
.after(FeedbackFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
); );
} }
} }
+51 -1
View File
@@ -99,6 +99,38 @@ pub struct DrawRequestWriters;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct InfoToastWriters; pub struct InfoToastWriters;
/// Self-ambiguous club for writers of `MoveRequestEvent` (drag drops, stock
/// clicks, radial menu, selection keys, replay step button). Consumers drain
/// the whole queue — append order is meaningless (#143). Same rules as
/// [`NewGameRequestWriters`]: do NOT hang ordering edges off this set.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MoveRequestWriters;
/// Self-ambiguous club for writers of `WarningToastEvent` (daily-expiry
/// warning, leaderboard opt-in/out failure polls). The toast pipeline
/// drains the whole queue — append order is meaningless (#143). Same
/// rules as [`InfoToastWriters`].
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct WarningToastWriters;
/// Set + self-ambiguous club for the mode-start request handlers (challenge,
/// daily, time-attack, play-by-seed, difficulty). Each drains its own
/// request stream and resets `GameStateResource` for its mode; only one
/// stream carries an event on any real frame, so member order is irrelevant
/// (club), while the set as a whole runs between the stats abandon-recorder
/// ([`crate::stats_plugin::AbandonRecord`], which must read the old game
/// first) and [`GameMutation`] (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModeStart;
/// Set wrapping `GamePlugin`'s pre-mutation game-state spine (elapsed-time
/// tick, settings sync, restore prompt). Sits between
/// [`crate::settings_plugin::SettingsMutation`] and [`GameMutation`]; other
/// pre-mutation readers of `GameStateResource` (e.g. the stats
/// abandon-recorder) order themselves `.after(PreMutation)` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PreMutation;
/// Persistence path for the in-progress game state file. `None` disables I/O. /// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>); pub struct GameStatePath(pub Option<PathBuf>);
@@ -267,7 +299,14 @@ impl Plugin for GamePlugin {
.in_set(InfoToastWriters) .in_set(InfoToastWriters)
.ambiguous_with(InfoToastWriters), .ambiguous_with(InfoToastWriters),
) )
.add_systems(Update, record_replay_on_win.after(GameMutation)) .add_systems(
Update,
record_replay_on_win
.after(GameMutation)
// Before the playback skip-recorder (which runs after
// AutoComplete) — both touch RecordingReplay (#143).
.before(crate::auto_complete_plugin::AutoComplete),
)
.add_systems( .add_systems(
Update, Update,
( (
@@ -301,9 +340,20 @@ impl Plugin for GamePlugin {
.ambiguous_with(NewGameRequestWriters), .ambiguous_with(NewGameRequestWriters),
) )
.chain() .chain()
.in_set(PreMutation),
)
.configure_sets(
Update,
PreMutation
.after(crate::settings_plugin::SettingsMutation) .after(crate::settings_plugin::SettingsMutation)
.before(GameMutation), .before(GameMutation),
) )
.configure_sets(
Update,
ModeStart
.after(crate::stats_plugin::AbandonRecord)
.before(GameMutation),
)
.init_resource::<AutoSaveTimer>() .init_resource::<AutoSaveTimer>()
.add_systems(Update, auto_save_game_state.after(GameMutation)) .add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Last, save_game_state_on_exit); .add_systems(Last, save_game_state_on_exit);
+17 -6
View File
@@ -54,12 +54,23 @@ impl Plugin for HelpPlugin {
.add_message::<TouchInput>() .add_message::<TouchInput>()
.add_systems( .add_systems(
Update, Update,
( (toggle_help_screen, handle_help_close_button)
toggle_help_screen, .chain()
handle_help_close_button, // FocusKeys consumes Esc after the modal handlers
scroll_help_panel, // (mirrors toggle_settings_screen, #143).
crate::ui_modal::touch_scroll_panel::<HelpScrollable>, .before(crate::ui_focus::FocusKeys),
), )
.add_systems(
Update,
scroll_help_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
); );
} }
} }
+33 -7
View File
@@ -334,19 +334,45 @@ impl Plugin for HomePlugin {
spawn_home_on_launch, spawn_home_on_launch,
toggle_home_screen, toggle_home_screen,
attach_focusable_to_home_mode_cards, attach_focusable_to_home_mode_cards,
handle_home_card_click, handle_home_card_click
handle_home_new_game_hero, .in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_home_new_game_hero
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_home_cancel_button, handle_home_cancel_button,
handle_home_profile_chip, handle_home_profile_chip
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
handle_home_draw_mode_buttons, handle_home_draw_mode_buttons,
handle_home_winnable_toggle, handle_home_winnable_toggle,
handle_home_deal_options_toggle, handle_home_deal_options_toggle,
handle_home_difficulty_chip_click, handle_home_difficulty_chip_click
handle_home_digit_keys, .in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_home_digit_keys
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.before(crate::ui_focus::FocusKeys),
) )
.chain(), .chain()
// The home context repaints from this frame's settled
// progress/stats/achievements; settings access joins the
// screen-plugin club (#143).
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::stats_plugin::StatsUpdate)
.after(crate::achievement_plugin::AchievementEval)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems(Update, scroll_home_panel); .add_systems(
Update,
scroll_home_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
} }
} }
+64 -10
View File
@@ -517,13 +517,31 @@ impl Plugin for HudPlugin {
Update, Update,
(restore_hud_on_modal, apply_hud_visibility) (restore_hud_on_modal, apply_hud_visibility)
.chain() .chain()
.before(LayoutSystem::UpdateOnResize), .before(LayoutSystem::UpdateOnResize)
// Toggles Visibility on HUD chrome roots only — joins
// the chrome club (#143).
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
) )
.add_systems( .add_systems(
Update, Update,
( (
update_hud_avatar.after(crate::settings_plugin::SettingsMutation), update_hud_avatar
handle_avatar_button.ambiguous_with(HudButtons), // Same-frame-exact after the settings spine, so NOT
// a SettingsAccess member — but the club's readers
// and the home deal-option writers touch settings
// fields the avatar never reads (#143).
.ambiguous_with(crate::settings_plugin::SettingsAccess)
.after(crate::settings_plugin::SettingsMutation)
// Two more settings writers arrived with the wider
// ambiguity cluster: the pause overlay's draw-mode
// toggle and the achievement onboarding flag (#143).
.after(crate::pause_plugin::PauseMutation)
.after(crate::game_plugin::InfoToastWriters),
handle_avatar_button
.ambiguous_with(HudButtons)
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
), ),
) )
.add_systems( .add_systems(
@@ -538,7 +556,11 @@ impl Plugin for HudPlugin {
// the board painters that resize card/label text (#143). // the board painters that resize card/label text (#143).
.add_systems( .add_systems(
Update, Update,
update_hud_typography.after(crate::card_plugin::BoardVisuals), update_hud_typography
.after(crate::card_plugin::BoardVisuals)
// Writes HUD TextFont only — chrome club (#143).
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
) )
.add_systems( .add_systems(
Update, Update,
@@ -551,16 +573,26 @@ impl Plugin for HudPlugin {
.after(GameMutation) .after(GameMutation)
.in_set(UiTextFx) .in_set(UiTextFx)
.ambiguous_with(UiTextFx) .ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals), .ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems( .add_systems(
Update, Update,
(start_streak_flourish, advance_streak_flourish) (start_streak_flourish, advance_streak_flourish)
.chain() .chain()
.after(GameMutation) .after(GameMutation)
// Reads the streak-milestone events StatsUpdate emits.
.after(crate::stats_plugin::StatsUpdate)
.in_set(UiTextFx) .in_set(UiTextFx)
.ambiguous_with(UiTextFx) .ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals), .ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems( .add_systems(
Update, Update,
@@ -584,16 +616,28 @@ impl Plugin for HudPlugin {
handle_help_button, handle_help_button,
handle_hint_button handle_hint_button
.after(GameMutation) .after(GameMutation)
// A press cancels/spawns the solver task before
// this frame's drop/poll pass (#143).
.before(crate::pending_hint::PendingHint)
.in_set(crate::game_plugin::InfoToastWriters) .in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters), .ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_modes_button, // The modes/menu popovers repaint the player level from
// ProgressResource — read it after this frame's XP award
// settles (#143).
handle_modes_button
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval),
handle_mode_option_click handle_mode_option_click
.in_set(NewGameRequestWriters) .in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters), .ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click, handle_modes_backdrop_click,
close_modes_popover_on_escape, close_modes_popover_on_escape,
handle_menu_button, handle_menu_button,
handle_menu_option_click, handle_menu_option_click
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval)
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
handle_menu_backdrop_click, handle_menu_backdrop_click,
close_menu_popover_on_escape, close_menu_popover_on_escape,
paint_action_buttons, paint_action_buttons,
@@ -602,8 +646,18 @@ impl Plugin for HudPlugin {
// Writes tab-label TextColor and reads TextFont — entity- // Writes tab-label TextColor and reads TextFont — entity-
// disjoint from the UiTextFx readout writers (tab labels // disjoint from the UiTextFx readout writers (tab labels
// vs HUD readouts), which Bevy can't prove, so declare it. // vs HUD readouts), which Bevy can't prove, so declare it.
animate_tab_expansion.ambiguous_with(UiTextFx), animate_tab_expansion
duck_tab_bar_during_drag, .ambiguous_with(UiTextFx)
// Tab-label fonts are never card/corner-label fonts.
.ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only reduce-motion; a frame of lag is
// invisible, so it joins the settings-reader club.
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
// Reads only reduce-motion — settings-reader club (#143).
duck_tab_bar_during_drag
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.chain() .chain()
.in_set(HudButtons) .in_set(HudButtons)
+54 -8
View File
@@ -58,6 +58,14 @@ use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING}; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::DrawStockConfig; use solitaire_core::DrawStockConfig;
/// Set wrapping the core pointer/keyboard input chain (clicks, drags, touch,
/// hotkeys). Runs between [`crate::game_plugin::PreMutation`] (it reads the
/// ticked game state) and [`crate::selection_plugin::SelectionKeySet`] (the
/// selection keys check `DragState::is_idle`); request events it emits are
/// consumed at `GameMutation` in the same frame (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PointerInput;
/// System-set labels used to anchor external systems relative to the touch /// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering. /// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
@@ -143,7 +151,13 @@ impl Plugin for InputPlugin {
// `.chain()` still orders them relative to each other. // `.chain()` still orders them relative to each other.
handle_keyboard_core handle_keyboard_core
.in_set(crate::game_plugin::DrawRequestWriters) .in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters), .ambiguous_with(crate::game_plugin::DrawRequestWriters)
// N/Z/X/… and U also feed the shared new-game and
// undo request streams (#143).
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
handle_keyboard_hint, handle_keyboard_hint,
handle_keyboard_forfeit, handle_keyboard_forfeit,
handle_stock_click handle_stock_click
@@ -159,29 +173,61 @@ impl Plugin for InputPlugin {
end_drag.before(GameMutation), end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState). // Touch drag pipeline (parallel path through DragState).
touch_start_drag.in_set(TouchDragSet::AfterStartDrag), touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
touch_follow_drag, // Moves the dragged card's Transform; UiTextFx members
// animate chrome entities only — disjoint domains Bevy
// can't prove (#143).
touch_follow_drag.ambiguous_with(crate::ui_theme::UiTextFx),
handle_double_tap, // before touch_end_drag: reads drag state pre-clear handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag touch_end_drag
.after(TouchDragSet::BeforeEndDrag) .after(TouchDragSet::BeforeEndDrag)
.before(GameMutation), .before(GameMutation),
// Reads StateChangedEvent via cursor — a reset one frame
// after the mutation is invisible (the cycle index only
// matters on the next H press), so it rides at the end
// of the input chain instead of chasing every
// StateChanged writer (#143).
// It also drains the new-game request stream via its own
// cursor, so it joins the writer club the same way
// enqueue_toasts does (#143).
reset_hint_cycle_on_state_change
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
) )
.chain(), .chain()
.in_set(PointerInput),
) )
.add_systems(Update, reset_hint_cycle_on_state_change); .configure_sets(
Update,
PointerInput
.after(crate::game_plugin::PreMutation)
.before(crate::selection_plugin::SelectionKeySet)
.before(crate::ui_focus::FocusKeys),
);
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen. // F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
// It writes only `Window.mode`; no system reads the mode, and the
// geometry change it triggers arrives via WindowResized events on a
// later frame — order against Window readers is provably irrelevant
// (#143).
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
app.add_systems(Update, handle_fullscreen); app.add_systems(Update, handle_fullscreen.ambiguous_with_all());
app app
// Async hint pipeline: state-change drop runs before the // Async hint pipeline: state-change drop runs before the
// poll system so a move applied this frame cancels any // poll system so a move applied this frame cancels any
// in-flight task before its result can be surfaced. // in-flight task before its result can be surfaced. The poll
// emits the hint toast and the pile-highlight event the board
// painters consume this frame (#143).
.add_systems( .add_systems(
Update, Update,
( (
crate::pending_hint::drop_pending_hint_on_state_change, crate::pending_hint::drop_pending_hint_on_state_change,
crate::pending_hint::poll_pending_hint_task, crate::pending_hint::poll_pending_hint_task
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.chain(), .chain()
.in_set(crate::pending_hint::PendingHint)
.after(GameMutation)
.before(crate::auto_complete_plugin::AutoComplete),
); );
} }
} }
+25 -5
View File
@@ -158,18 +158,38 @@ impl Plugin for LeaderboardPlugin {
poll_leaderboard_fetch, poll_leaderboard_fetch,
update_leaderboard_panel, update_leaderboard_panel,
handle_opt_in_button, handle_opt_in_button,
poll_opt_in_task, // The polls share the warning-toast stream with the
// daily-expiry check (drained wholesale, #143).
poll_opt_in_task
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
handle_opt_out_button, handle_opt_out_button,
poll_opt_out_task, poll_opt_out_task
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
handle_set_display_name_button, handle_set_display_name_button,
handle_display_name_text_input, handle_display_name_text_input,
handle_display_name_confirm, handle_display_name_confirm,
handle_display_name_cancel, handle_display_name_cancel,
update_leaderboard_public_name_label, // Writes one marker-filtered Text label — disjoint from
// the HUD/stats text painters (UiTextFx club, #143).
update_leaderboard_public_name_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
) )
.chain(), .chain()
// Opt-in state and display name live in
// SettingsResource — the whole chain joins the
// screen-plugin settings club (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems(Update, scroll_leaderboard_panel); .add_systems(
Update,
scroll_leaderboard_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
} }
} }
+18 -4
View File
@@ -98,11 +98,25 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin { impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>() app.init_resource::<OnboardingSlideIndex>().add_systems(
.add_systems(Update, spawn_if_first_run)
.add_systems(
Update, Update,
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(), // One chain: all three touch the slide index and the
// first-run flag in SettingsResource. Club membership
// declares the settings access disjoint from the other
// screen plugins'; the keyboard handler runs before
// FocusKeys like every other Esc consumer (#143).
(
spawn_if_first_run,
handle_onboarding_buttons,
handle_onboarding_keyboard.before(crate::ui_focus::FocusKeys),
)
.chain()
// Before the settings spine: the first-run flag write
// lands before every same-frame settings reader (they
// all sit at or after SettingsMutation transitively).
.before(crate::settings_plugin::SettingsMutation)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
); );
} }
} }
+49 -13
View File
@@ -104,6 +104,15 @@ pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
/// to disk, and routes the Forfeit confirm-modal flow. /// to disk, and routes the Forfeit confirm-modal flow.
pub struct PausePlugin; pub struct PausePlugin;
/// Set wrapping every system that mutates [`PausedResource`] or drives the
/// pause/forfeit modal flow. Runs as one deterministic chain between
/// [`crate::settings_plugin::SettingsMutation`] (the pause overlay's
/// draw-mode toggle writes `SettingsResource`) and
/// [`crate::game_plugin::GameMutation`], so every system ordered after
/// `GameMutation` observes this frame's pause state transitively (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PauseMutation;
impl Plugin for PausePlugin { impl Plugin for PausePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// add_message is idempotent — other plugins may register these // add_message is idempotent — other plugins may register these
@@ -116,28 +125,55 @@ impl Plugin for PausePlugin {
.add_message::<crate::events::ShowSolutionRequestEvent>() .add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.init_resource::<PausedResource>() .init_resource::<PausedResource>()
.configure_sets(
Update,
PauseMutation
.after(crate::settings_plugin::SettingsMutation)
// Before the pre-mutation game-state spine: the elapsed
// timer must observe this frame's pause transition, and
// the draw-mode toggle's settings write must land before
// sync_settings_to_game forwards it.
.before(crate::game_plugin::PreMutation)
.before(crate::game_plugin::GameMutation)
// toggle_pause must see SelectionState *before*
// handle_selection_keys clears it, so it can skip Escape
// when a card is selected. The other members mutate
// PausedResource, which the selection keys read.
.before(SelectionKeySet)
// HUD buttons read PausedResource (e.g. hint is inert
// while paused) — order them after this frame's pause
// transition.
.before(crate::hud_plugin::HudButtons),
)
.add_systems( .add_systems(
Update, Update,
( (
// toggle_pause must see SelectionState *before* handle_selection_keys // toggle_pause must run *before* handle_forfeit_keyboard
// clears it, so it can skip Escape when a card is selected. // so the ForfeitConfirmScreen is still alive when
// It must also run *before* handle_forfeit_keyboard so the // toggle_pause's early-return guard checks for it —
// ForfeitConfirmScreen is still alive when toggle_pause's // otherwise an Esc that closes the forfeit modal would
// early-return guard checks for it — otherwise an Esc that // also open pause in the same frame. The chain preserves
// closes the forfeit modal would also open pause in the // that edge and makes the whole pause/forfeit flow
// same frame. // deterministic (all members share PausedResource and
// the forfeit message streams).
// Reads only motion/display settings fields — disjoint
// from the screen-plugin club's writes (#143).
toggle_pause toggle_pause
.before(SelectionKeySet) .in_set(crate::settings_plugin::SettingsAccess)
.before(handle_forfeit_keyboard), .ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_pause_draw_buttons, auto_resume_on_overlay,
handle_pause_draw_buttons
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_pause_resume_button, handle_pause_resume_button,
handle_pause_forfeit_button, handle_pause_forfeit_button,
handle_pause_solution_button, handle_pause_solution_button,
handle_forfeit_request, handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard, handle_forfeit_keyboard,
auto_resume_on_overlay, handle_forfeit_confirm_buttons,
), )
.chain()
.in_set(PauseMutation),
); );
} }
} }
+7
View File
@@ -39,6 +39,13 @@ use crate::resources::{GameStateResource, HintCycleIndex};
/// emits the hint visuals once the task completes. At most one task /// emits the hint visuals once the task completes. At most one task
/// is ever in flight: a fresh H press while a previous task is /// is ever in flight: a fresh H press while a previous task is
/// running drops the previous task and queues the new one. /// running drops the previous task and queues the new one.
/// Set wrapping the async hint pipeline (state-change drop → poll). The
/// HUD hint button orders `.before` it so a press cancels/spawns before
/// this frame's poll; the drag-highlight painters order `.after` it so
/// the hint sprite writes are settled (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PendingHint;
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct PendingHintTask { pub struct PendingHintTask {
/// `Some` while the solver is still working on a verdict. /// `Some` while the solver is still working on a verdict.
+21 -4
View File
@@ -115,10 +115,27 @@ impl Plugin for PlayBySeedPlugin {
Update, Update,
( (
handle_open_dialog, handle_open_dialog,
handle_text_input, // Writes the marker-filtered seed-dialog Text nodes —
tick_debounce_and_spawn_solver_task, // disjoint from the other text painters (UiTextFx
poll_solver_task, // club, #143).
handle_confirm, handle_text_input
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
tick_debounce_and_spawn_solver_task
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
// Reads deal settings for the solver spawn; a frame
// of lag is invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
poll_solver_task
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
// Shares the new-game request stream with the other
// writers (drained wholesale at GameMutation, #143).
handle_confirm
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_cancel, handle_cancel,
) )
.chain() .chain()
+14 -1
View File
@@ -60,6 +60,14 @@ pub struct DailyCalendarDot {
/// [`spawn_profile_body`] for this tab's content. /// [`spawn_profile_body`] for this tab's content.
pub struct ProfilePlugin; pub struct ProfilePlugin;
/// Self-ambiguous club for writers of
/// [`crate::events::ToggleProfileRequestEvent`] (HUD avatar button, home
/// profile chip, HUD menu option). The You-hub reader drains the whole
/// queue, so writer append order is irrelevant; `you_hub_plugin` orders
/// its open/tab handling after the club (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProfileToggleWriters;
/// Marker on the scrollable body Node inside the Profile modal. /// Marker on the scrollable body Node inside the Profile modal.
/// ///
/// The Profile panel renders sync info, progression (incl. 14-day /// The Profile panel renders sync info, progression (incl. 14-day
@@ -82,7 +90,12 @@ impl Plugin for ProfilePlugin {
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
// Open/close/tab handling moved to `you_hub_plugin` // Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll. // (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_profile_panel); .add_systems(
Update,
scroll_profile_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
} }
} }
+36 -1
View File
@@ -37,6 +37,21 @@ pub struct LevelUpEvent {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProgressUpdate; pub struct ProgressUpdate;
/// Set for `challenge_plugin::advance_on_challenge_win`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ChallengeCompletion;
/// Set for `daily_challenge_plugin::handle_daily_completion`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct DailyCompletion;
/// Set for `weekly_goals_plugin::evaluate_weekly_goals`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct WeeklyGoalsEval;
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`, /// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
/// and emits `LevelUpEvent` whenever a win crosses a level boundary. /// and emits `LevelUpEvent` whenever a win crosses a level boundary.
/// ///
@@ -74,7 +89,27 @@ impl Plugin for ProgressPlugin {
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_systems( .add_systems(
Update, Update,
award_xp_on_win.after(GameMutation).in_set(ProgressUpdate), award_xp_on_win
.after(GameMutation)
.in_set(ProgressUpdate)
// Base win XP lands before the mode bonuses so the
// level-up attribution is stable.
.before(ChallengeCompletion),
)
// Mode-completion evaluators, pinned in one explicit chain
// INSIDE ProgressUpdate — every `.after(ProgressUpdate)`
// reader then sees fully-settled progress. Each evaluator
// drains GameWonEvent via its own cursor and writes disjoint
// ProgressResource fields plus XP, but a daily win also counts
// toward weekly goals, so two members can fire on the same
// frame — we pin an order rather than argue the XP/level-up
// event stream is commutative (#143).
.configure_sets(
Update,
(ChallengeCompletion, DailyCompletion, WeeklyGoalsEval)
.chain()
.in_set(ProgressUpdate)
.after(GameMutation),
); );
} }
} }
+18 -3
View File
@@ -185,16 +185,31 @@ impl Plugin for RadialMenuPlugin {
// Tests inject `RadialCursorOverride` themselves; production // Tests inject `RadialCursorOverride` themselves; production
// never touches it. We do not `init_resource` here so the // never touches it. We do not `init_resource` here so the
// cursor-from-window path is the default. // cursor-from-window path is the default.
// The radial gesture reacts to pointer state the drag pipeline
// settles, and its release handler feeds the shared move
// request stream consumed at GameMutation this frame, so the
// chain sits between PointerInput and GameMutation. The redraw
// reads settings (reduce-motion) where a frame of lag is
// invisible — SettingsAccess club (#143).
.add_systems( .add_systems(
Update, Update,
( (
radial_open_on_right_click, radial_open_on_right_click,
radial_open_on_long_press, radial_open_on_long_press,
radial_track_cursor, radial_track_cursor,
radial_handle_release_or_cancel, radial_handle_release_or_cancel
radial_redraw_overlay, .in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
radial_redraw_overlay
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.chain(), .chain()
.after(crate::input_plugin::PointerInput)
// Selection keys also touch DragState (mutual pointer/
// keyboard exclusion) — keys settle first.
.after(crate::selection_plugin::SelectionKeySet)
.before(crate::game_plugin::GameMutation),
); );
} }
} }
+60 -14
View File
@@ -464,25 +464,71 @@ impl Plugin for ReplayOverlayPlugin {
Update, Update,
( (
react_to_state_change, react_to_state_change,
update_banner_label, // Marker-filtered Text/Node repaints — each owns one
update_progress_text, // disjoint overlay marker, so they join the UiTextFx
update_floating_progress_chip, // club alongside the HUD text painters (#143).
update_scrub_fill, update_banner_label
update_move_log_header, .in_set(crate::ui_theme::UiTextFx)
update_move_log_active_row, .ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_prev_rows, update_progress_text
update_move_log_next_rows, .in_set(crate::ui_theme::UiTextFx)
update_mini_tableau_foundations, .ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_stock_waste, update_floating_progress_chip
update_pause_button_label, .in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_scrub_fill
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_header
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_active_row
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_prev_rows
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_next_rows
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_foundations
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_stock_waste
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_pause_button_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
handle_pause_button, handle_pause_button,
handle_step_button, // Step/scrub dispatch into the shared move/draw/undo
// request streams (drained wholesale at GameMutation),
// so they join the writer clubs (#143).
handle_step_button
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_pause_keyboard, handle_pause_keyboard,
handle_stop_keyboard, handle_stop_keyboard,
handle_arrow_keyboard, handle_arrow_keyboard
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_stop_button, handle_stop_button,
) )
.chain(), .chain()
// After the playback driver so painters render the
// post-tick state; before GameMutation so the step /
// scrub events are consumed this frame. Click handlers
// writing ReplayPlaybackState stay at the end of the
// chain — a press is observed by react_to_state_change
// next frame (state-driven loop, see above) (#143).
.after(crate::replay_playback::ReplayPlayback)
.before(crate::game_plugin::GameMutation),
); );
} }
} }
+39 -3
View File
@@ -550,6 +550,14 @@ fn fire_state_changed_on_completion(
*last_was_completed = now_completed; *last_was_completed = now_completed;
} }
/// Set wrapping the replay playback driver chain (tick → auto-clear →
/// completion `StateChangedEvent`). The tick feeds `MoveRequestEvent` /
/// `DrawRequestEvent` into this frame's [`GameMutation`], so the set sits
/// on the pre-mutation spine; the overlay UI (`replay_overlay`) orders
/// itself after this set so its painters render the post-tick state (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReplayPlayback;
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives /// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
/// playback ticks, completion linger, and the recording-pause guard. /// playback ticks, completion linger, and the recording-pause guard.
/// ///
@@ -562,18 +570,46 @@ impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>() app.init_resource::<ReplayPlaybackState>()
.add_message::<StateChangedEvent>() .add_message::<StateChangedEvent>()
.configure_sets(
Update,
ReplayPlayback
.after(crate::game_plugin::PreMutation)
// Input first: a stop/pause press or a drag-emitted
// request this frame is observed before the tick fires
// the next scripted move (#143).
.after(crate::input_plugin::PointerInput)
.before(GameMutation),
)
.add_systems( .add_systems(
Update, Update,
( (
tick_replay_playback, // The tick shares the move/draw request streams with the
// pointer/keyboard writers (drained wholesale at
// GameMutation) and reads playback speed from settings.
tick_replay_playback
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
auto_clear_completed_replay, auto_clear_completed_replay,
// Chained before GameMutation: explicit order against the
// mutation systems' StateChangedEvent writes; readers
// drain the whole queue after GameMutation.
fire_state_changed_on_completion, fire_state_changed_on_completion,
) )
.chain(), .chain()
.in_set(ReplayPlayback),
) )
.add_systems( .add_systems(
Update, Update,
record_replay_skip_during_playback.after(GameMutation), record_replay_skip_during_playback
.after(GameMutation)
// After AutoComplete so the stats overlay's watch-replay
// handler (which resets ReplayPlaybackState) and the
// win-path recorder have both settled (#143).
.after(crate::auto_complete_plugin::AutoComplete),
); );
} }
} }
+19
View File
@@ -102,6 +102,25 @@ pub enum SyncStatus {
#[derive(Resource, Debug, Clone, Default)] #[derive(Resource, Debug, Clone, Default)]
pub struct SyncStatusResource(pub SyncStatus); pub struct SyncStatusResource(pub SyncStatus);
/// Outcome of the most recent successful sync merge (Phase M — sync
/// transparency). `None` until the first pull of the session resolves.
///
/// The Account tab renders the conflict details; the sync poller emits a
/// one-line toast summary when `conflicts` is non-empty.
#[derive(Resource, Debug, Clone, Default)]
pub struct SyncConflictLog(pub Option<SyncMergeSummary>);
/// Timestamp + per-field conflict list captured from
/// [`solitaire_sync::merge`] after a pull resolves.
#[derive(Debug, Clone)]
pub struct SyncMergeSummary {
/// When the merge resolved.
pub at: DateTime<Utc>,
/// Fields that could not be merged deterministically (best-effort
/// resolution already applied — see `solitaire_sync::merge`).
pub conflicts: Vec<solitaire_sync::ConflictReport>,
}
/// Tracks which hint the player is currently cycling through. /// Tracks which hint the player is currently cycling through.
/// ///
/// Incremented on each H press so repeated presses reveal different moves. /// Incremented on each H press so repeated presses reveal different moves.
+19 -1
View File
@@ -73,11 +73,29 @@ impl Plugin for SafeAreaInsetsPlugin {
.init_resource::<SafeAreaInsets>() .init_resource::<SafeAreaInsets>()
.add_systems( .add_systems(
Update, Update,
(
// All three write Node padding on disjoint chrome roots
// (top anchors / bottom anchors / modal scrims); chained
// for a deterministic pass, and they join the UiTextFx
// chrome club — anchored roots are never the label/
// tooltip/readout entities the other members touch,
// which Bevy can't prove from the queries (#143).
( (
apply_safe_area_anchors, apply_safe_area_anchors,
apply_safe_area_bottom_anchors, apply_safe_area_bottom_anchors,
apply_safe_area_to_modal_scrims, apply_safe_area_to_modal_scrims,
on_app_resumed, )
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
// UiScale is written on the settings mutator spine.
.after(crate::settings_plugin::SettingsMutation),
// Emits a synthetic WindowResized on resume only; every
// reader drains the queue via cursor, and a resume
// already spans hundreds of ms, so whether the synthetic
// event lands this frame or next is invisible — same
// argument as handle_fullscreen (#143).
on_app_resumed.ambiguous_with_all(),
), ),
); );
+63 -1
View File
@@ -23,14 +23,43 @@ mod tests {
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings}; use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
use bevy::prelude::*; use bevy::prelude::*;
use crate::achievement_plugin::AchievementPlugin;
use crate::animation_plugin::AnimationPlugin;
use crate::auto_complete_plugin::AutoCompletePlugin; use crate::auto_complete_plugin::AutoCompletePlugin;
use crate::card_animation::CardAnimationPlugin;
use crate::card_plugin::CardPlugin; use crate::card_plugin::CardPlugin;
use crate::challenge_plugin::ChallengePlugin;
use crate::cursor_plugin::CursorPlugin;
use crate::daily_challenge_plugin::DailyChallengePlugin;
use crate::difficulty_plugin::DifficultyPlugin;
use crate::feedback_anim_plugin::FeedbackAnimPlugin;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::help_plugin::HelpPlugin;
use crate::home_plugin::HomePlugin;
use crate::hud_plugin::HudPlugin; use crate::hud_plugin::HudPlugin;
use crate::input_plugin::InputPlugin;
use crate::leaderboard_plugin::LeaderboardPlugin;
use crate::onboarding_plugin::OnboardingPlugin;
use crate::pause_plugin::PausePlugin;
use crate::play_by_seed_plugin::PlayBySeedPlugin;
use crate::profile_plugin::ProfilePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::radial_menu::RadialMenuPlugin;
use crate::replay_overlay::ReplayOverlayPlugin;
use crate::replay_playback::ReplayPlaybackPlugin;
use crate::safe_area::SafeAreaInsetsPlugin;
use crate::selection_plugin::SelectionPlugin;
use crate::settings_plugin::SettingsPlugin; use crate::settings_plugin::SettingsPlugin;
use crate::splash_plugin::SplashPlugin;
use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use crate::time_attack_plugin::TimeAttackPlugin;
use crate::touch_selection_plugin::TouchSelectionPlugin;
use crate::ui_focus::UiFocusPlugin; use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin; use crate::ui_modal::UiModalPlugin;
use crate::ui_tooltip::UiTooltipPlugin;
use crate::weekly_goals_plugin::WeeklyGoalsPlugin;
use crate::win_summary_plugin::WinSummaryPlugin;
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the /// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
/// same day (#143, PRs #146#149) — this is now a hard gate. If your /// same day (#143, PRs #146#149) — this is now a hard gate. If your
@@ -51,7 +80,40 @@ mod tests {
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin::headless()); .add_plugins(SettingsPlugin::headless())
// Batch: game-adjacent plugins (all already exercised headless
// by the engine behaviour tests).
.add_plugins(ProgressPlugin::headless())
.add_plugins(StatsPlugin::headless())
.add_plugins(AchievementPlugin::headless())
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(PausePlugin)
// Batch: screen/overlay plugins.
.add_plugins(HomePlugin::headless())
.add_plugins(WinSummaryPlugin)
.add_plugins(SplashPlugin)
.add_plugins(HelpPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(ProfilePlugin)
.add_plugins(LeaderboardPlugin)
// Batch: mode/replay/input plugins.
.add_plugins(ChallengePlugin)
.add_plugins(DailyChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(InputPlugin)
.add_plugins(TouchSelectionPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(CursorPlugin)
.add_plugins(SafeAreaInsetsPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app app
} }
+32 -3
View File
@@ -134,6 +134,14 @@ impl KeyboardDragState {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionKeySet; pub struct SelectionKeySet;
/// Set wrapping the post-mutation selection maintenance chain (clearing the
/// selection on state change, painting the highlight). Runs between
/// [`crate::game_plugin::GameMutation`] and
/// [`crate::card_plugin::BoardVisuals`] so the painters read the settled
/// selection/drag state (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionFx;
/// Marker component placed on the outline sprite used as the keyboard-selection /// Marker component placed on the outline sprite used as the keyboard-selection
/// highlight. /// highlight.
/// ///
@@ -158,14 +166,35 @@ impl Plugin for SelectionPlugin {
( (
handle_selection_keys handle_selection_keys
.in_set(SelectionKeySet) .in_set(SelectionKeySet)
.before(GameMutation), .before(GameMutation)
clear_selection_on_state_change.after(GameMutation), // Enter-to-move feeds the shared move request
update_selection_highlight.after(GameMutation).run_if( // stream (drained wholesale at GameMutation, #143).
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
(
clear_selection_on_state_change,
update_selection_highlight.run_if(
resource_changed::<SelectionState> resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>) .or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<GameStateResource>), .or(resource_changed::<GameStateResource>),
), ),
)
.chain()
.in_set(SelectionFx),
), ),
)
.configure_sets(
Update,
SelectionFx
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals),
)
.configure_sets(
Update,
// Selection reads GameStateResource (legal destinations) —
// run after the pre-mutation spine that ticks/mutates it
// (#143).
SelectionKeySet.after(crate::game_plugin::PreMutation),
); );
} }
} }
@@ -0,0 +1,139 @@
//! Phase M (sync transparency): local backup export/import.
//!
//! Runs in the `Last` schedule — the export snapshot is taken after the
//! whole frame settles, and the import's resource rewrites become visible
//! to every Update system on the next frame, keeping this rare bulk
//! operation off the annotated Update spine entirely.
use bevy::prelude::*;
use chrono::Utc;
use solitaire_data::{DataBundle, data_bundle_path, load_bundle_from, save_bundle_to};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{
DataExportRequestEvent, DataImportRequestEvent, InfoToastEvent, WarningToastEvent,
};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::stats_plugin::{StatsResource, StatsStoragePath};
use super::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
/// Drains the export/import request streams and performs the file IO.
///
/// The bundle is small (a few KB of JSON), so the write happens inline
/// rather than on the async pool — one synchronous write per explicit
/// button press, never per frame.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_data_transfer(
mut exports: MessageReader<DataExportRequestEvent>,
mut imports: MessageReader<DataImportRequestEvent>,
mut settings: ResMut<SettingsResource>,
settings_path: Res<SettingsStoragePath>,
stats: Option<ResMut<StatsResource>>,
stats_path: Option<Res<StatsStoragePath>>,
achievements: Option<ResMut<AchievementsResource>>,
achievements_path: Option<Res<AchievementsStoragePath>>,
progress: Option<ResMut<ProgressResource>>,
progress_path: Option<Res<ProgressStoragePath>>,
mut settings_changed: MessageWriter<SettingsChangedEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
) {
let export_requested = exports.read().next().is_some();
let import_requested = imports.read().next().is_some();
if !export_requested && !import_requested {
return;
}
// The bundle lives next to settings.json (same app data dir in
// production); deriving it from the injected settings path keeps
// tests off the real platform directory.
let path = settings_path
.0
.as_ref()
.map(|p| p.with_file_name(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME))
.or_else(data_bundle_path);
let Some(path) = path else {
warning_toast.write(WarningToastEvent(
"No data directory available on this platform".to_string(),
));
return;
};
// An import in the same frame as an export would race on the file;
// real UI can't produce both, but be deterministic anyway: export
// first, then import reads what was just written (a no-op restore).
if export_requested {
let bundle = DataBundle {
schema_version: solitaire_data::DATA_BUNDLE_SCHEMA_VERSION,
exported_at: Utc::now(),
settings: settings.0.clone(),
stats: stats.as_ref().map(|s| s.0.clone()).unwrap_or_default(),
achievements: achievements
.as_ref()
.map(|a| a.0.clone())
.unwrap_or_default(),
progress: progress.as_ref().map(|p| p.0.clone()).unwrap_or_default(),
};
match save_bundle_to(&path, &bundle) {
Ok(()) => {
info_toast.write(InfoToastEvent(format!(
"Data exported to {}",
path.display()
)));
}
Err(e) => {
warn!("data export failed: {e}");
warning_toast.write(WarningToastEvent(format!("Export failed: {e}")));
}
}
}
if import_requested {
match load_bundle_from(&path) {
Ok(bundle) => {
settings.0 = bundle.settings;
if let Some(target) = &settings_path.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
{
warn!("import: failed to persist settings: {e}");
}
// Settings appliers (theme, audio, layout) react to the
// change event exactly as if edited in the panel.
settings_changed.write(SettingsChangedEvent(settings.0.clone()));
if let Some(mut s) = stats {
s.0 = bundle.stats;
if let Some(target) = stats_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = solitaire_data::save_stats_to(target, &s.0)
{
warn!("import: failed to persist stats: {e}");
}
}
if let Some(mut a) = achievements {
a.0 = bundle.achievements;
if let Some(target) = achievements_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = solitaire_data::save_achievements_to(target, &a.0)
{
warn!("import: failed to persist achievements: {e}");
}
}
if let Some(mut p) = progress {
p.0 = bundle.progress;
if let Some(target) = progress_path.as_ref().and_then(|pp| pp.0.as_ref())
&& let Err(e) = solitaire_data::save_progress_to(target, &p.0)
{
warn!("import: failed to persist progress: {e}");
}
}
info_toast.write(InfoToastEvent(format!(
"Backup from {} restored",
bundle.exported_at.format("%Y-%m-%d %H:%M UTC")
)));
}
Err(e) => {
warn!("data import failed: {e}");
warning_toast.write(WarningToastEvent(format!("Import failed: {e}")));
}
}
}
}
+15 -2
View File
@@ -433,6 +433,9 @@ pub(super) fn handle_settings_buttons(
SettingsButton::OpenThemeStore => { SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`. // Handled by `handle_sync_buttons`.
} }
SettingsButton::ExportData | SettingsButton::ImportData => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => { SettingsButton::Done => {
screen.0 = false; screen.0 = false;
} }
@@ -460,14 +463,18 @@ pub(super) fn handle_tab_buttons(
} }
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect, /// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within /// Delete Account, and the Phase M Export/Import data pair. Split from
/// Bevy's 16-parameter system limit. /// `handle_settings_buttons` to stay within Bevy's 16-parameter system
/// limit.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_sync_buttons( pub(super) fn handle_sync_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>, mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>, mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>, mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>, mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
mut export_data: MessageWriter<crate::events::DataExportRequestEvent>,
mut import_data: MessageWriter<crate::events::DataImportRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter< #[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent, crate::events::ThemeStoreOpenRequestEvent,
>, >,
@@ -500,6 +507,12 @@ pub(super) fn handle_sync_buttons(
SettingsButton::DeleteAccount => { SettingsButton::DeleteAccount => {
delete_account.write(DeleteAccountRequestEvent); delete_account.write(DeleteAccountRequestEvent);
} }
SettingsButton::ExportData => {
export_data.write(crate::events::DataExportRequestEvent);
}
SettingsButton::ImportData => {
import_data.write(crate::events::DataImportRequestEvent);
}
_ => {} _ => {}
} }
} }
+47 -3
View File
@@ -25,6 +25,7 @@ use crate::events::{
use crate::resources::SettingsScrollPos; use crate::resources::SettingsScrollPos;
use crate::theme::ThemeThumbnailPair; use crate::theme::ThemeThumbnailPair;
mod data_transfer;
mod input; mod input;
mod ui; mod ui;
mod updates; mod updates;
@@ -87,6 +88,19 @@ pub struct SettingsChangedEvent(pub Settings);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation; pub struct SettingsMutation;
/// Self-ambiguous club for the screen/overlay systems that touch
/// [`SettingsResource`] (onboarding first-run flag, home difficulty/draw
/// chips, leaderboard opt-in and display name, pause draw toggle, splash
/// reduce-motion reads, …). Each accessor touches its own disjoint fields,
/// and the modal-stacking guard means at most one of these screens is
/// interactive at a time — same-frame access order is provably irrelevant,
/// so members declare `.in_set(SettingsAccess).ambiguous_with(SettingsAccess)`
/// (#143). [`SettingsMutation`] is nested inside the club (its members keep
/// their internal chain). Do NOT add members whose settings read must be
/// same-frame-exact; order those `.after(SettingsMutation)` instead.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsAccess;
/// Marker on the root Settings panel entity. /// Marker on the root Settings panel entity.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct SettingsPanel; pub struct SettingsPanel;
@@ -111,6 +125,12 @@ struct ThemeText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SyncStatusText; struct SyncStatusText;
/// Marks the headline `Text` node of the last-merge summary in the
/// Account tab (Phase M — sync transparency). Tests query it to assert
/// the summary rendered.
#[derive(Component, Debug)]
pub(super) struct SyncMergeSummaryText;
/// Marks the `Text` node showing the active card-back index. /// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct CardBackText; struct CardBackText;
@@ -321,6 +341,10 @@ enum SettingsButton {
DisconnectSync, DisconnectSync,
/// Open the account-deletion confirmation modal. /// Open the account-deletion confirmation modal.
DeleteAccount, DeleteAccount,
/// Write every JSON save into one backup bundle (Phase M).
ExportData,
/// Restore every JSON save from the backup bundle (Phase M).
ImportData,
Done, Done,
/// Select a specific card-back by index from the picker row. /// Select a specific card-back by index from the picker row.
SelectCardBack(usize), SelectCardBack(usize),
@@ -386,6 +410,8 @@ impl SettingsButton {
SettingsButton::ConnectSync => 91, SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92, SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93, SettingsButton::DeleteAccount => 93,
SettingsButton::ExportData => 94,
SettingsButton::ImportData => 95,
// Done is tagged by `attach_focusable_to_modal_buttons` and // Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the // never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness. // value here is only a fallback for completeness.
@@ -439,6 +465,15 @@ impl Plugin for SettingsPlugin {
.add_message::<SyncConfigureRequestEvent>() .add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>() .add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>() .add_message::<DeleteAccountRequestEvent>()
.add_message::<crate::events::DataExportRequestEvent>()
.add_message::<crate::events::DataImportRequestEvent>()
// Idempotent — the sync plugin also registers it; needed here
// so the transfer handler's failure toasts work headless.
.add_message::<crate::events::WarningToastEvent>()
// Bulk backup/restore lives in Last: the export snapshot is
// taken post-frame and an import's rewrites surface next
// frame — keeps the rare path off the annotated Update spine.
.add_systems(Last, data_transfer::handle_data_transfer)
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>() .add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
@@ -459,7 +494,12 @@ impl Plugin for SettingsPlugin {
Update, Update,
SettingsMutation SettingsMutation
.after(crate::layout::LayoutSystem::UpdateOnResize) .after(crate::layout::LayoutSystem::UpdateOnResize)
.before(crate::game_plugin::GameMutation), .before(crate::game_plugin::GameMutation)
// Nested in the club: members keep their chain, but
// their pairs against other screen-plugin settings
// accessors are declared irrelevant (#143).
.in_set(SettingsAccess)
.ambiguous_with(SettingsAccess),
) )
.add_systems( .add_systems(
Update, Update,
@@ -482,8 +522,12 @@ impl Plugin for SettingsPlugin {
toggle_settings_screen toggle_settings_screen
.before(crate::ui_focus::FocusKeys) .before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons), .ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel, scroll_settings_panel
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>, .in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
) )
.chain(), .chain(),
); );
@@ -793,3 +793,93 @@ fn ui_scale_out_of_range_sanitizes_on_load() {
.sanitized(); .sanitized();
assert_eq!(tiny.ui_scale, UI_SCALE_MIN); assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
} }
// ---------------------------------------------------------------------------
// Phase M: local data export / import
// ---------------------------------------------------------------------------
/// App with a real (temp-dir) settings path so the data-transfer handler
/// derives its bundle path inside the temp dir instead of the platform
/// data directory.
fn transfer_app(dir: &std::path::Path) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(SettingsPlugin {
storage_path: Some(dir.join("settings.json")),
ui_enabled: false,
});
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn export_then_import_round_trips_settings() {
let dir = std::env::temp_dir().join(format!("settings_transfer_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let mut app = transfer_app(&dir);
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.25;
app.world_mut()
.write_message(crate::events::DataExportRequestEvent);
app.update();
assert!(
dir.join(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME)
.exists(),
"export should write the bundle next to settings.json"
);
// Clobber the live value, then restore from the bundle.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 1.0;
app.world_mut()
.write_message(crate::events::DataImportRequestEvent);
app.update();
// Import handling runs in Last of the same frame; the rewrite is
// visible immediately after update() returns.
let restored = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(
(restored - 0.25).abs() < f32::EPSILON,
"import should restore the exported value, got {restored}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_without_backup_warns_instead_of_defaulting() {
let dir =
std::env::temp_dir().join(format!("settings_transfer_missing_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let mut app = transfer_app(&dir);
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.25;
app.world_mut()
.write_message(crate::events::DataImportRequestEvent);
app.update();
// Settings untouched (no silent default-restore)…
let v = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(
(v - 0.25).abs() < f32::EPSILON,
"failed import must not touch settings"
);
// …and the failure is surfaced as a warning toast.
let msgs = app
.world()
.resource::<Messages<crate::events::WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).any(|w| w.0.contains("Import failed")),
"missing backup should emit a warning toast"
);
let _ = std::fs::remove_dir_all(&dir);
}
+143 -1
View File
@@ -46,6 +46,7 @@ pub(super) fn spawn_settings_panel(
scroll_offset: f32, scroll_offset: f32,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
theme_overrides_back: bool, theme_overrides_back: bool,
conflict_log: Option<&crate::resources::SyncConflictLog>,
active_tab: SettingsTab, active_tab: SettingsTab,
) { ) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| { spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
@@ -100,7 +101,9 @@ pub(super) fn spawn_settings_panel(
font_res, font_res,
), ),
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res), SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res), SettingsTab::Account => {
spawn_account_tab(body, settings, sync_status, conflict_log, font_res)
}
}); });
// Done is the only action — primary so the player always knows // Done is the only action — primary so the player always knows
@@ -353,6 +356,7 @@ fn spawn_account_tab(
body: &mut ChildSpawnerCommands, body: &mut ChildSpawnerCommands,
settings: &Settings, settings: &Settings,
sync_status: &str, sync_status: &str,
conflict_log: Option<&crate::resources::SyncConflictLog>,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
// --- Privacy (only shown when a Matomo URL is configured) --- // --- Privacy (only shown when a Matomo URL is configured) ---
@@ -372,6 +376,144 @@ fn spawn_account_tab(
// --- Sync --- // --- Sync ---
section_label(body, "Sync", font_res); section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res); sync_row(body, sync_status, &settings.sync_backend, font_res);
spawn_sync_merge_summary(body, conflict_log, font_res);
// --- Local data (Phase M) --- backup/restore of the JSON saves;
// filesystem-backed, so not offered on the wasm build.
#[cfg(not(target_arch = "wasm32"))]
{
section_label(body, "Local data", font_res);
local_data_row(body, font_res);
}
}
/// `Export data` / `Import data` pill buttons plus a caption naming the
/// backup file (Phase M). Mirrors the sync row's pill-button styling.
#[cfg(not(target_arch = "wasm32"))]
fn local_data_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let button_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let caption_font = button_font.clone();
let small_button = |row: &mut ChildSpawnerCommands,
marker: SettingsButton,
label: &str,
tooltip: String,
font: TextFont| {
row.spawn((
marker,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
});
};
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|col| {
col.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
small_button(
row,
SettingsButton::ExportData,
"Export data",
"Write settings, stats, achievements and progress to one backup file"
.to_string(),
button_font.clone(),
);
small_button(
row,
SettingsButton::ImportData,
"Import data",
"Restore all saves from the backup file (replaces current data)".to_string(),
button_font.clone(),
);
});
col.spawn((
Text::new(format!(
"Backup file: {}",
solitaire_data::data_bundle_path()
.map_or_else(|| "unavailable".to_string(), |p| p.display().to_string())
)),
caption_font,
TextColor(TEXT_SECONDARY),
));
});
}
/// Phase M (sync transparency): renders what the most recent sync merge
/// did — a clean-merge caption, or one line per conflicting field with
/// both values, so the player can see exactly which numbers were
/// reconciled and how.
fn spawn_sync_merge_summary(
body: &mut ChildSpawnerCommands,
conflict_log: Option<&crate::resources::SyncConflictLog>,
font_res: Option<&FontResource>,
) {
let Some(summary) = conflict_log.and_then(|log| log.0.as_ref()) else {
return;
};
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let when = summary.at.format("%H:%M UTC");
if summary.conflicts.is_empty() {
body.spawn((
SyncMergeSummaryText,
Text::new(format!("Last merge at {when}: no conflicts")),
caption_font,
TextColor(TEXT_SECONDARY),
));
return;
}
let n = summary.conflicts.len();
let plural = if n == 1 { "conflict" } else { "conflicts" };
body.spawn((
SyncMergeSummaryText,
Text::new(format!(
"Last merge at {when}: {n} {plural} — kept newer values"
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
for c in &summary.conflicts {
body.spawn((
Text::new(format!(
" {} — this device: {} / server: {}",
c.field, c.local_value, c.remote_value
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
} }
/// Section divider — small lavender label inside the scrollable body. /// Section divider — small lavender label inside the scrollable body.
@@ -32,6 +32,7 @@ pub(super) fn sync_settings_panel_visibility(
theme_registry: Option<Res<crate::theme::ThemeRegistry>>, theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>, theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>, card_images: Option<Res<crate::card_plugin::CardImageSet>>,
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
) { ) {
if !screen.is_changed() { if !screen.is_changed() {
return; return;
@@ -47,6 +48,7 @@ pub(super) fn sync_settings_panel_visibility(
theme_registry.as_deref(), theme_registry.as_deref(),
theme_thumbs.as_deref(), theme_thumbs.as_deref(),
card_images.as_deref(), card_images.as_deref(),
conflict_log.as_deref(),
scroll_pos.0, scroll_pos.0,
active_tab.0, active_tab.0,
); );
@@ -76,6 +78,7 @@ fn build_panel(
theme_registry: Option<&crate::theme::ThemeRegistry>, theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>, theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>, card_images: Option<&crate::card_plugin::CardImageSet>,
conflict_log: Option<&crate::resources::SyncConflictLog>,
scroll_offset: f32, scroll_offset: f32,
active_tab: SettingsTab, active_tab: SettingsTab,
) { ) {
@@ -125,6 +128,7 @@ fn build_panel(
scroll_offset, scroll_offset,
font_res, font_res,
theme_overrides_back, theme_overrides_back,
conflict_log,
active_tab, active_tab,
); );
} }
@@ -145,6 +149,7 @@ pub(super) fn rebuild_panel_on_tab_change(
theme_registry: Option<Res<crate::theme::ThemeRegistry>>, theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>, theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>, card_images: Option<Res<crate::card_plugin::CardImageSet>>,
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
) { ) {
if !active_tab.is_changed() || active_tab.is_added() { if !active_tab.is_changed() || active_tab.is_added() {
return; return;
@@ -167,6 +172,7 @@ pub(super) fn rebuild_panel_on_tab_change(
theme_registry.as_deref(), theme_registry.as_deref(),
theme_thumbs.as_deref(), theme_thumbs.as_deref(),
card_images.as_deref(), card_images.as_deref(),
conflict_log.as_deref(),
0.0, 0.0,
active_tab.0, active_tab.0,
); );
+12 -1
View File
@@ -99,7 +99,18 @@ impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems( app.add_systems(Startup, spawn_splash).add_systems(
Update, Update,
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor).chain(), // Splash text/fade writers touch UI entities only — they join
// the UiTextFx club (disjoint from board/anim sets) and, for
// the reduce-motion read in the pulse, the screen-plugin
// settings club. Disjoint from the HUD button painters for the
// same entity-domain reason (#143).
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::hud_plugin::HudButtons)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
); );
} }
} }
+83 -11
View File
@@ -46,6 +46,14 @@ pub struct StatsStoragePath(pub Option<PathBuf>);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct StatsUpdate; pub struct StatsUpdate;
/// Set holding the abandon-recorder (`update_stats_on_new_game`). It must
/// read the outgoing game's `move_count` before any mode-start handler or
/// `GameMutation` clobbers the game state — mode plugins order their reset
/// handlers `.after(AbandonRecord)` via `crate::game_plugin::ModeStart`
/// (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AbandonRecord;
/// Marker component on the stats overlay root node. /// Marker component on the stats overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StatsScreen; pub struct StatsScreen;
@@ -205,10 +213,34 @@ impl Plugin for StatsPlugin {
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)), // constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
// and a system cannot be both inside a set and individually before a // and a system cannot be both inside a set and individually before a
// set-level ordering constraint. // set-level ordering constraint.
.add_systems(Update, update_stats_on_new_game.before(GameMutation))
.add_systems( .add_systems(
Update, Update,
update_stats_on_win.after(GameMutation).in_set(StatsUpdate), update_stats_on_new_game
.in_set(AbandonRecord)
.before(GameMutation)
// Reads the shared new-game request stream via its own
// cursor — append order is irrelevant, so it joins the
// self-ambiguous writer club (NewGameRequestWriters must
// not carry set-level edges; see its doc). The explicit
// edges pin it after the pause/settings/selection
// mutators and the pre-mutation game-state spine it
// reads `move_count` from (#143).
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.after(crate::game_plugin::PreMutation)
.after(crate::pause_plugin::PauseMutation)
.after(crate::settings_plugin::SettingsMutation)
.after(crate::selection_plugin::SelectionKeySet),
)
.add_systems(
Update,
update_stats_on_win
.after(GameMutation)
.in_set(StatsUpdate)
// Also emits toasts (streak milestones) — join the
// self-ambiguous writer club (#143).
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.add_systems( .add_systems(
Update, Update,
@@ -216,29 +248,69 @@ impl Plugin for StatsPlugin {
// the NewGameRequestEvent it emits is not visible to // the NewGameRequestEvent it emits is not visible to
// update_stats_on_new_game in the same frame — otherwise // update_stats_on_new_game in the same frame — otherwise
// record_abandoned() fires twice on every forfeit (#21). // record_abandoned() fires twice on every forfeit (#21).
// It drains the ForfeitEvent the pause/forfeit modal chain
// emits, and shares the new-game request stream with the
// other writers (#143).
handle_forfeit handle_forfeit
.before(GameMutation) .before(GameMutation)
.before(update_stats_on_new_game), .before(update_stats_on_new_game)
.after(crate::pause_plugin::PauseMutation)
.after(crate::selection_plugin::SelectionKeySet)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
) )
// Open/close/tab handling moved to `you_hub_plugin` // Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content, replay // (Phase E) — this plugin now owns body content, replay
// selector behavior, and scroll. // selector behavior, and scroll.
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation)) //
.add_systems(Update, handle_watch_replay_button) // Stats overlay UI: one deterministic chain — every member
.add_systems(Update, handle_copy_share_link_button) // touches the replay-selector state or the overlay's nodes, and
// handle_watch_replay_button resets the live game, so the chain
// sits between GameMutation and the auto-complete driver on the
// game-state timeline (#143). The toast-emitting members join
// the self-ambiguous InfoToastWriters set so the toast pipeline
// (AnimFx) drains them this frame.
.add_systems( .add_systems(
Update, Update,
( (
refresh_replay_history_on_win,
handle_replay_selector_buttons, handle_replay_selector_buttons,
repaint_replay_selector_caption, // Marker-filtered Text repaints — disjoint from the
repaint_replay_selector_detail, // HUD text painters (UiTextFx club, #143).
repaint_replay_selector_caption
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
repaint_replay_selector_detail
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
handle_watch_replay_button
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_copy_share_link_button
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.chain(), .chain()
.after(GameMutation)
// The overlay repaints read this frame's settled stats,
// progress, and achievement evaluation (level line,
// streak).
.after(StatsUpdate)
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval)
.before(crate::auto_complete_plugin::AutoComplete),
) )
.add_systems(Update, scroll_stats_panel)
.add_systems( .add_systems(
Update, Update,
crate::ui_modal::touch_scroll_panel::<StatsScrollable>, scroll_stats_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<StatsScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
); );
} }
} }
+28 -4
View File
@@ -26,11 +26,15 @@ use solitaire_sync::{SyncPayload, merge};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath}; use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{ use crate::events::{
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent, GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
WarningToastEvent,
}; };
use crate::game_plugin::RecordingReplay; use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource}; use crate::resources::{
GameStateResource, SyncConflictLog, SyncMergeSummary, SyncStatus, SyncStatusResource,
TokioRuntimeResource,
};
use crate::stats_plugin::{ use crate::stats_plugin::{
LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath, LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath,
}; };
@@ -106,9 +110,13 @@ impl Plugin for SyncPlugin {
.init_resource::<PullTaskResult>() .init_resource::<PullTaskResult>()
.init_resource::<PullTask>() .init_resource::<PullTask>()
.init_resource::<PendingReplayUpload>() .init_resource::<PendingReplayUpload>()
.init_resource::<SyncConflictLog>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncConfigureRequestEvent>() .add_message::<SyncConfigureRequestEvent>()
.add_message::<WarningToastEvent>(); .add_message::<WarningToastEvent>()
// Idempotent — GamePlugin also registers it; re-register so the
// conflict-summary toast works under MinimalPlugins tests.
.add_message::<InfoToastEvent>();
// Build the shared Tokio runtime; disable all network sync if the OS // Build the shared Tokio runtime; disable all network sync if the OS
// refuses to create threads (resource-limited environments, sandboxed // refuses to create threads (resource-limited environments, sandboxed
@@ -198,6 +206,8 @@ fn poll_pull_result(
progress_path: Res<ProgressStoragePath>, progress_path: Res<ProgressStoragePath>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>, mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>, mut warning_toast: MessageWriter<WarningToastEvent>,
mut conflict_log: ResMut<SyncConflictLog>,
mut info_toast: MessageWriter<InfoToastEvent>,
) { ) {
let Some(task) = task_res.0.as_mut() else { let Some(task) = task_res.0.as_mut() else {
return; return;
@@ -210,7 +220,21 @@ 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);
// Phase M (sync transparency): keep the conflict details for
// the Account tab and summarise non-clean merges in a toast.
if !conflicts.is_empty() {
let n = conflicts.len();
let plural = if n == 1 { "conflict" } else { "conflicts" };
info_toast.write(InfoToastEvent(format!(
"Synced — {n} {plural}, kept newer values"
)));
}
conflict_log.0 = Some(SyncMergeSummary {
at: Utc::now(),
conflicts,
});
// Persist merged state atomically. // Persist merged state atomically.
if let Some(p) = &stats_path.0 if let Some(p) = &stats_path.0
+6 -1
View File
@@ -112,7 +112,12 @@ impl Plugin for TablePlugin {
// paint pipeline — markers and cards share Sprite/ // paint pipeline — markers and cards share Sprite/
// Transform access (#143). // Transform access (#143).
( (
apply_theme_on_settings_change, // Drains SettingsChangedEvent via cursor — a home
// deal-option edit (post-mutation SettingsAccess
// writer) surfacing one frame later repaints one
// frame later, which is invisible (#143).
apply_theme_on_settings_change
.ambiguous_with(crate::settings_plugin::SettingsAccess),
apply_hint_pile_highlight, apply_hint_pile_highlight,
tick_hint_pile_highlights, tick_hint_pile_highlights,
sync_pile_marker_visibility.after(GameMutation), sync_pile_marker_visibility.after(GameMutation),
+32 -4
View File
@@ -109,13 +109,41 @@ impl Plugin for TimeAttackPlugin {
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>() .add_message::<StartTimeAttackRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems( .add_systems(
Update, Update,
handle_start_time_attack_request.before(GameMutation), handle_start_time_attack_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// One post-mutation chain for the TimeAttackResource writers:
// clock tick, win auto-deal, then the debounced disk save.
// The start handler (ModeStart, pre-mutation) is ordered
// against all three transitively. auto_deal's new-game request
// is drained wholesale next frame, so it joins the writer club
// (#143). PausedResource is settled: PauseMutation < GameMutation.
.add_systems(
Update,
(
advance_time_attack,
auto_deal_on_time_attack_win
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
auto_save_time_attack_session,
)
.chain()
.after(GameMutation)
// The HUD clock (after AutoComplete) and the ended-toast
// handler (AnimFx) read what this chain writes — settle
// it before both (#143).
.before(crate::auto_complete_plugin::AutoComplete)
.before(crate::animation_plugin::AnimFx),
) )
.add_systems(Update, advance_time_attack)
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation))
.add_systems(Update, auto_save_time_attack_session)
.add_systems(Last, save_time_attack_session_on_exit); .add_systems(Last, save_time_attack_session_on_exit);
} }
} }
@@ -100,7 +100,11 @@ impl Plugin for TouchSelectionPlugin {
update_touch_selection_highlight, update_touch_selection_highlight,
) )
.chain() .chain()
.after(GameMutation), .after(GameMutation)
// Selection cleanup and highlight placement settle before
// the board painters repaint (and before resync's synthetic
// StateChanged / fan-frac's layout write land) (#143).
.before(crate::card_plugin::BoardVisuals),
); );
} }
} }
+14 -1
View File
@@ -546,6 +546,15 @@ pub fn spawn_tab_chip<M: Component>(
// Generic touch-scroll helper // Generic touch-scroll helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Self-ambiguous writer set for the per-modal scroll systems (mouse-wheel
/// and touch variants). Every member writes `ScrollPosition`, but each
/// targets only its own modal's scrollable body via a marker component, and
/// the modal-stacking guard means at most one such panel exists at a time —
/// the writes are disjoint by design, so members declare
/// `.in_set(PanelScroll).ambiguous_with(PanelScroll)` (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PanelScroll;
/// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger /// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger
/// touch pan. Add this as a system for each scrollable modal panel: /// touch pan. Add this as a system for each scrollable modal panel:
/// ///
@@ -840,7 +849,11 @@ impl Plugin for UiModalPlugin {
.in_set(crate::ui_theme::UiTextFx) .in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx) .ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals) .ambiguous_with(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::hud_plugin::HudButtons), .ambiguous_with(crate::hud_plugin::HudButtons)
// Reads only the anim-speed settings field — disjoint from
// the screen-plugin club's writes (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
); );
// Click-outside-to-dismiss is independent of the open // Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs // animation chain — it reads `just_pressed(Left)` and runs
+17 -1
View File
@@ -93,7 +93,23 @@ impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<TooltipState>() app.init_resource::<TooltipState>()
.add_systems(Startup, spawn_tooltip_overlay) .add_systems(Startup, spawn_tooltip_overlay)
.add_systems(Update, (track_tooltip_hover, show_or_hide_tooltip).chain()); // The tooltip pair only touches the tooltip overlay entity and
// reads settings for the enable flag — chrome-disjoint from the
// other UiTextFx members, and a settings read that may lag a
// frame joins the SettingsAccess club (#143).
.add_systems(
Update,
(track_tooltip_hover, show_or_hide_tooltip)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess)
// The tooltip node is neither a board entity nor a pile
// marker; the visibility writes can't collide (#143).
.ambiguous_with(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::table_plugin::MarkerVisuals),
);
} }
} }
+5 -5
View File
@@ -10,8 +10,8 @@ use solitaire_data::{
}; };
use crate::events::{GameWonEvent, XpAwardedEvent}; use crate::events::{GameWonEvent, XpAwardedEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
/// Fired when the player has just completed a weekly goal. /// Fired when the player has just completed a weekly goal.
@@ -33,11 +33,11 @@ impl Plugin for WeeklyGoalsPlugin {
.add_systems(Startup, roll_weekly_goals_on_startup) .add_systems(Startup, roll_weekly_goals_on_startup)
// Run after GameMutation (so GameWonEvent is available) and // Run after GameMutation (so GameWonEvent is available) and
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp). // ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
// Ordering comes from the mode-completion chain configured in
// progress_plugin (#143).
.add_systems( .add_systems(
Update, Update,
evaluate_weekly_goals evaluate_weekly_goals.in_set(crate::progress_plugin::WeeklyGoalsEval),
.after(GameMutation)
.after(ProgressUpdate),
); );
} }
} }
+133 -9
View File
@@ -18,7 +18,7 @@
//! shake duration elapses. //! shake duration elapses.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::{GameMode, GameState};
use solitaire_core::scoring::compute_time_bonus; use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
@@ -93,6 +93,40 @@ 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.
@@ -232,20 +266,54 @@ impl Plugin for WinSummaryPlugin {
// the player's old personal-best values before `StatsPlugin` overwrites them. // the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems( .add_systems(
Update, Update,
cache_win_data.after(GameMutation).before(StatsUpdate), cache_win_data
.after(GameMutation)
.before(StatsUpdate)
// Must also read pre-award progress (old level for the
// level-up row) and emits the personal-best toast.
.before(crate::progress_plugin::ProgressUpdate)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.add_systems( .add_systems(
Update, Update,
// One deterministic chain, late on the frame: after AnimFx
// so the unlock/XP event streams it drains are settled by
// both achievement evaluators (win + replay-completion run
// before AnimFx). Members join the request/toast writer
// clubs they append to; the camera shake and the score
// reveal touch entity domains disjoint from the card/HUD
// painters (#143).
( (
collect_session_achievements, collect_session_achievements
spawn_win_summary_after_delay, .in_set(crate::game_plugin::NewGameRequestWriters)
handle_win_summary_buttons, .ambiguous_with(crate::game_plugin::NewGameRequestWriters),
spawn_win_summary_after_delay
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_win_summary_buttons
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
close_overlay_on_watch_replay, close_overlay_on_watch_replay,
handle_win_summary_keyboard, handle_win_summary_keyboard
apply_screen_shake, .in_set(crate::game_plugin::NewGameRequestWriters)
reveal_score_breakdown, .ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.before(crate::ui_focus::FocusKeys),
apply_screen_shake
.ambiguous_with(crate::feedback_anim_plugin::FeedbackFx)
// Shakes the camera; hint ghosts are their own
// entity domain (#143).
.ambiguous_with(crate::feedback_anim_plugin::HintGhostFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
reveal_score_breakdown
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
) )
.after(GameMutation), .chain()
.after(GameMutation)
.after(crate::animation_plugin::AnimFx),
); );
} }
} }
@@ -492,6 +560,7 @@ 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()));
@@ -913,6 +982,18 @@ fn spawn_overlay(
)); ));
} }
// Move-type recap (same quiet styling as the XP breakdown)
if !pending.move_detail.is_empty() {
card.spawn((
Text::new(pending.move_detail.clone()),
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Achievements unlocked this game — at most 3 shown explicitly; // 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() {
@@ -1288,6 +1369,49 @@ 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.
+4 -10
View File
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS); }, STEP_INTERVAL_MS);
}); });
/// Step the player back one move. Re-creates the ReplayPlayer and fast- /// Step the player back one move via the wasm-side seek (rewinds to the
/// forwards to (step_idx - 1) without rendering intermediate frames, then /// recorded deal and fast-forwards internally), then renders once so the
/// renders once so the CSS transition animates each card to its previous /// CSS transition animates each card to its previous position.
/// position.
function stepBack() { function stepBack() {
if (!player || player.step_idx() === 0) return; if (!player || player.step_idx() === 0) return;
if (playInterval) { if (playInterval) {
@@ -326,12 +325,7 @@ function stepBack() {
playInterval = null; playInterval = null;
btnPlay.textContent = "▶ Play"; btnPlay.textContent = "▶ Play";
} }
const target = player.step_idx() - 1; render(player.seek(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;
+91
View File
@@ -110,6 +110,9 @@ 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,
} }
@@ -144,12 +147,30 @@ impl ReplayPlayer {
// the current build maps seeds to deals. // the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode); let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self { Ok(Self {
initial: game.clone(),
game, game,
moves, 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() {
@@ -236,6 +257,25 @@ impl ReplayPlayer {
} }
} }
/// Jump directly to `step` moves applied (clamped to the move count)
/// and return the snapshot there. Backwards seeks rewind to the
/// recorded deal and fast-forward, so any position is O(replay length)
/// at worst — no JSON reparse, no intermediate renders.
///
/// Throws `"replay_desync"` if a recorded move is illegal during the
/// fast-forward (corrupt recording).
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
match self.seek_native(step) {
Ok(snap) => {
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => {
log_replay_move_error(&e);
Err(JsValue::from_str("replay_desync"))
}
}
}
/// Total number of moves the replay contains. /// 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()
@@ -1062,6 +1102,57 @@ mod tests {
assert_eq!(orig["stock"], repl["stock"], "stock deal must match"); 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 {