Compare commits

...

9 Commits

Author SHA1 Message Date
funman300 36cbf6df58 fix(ci): copy CHANGELOG.md into the wasm docker stage — deploys broken since v0.47.0
Build and Deploy / build-and-push (push) Successful in 9m33s
whats_new_plugin embeds the changelog at compile time
(include_str!("../../CHANGELOG.md"), shipped 2026-07-13), but the
Dockerfile's wasm-builder stage copies an explicit file list that
never included CHANGELOG.md — every docker-build since (10 runs,
v0.48.x and v0.49.0 content) failed in build_wasm.sh while web-e2e
(full checkout) stayed green, so the web has been serving the
July 14 image.

Also add CHANGELOG.md to the workflow's trigger paths so changelog
cuts redeploy the web and the What's-new card stays fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:53:13 -07:00
funman300 4e18b18122 chore(ci): retrigger web deploy — run 579 failed post-#189, cause TBD (logs 404)
Build and Deploy / build-and-push (push) Failing after 7m20s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:41:27 -07:00
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
53 changed files with 2015 additions and 193 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
# Build and deploy the solitaire server Docker image.
# Build and deploy the solitaire server Docker image. (retriggered 2026-07-16)
name: Build and Deploy
on:
@@ -15,6 +15,9 @@ on:
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
# The engine embeds CHANGELOG.md (What's-new card) — changelog cuts
# must redeploy the web so the card content stays fresh.
- 'CHANGELOG.md'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml'
+23
View File
@@ -6,6 +6,29 @@ project follows [Semantic Versioning](https://semver.org/).
## [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
### Added
+26
View File
@@ -490,6 +490,32 @@ impl GameState {
self.session.stats().stats().recycle_count()
}
/// Number of cards moved onto foundations this game, read from the
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
/// not rolled back on undo.
pub fn move_to_foundation_count(&self) -> u32 {
self.session.stats().stats().move_to_foundation_count()
}
/// Number of stacks moved onto tableaus (from stock or another tableau)
/// this game, read from the upstream session stats. Cumulative — not
/// rolled back on undo.
pub fn move_to_tableau_count(&self) -> u32 {
self.session.stats().stats().move_to_tableau_count()
}
/// Number of cards taken back off a foundation this game, read from the
/// upstream session stats. Cumulative — not rolled back on undo.
pub fn move_from_foundation_count(&self) -> u32 {
self.session.stats().stats().move_from_foundation_count()
}
/// Number of face-down cards revealed (flipped up) this game, read from
/// the upstream session stats. Cumulative — not rolled back on undo.
pub fn flip_up_count(&self) -> u32 {
self.session.stats().stats().flip_up_bonus_count()
}
/// Total moves made this game (draws, recycles, and card moves), derived
/// from the session's instruction history length.
pub fn move_count(&self) -> u32 {
+6
View File
@@ -133,6 +133,12 @@ pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
pub mod difficulty_seeds;
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 use settings::{
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)]
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 storage_path: Option<PathBuf>,
}
@@ -119,9 +129,13 @@ impl Plugin for AchievementPlugin {
.add_systems(
Update,
evaluate_on_win
.in_set(AchievementEval)
.after(GameMutation)
.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
// first win to teach the Achievements panel exists. Must run
@@ -131,20 +145,50 @@ impl Plugin for AchievementPlugin {
Update,
fire_achievement_onboarding_toast
.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`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_achievements_panel)
.add_systems(
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
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
// 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.
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 {
fn build(&self, app: &mut App) {
// 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::<ActiveToast>()
.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(
Update,
(
advance_card_anims,
sync_slide_duration,
handle_win_cascade,
// Both drain SettingsChangedEvent / read anim-speed
// 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_levelup_toast,
handle_daily_goal_announcement_toast,
@@ -266,16 +298,23 @@ impl Plugin for AnimationPlugin {
handle_weekly_toast,
handle_time_attack_toast,
handle_challenge_toast,
handle_settings_toast,
handle_settings_toast.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_auto_complete_toast,
handle_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
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,
)
.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`.
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 {
fn build(&self, app: &mut App) {
// 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
// commands aren't applied until the next ApplyDeferred
// 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.
advance_animation_chains,
// Interaction visuals (run after animation for final positions).
@@ -123,10 +138,24 @@ impl Plugin for CardAnimationPlugin {
apply_hover_scale,
apply_drag_visual,
// 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()
.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,
clear_right_click_highlights_on_state_change,
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_count_badge.run_if(resource_changed::<GameStateResource>),
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::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource;
/// Minimum player level required to start a Challenge run.
@@ -38,8 +37,28 @@ impl Plugin for ChallengePlugin {
.add_message::<StartChallengeRequestEvent>()
.add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
// Ordering comes from the mode-completion chain configured in
// 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),
);
}
}
+22 -3
View File
@@ -76,12 +76,31 @@ pub struct CursorPlugin;
impl Plugin for CursorPlugin {
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(
Update,
(
update_cursor_icon,
update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays,
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_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,
WarningToastEvent, XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource;
@@ -124,17 +124,51 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
// record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover);
// Ordering comes from the mode-completion chain configured in
// progress_plugin; the toast it emits joins the writer club
// drained by the toast pipeline this frame (#143).
.add_systems(
Update,
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.
// The poll replaces DailyChallengeResource, so it heads the same
// pre-ModeStart timeline as the rollover/expiry chain above (#143).
#[cfg(not(target_arch = "wasm32"))]
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 crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
use crate::game_plugin::GameMutation;
// ---------------------------------------------------------------------------
// Resources
@@ -77,7 +76,16 @@ impl Plugin for DifficultyPlugin {
app.init_resource::<DifficultyIndexResource>()
.add_message::<StartDifficultyRequestEvent>()
.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)]
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
/// the same toggle path runs whether the player presses `Esc` or clicks.
/// 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.
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 {
fn build(&self, app: &mut App) {
// 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::<StateChangedEvent>()
.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(
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,
start_settle_anim.after(GameMutation),
start_settle_anim,
// tick_foundation_flourish writes the full Transform.scale
// (Vec3); tick_settle_anim writes only scale.y on top of
// it. Ordering ensures the settle's y-only write always
// applies last so it wins on the ~0.15 s overlap when both
// components are present on the same King entity.
tick_foundation_flourish.before(tick_settle_anim),
tick_foundation_flourish,
tick_settle_anim,
start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
),
start_deal_anim,
start_foundation_flourish,
)
.chain()
.in_set(FeedbackFx),
)
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches
@@ -240,7 +270,24 @@ impl Plugin for FeedbackAnimPlugin {
tick_hint_ghosts,
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)]
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.
#[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>);
@@ -267,7 +299,14 @@ impl Plugin for GamePlugin {
.in_set(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(
Update,
(
@@ -301,9 +340,20 @@ impl Plugin for GamePlugin {
.ambiguous_with(NewGameRequestWriters),
)
.chain()
.in_set(PreMutation),
)
.configure_sets(
Update,
PreMutation
.after(crate::settings_plugin::SettingsMutation)
.before(GameMutation),
)
.configure_sets(
Update,
ModeStart
.after(crate::stats_plugin::AbandonRecord)
.before(GameMutation),
)
.init_resource::<AutoSaveTimer>()
.add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Last, save_game_state_on_exit);
+17 -6
View File
@@ -54,12 +54,23 @@ impl Plugin for HelpPlugin {
.add_message::<TouchInput>()
.add_systems(
Update,
(
toggle_help_screen,
handle_help_close_button,
scroll_help_panel,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
),
(toggle_help_screen, handle_help_close_button)
.chain()
// FocusKeys consumes Esc after the modal handlers
// (mirrors toggle_settings_screen, #143).
.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,
toggle_home_screen,
attach_focusable_to_home_mode_cards,
handle_home_card_click,
handle_home_new_game_hero,
handle_home_card_click
.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_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_winnable_toggle,
handle_home_deal_options_toggle,
handle_home_difficulty_chip_click,
handle_home_digit_keys,
handle_home_difficulty_chip_click
.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,
(restore_hud_on_modal, apply_hud_visibility)
.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(
Update,
(
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
handle_avatar_button.ambiguous_with(HudButtons),
update_hud_avatar
// 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(
@@ -538,7 +556,11 @@ impl Plugin for HudPlugin {
// the board painters that resize card/label text (#143).
.add_systems(
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(
Update,
@@ -551,16 +573,26 @@ impl Plugin for HudPlugin {
.after(GameMutation)
.in_set(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(
Update,
(start_streak_flourish, advance_streak_flourish)
.chain()
.after(GameMutation)
// Reads the streak-milestone events StatsUpdate emits.
.after(crate::stats_plugin::StatsUpdate)
.in_set(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(
Update,
@@ -584,16 +616,28 @@ impl Plugin for HudPlugin {
handle_help_button,
handle_hint_button
.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)
.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
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click,
close_modes_popover_on_escape,
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,
close_menu_popover_on_escape,
paint_action_buttons,
@@ -602,8 +646,18 @@ impl Plugin for HudPlugin {
// Writes tab-label TextColor and reads TextFont — entity-
// disjoint from the UiTextFx readout writers (tab labels
// vs HUD readouts), which Bevy can't prove, so declare it.
animate_tab_expansion.ambiguous_with(UiTextFx),
duck_tab_bar_during_drag,
animate_tab_expansion
.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()
.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 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
/// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
@@ -143,7 +151,13 @@ impl Plugin for InputPlugin {
// `.chain()` still orders them relative to each other.
handle_keyboard_core
.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_forfeit,
handle_stock_click
@@ -159,29 +173,61 @@ impl Plugin for InputPlugin {
end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState).
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
touch_end_drag
.after(TouchDragSet::BeforeEndDrag)
.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.
// 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"))]
app.add_systems(Update, handle_fullscreen);
app.add_systems(Update, handle_fullscreen.ambiguous_with_all());
app
// Async hint pipeline: state-change drop runs before the
// 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(
Update,
(
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,
update_leaderboard_panel,
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,
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_display_name_text_input,
handle_display_name_confirm,
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),
);
}
}
+20 -6
View File
@@ -98,12 +98,26 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>()
.add_systems(Update, spawn_if_first_run)
.add_systems(
Update,
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
);
app.init_resource::<OnboardingSlideIndex>().add_systems(
Update,
// 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.
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 {
fn build(&self, app: &mut App) {
// add_message is idempotent — other plugins may register these
@@ -116,28 +125,55 @@ impl Plugin for PausePlugin {
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.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(
Update,
(
// toggle_pause must see SelectionState *before* handle_selection_keys
// clears it, so it can skip Escape when a card is selected.
// It must also run *before* handle_forfeit_keyboard so the
// ForfeitConfirmScreen is still alive when toggle_pause's
// early-return guard checks for it — otherwise an Esc that
// closes the forfeit modal would also open pause in the
// same frame.
// toggle_pause must run *before* handle_forfeit_keyboard
// so the ForfeitConfirmScreen is still alive when
// toggle_pause's early-return guard checks for it —
// otherwise an Esc that closes the forfeit modal would
// also open pause in the same frame. The chain preserves
// that edge and makes the whole pause/forfeit flow
// 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
.before(SelectionKeySet)
.before(handle_forfeit_keyboard),
handle_pause_draw_buttons,
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
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_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
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
/// is ever in flight: a fresh H press while a previous task is
/// 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)]
pub struct PendingHintTask {
/// `Some` while the solver is still working on a verdict.
+21 -4
View File
@@ -115,10 +115,27 @@ impl Plugin for PlayBySeedPlugin {
Update,
(
handle_open_dialog,
handle_text_input,
tick_debounce_and_spawn_solver_task,
poll_solver_task,
handle_confirm,
// Writes the marker-filtered seed-dialog Text nodes —
// disjoint from the other text painters (UiTextFx
// club, #143).
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,
)
.chain()
+14 -1
View File
@@ -60,6 +60,14 @@ pub struct DailyCalendarDot {
/// [`spawn_profile_body`] for this tab's content.
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.
///
/// The Profile panel renders sync info, progression (incl. 14-day
@@ -82,7 +90,12 @@ impl Plugin for ProfilePlugin {
.add_message::<MouseWheel>()
// Open/close/tab handling moved to `you_hub_plugin`
// (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)]
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`,
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
///
@@ -74,7 +89,27 @@ impl Plugin for ProgressPlugin {
.add_message::<GameWonEvent>()
.add_systems(
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
// never touches it. We do not `init_resource` here so the
// 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(
Update,
(
radial_open_on_right_click,
radial_open_on_long_press,
radial_track_cursor,
radial_handle_release_or_cancel,
radial_redraw_overlay,
radial_handle_release_or_cancel
.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,
(
react_to_state_change,
update_banner_label,
update_progress_text,
update_floating_progress_chip,
update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
// Marker-filtered Text/Node repaints — each owns one
// disjoint overlay marker, so they join the UiTextFx
// club alongside the HUD text painters (#143).
update_banner_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_progress_text
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_floating_progress_chip
.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_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_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,
)
.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),
);
}
}
+44 -8
View File
@@ -550,6 +550,14 @@ fn fire_state_changed_on_completion(
*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
/// playback ticks, completion linger, and the recording-pause guard.
///
@@ -562,18 +570,46 @@ impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>()
.add_message::<StateChangedEvent>()
.add_systems(
.configure_sets(
Update,
(
tick_replay_playback,
auto_clear_completed_replay,
fire_state_changed_on_completion,
)
.chain(),
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(
Update,
record_replay_skip_during_playback.after(GameMutation),
(
// 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,
// Chained before GameMutation: explicit order against the
// mutation systems' StateChangedEvent writes; readers
// drain the whole queue after GameMutation.
fire_state_changed_on_completion,
)
.chain()
.in_set(ReplayPlayback),
)
.add_systems(
Update,
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)]
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.
///
/// Incremented on each H press so repeated presses reveal different moves.
+22 -4
View File
@@ -74,10 +74,28 @@ impl Plugin for SafeAreaInsetsPlugin {
.add_systems(
Update,
(
apply_safe_area_anchors,
apply_safe_area_bottom_anchors,
apply_safe_area_to_modal_scrims,
on_app_resumed,
// 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_bottom_anchors,
apply_safe_area_to_modal_scrims,
)
.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::prelude::*;
use crate::achievement_plugin::AchievementPlugin;
use crate::animation_plugin::AnimationPlugin;
use crate::auto_complete_plugin::AutoCompletePlugin;
use crate::card_animation::CardAnimationPlugin;
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::help_plugin::HelpPlugin;
use crate::home_plugin::HomePlugin;
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::splash_plugin::SplashPlugin;
use crate::stats_plugin::StatsPlugin;
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_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
/// 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(UiModalPlugin)
.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
}
+36 -7
View File
@@ -134,6 +134,14 @@ impl KeyboardDragState {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
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
/// highlight.
///
@@ -158,14 +166,35 @@ impl Plugin for SelectionPlugin {
(
handle_selection_keys
.in_set(SelectionKeySet)
.before(GameMutation),
clear_selection_on_state_change.after(GameMutation),
update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<GameStateResource>),
),
.before(GameMutation)
// Enter-to-move feeds the shared move request
// 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>
.or(resource_changed::<KeyboardDragState>)
.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 => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::ExportData | SettingsButton::ImportData => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => {
screen.0 = false;
}
@@ -460,14 +463,18 @@ pub(super) fn handle_tab_buttons(
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit.
/// Delete Account, and the Phase M Export/Import data pair. Split from
/// `handle_settings_buttons` to stay within Bevy's 16-parameter system
/// limit.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_sync_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
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<
crate::events::ThemeStoreOpenRequestEvent,
>,
@@ -500,6 +507,12 @@ pub(super) fn handle_sync_buttons(
SettingsButton::DeleteAccount => {
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::theme::ThemeThumbnailPair;
mod data_transfer;
mod input;
mod ui;
mod updates;
@@ -87,6 +88,19 @@ pub struct SettingsChangedEvent(pub Settings);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
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.
#[derive(Component, Debug)]
pub struct SettingsPanel;
@@ -111,6 +125,12 @@ struct ThemeText;
#[derive(Component, Debug)]
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.
#[derive(Component, Debug)]
struct CardBackText;
@@ -321,6 +341,10 @@ enum SettingsButton {
DisconnectSync,
/// Open the account-deletion confirmation modal.
DeleteAccount,
/// Write every JSON save into one backup bundle (Phase M).
ExportData,
/// Restore every JSON save from the backup bundle (Phase M).
ImportData,
Done,
/// Select a specific card-back by index from the picker row.
SelectCardBack(usize),
@@ -386,6 +410,8 @@ impl SettingsButton {
SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93,
SettingsButton::ExportData => 94,
SettingsButton::ImportData => 95,
// Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness.
@@ -439,6 +465,15 @@ impl Plugin for SettingsPlugin {
.add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>()
.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::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
@@ -459,7 +494,12 @@ impl Plugin for SettingsPlugin {
Update,
SettingsMutation
.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(
Update,
@@ -482,8 +522,12 @@ impl Plugin for SettingsPlugin {
toggle_settings_screen
.before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
scroll_settings_panel
.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(),
);
@@ -793,3 +793,93 @@ fn ui_scale_out_of_range_sanitizes_on_load() {
.sanitized();
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,
font_res: Option<&FontResource>,
theme_overrides_back: bool,
conflict_log: Option<&crate::resources::SyncConflictLog>,
active_tab: SettingsTab,
) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
@@ -100,7 +101,9 @@ pub(super) fn spawn_settings_panel(
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
@@ -353,6 +356,7 @@ fn spawn_account_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
sync_status: &str,
conflict_log: Option<&crate::resources::SyncConflictLog>,
font_res: Option<&FontResource>,
) {
// --- Privacy (only shown when a Matomo URL is configured) ---
@@ -372,6 +376,144 @@ fn spawn_account_tab(
// --- Sync ---
section_label(body, "Sync", 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.
@@ -32,6 +32,7 @@ pub(super) fn sync_settings_panel_visibility(
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
) {
if !screen.is_changed() {
return;
@@ -47,6 +48,7 @@ pub(super) fn sync_settings_panel_visibility(
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
conflict_log.as_deref(),
scroll_pos.0,
active_tab.0,
);
@@ -76,6 +78,7 @@ fn build_panel(
theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>,
conflict_log: Option<&crate::resources::SyncConflictLog>,
scroll_offset: f32,
active_tab: SettingsTab,
) {
@@ -125,6 +128,7 @@ fn build_panel(
scroll_offset,
font_res,
theme_overrides_back,
conflict_log,
active_tab,
);
}
@@ -145,6 +149,7 @@ pub(super) fn rebuild_panel_on_tab_change(
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
) {
if !active_tab.is_changed() || active_tab.is_added() {
return;
@@ -167,6 +172,7 @@ pub(super) fn rebuild_panel_on_tab_change(
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
conflict_log.as_deref(),
0.0,
active_tab.0,
);
+12 -1
View File
@@ -99,7 +99,18 @@ impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems(
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)]
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.
#[derive(Component, Debug)]
pub struct StatsScreen;
@@ -205,10 +213,34 @@ impl Plugin for StatsPlugin {
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
// and a system cannot be both inside a set and individually before a
// set-level ordering constraint.
.add_systems(Update, update_stats_on_new_game.before(GameMutation))
.add_systems(
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(
Update,
@@ -216,29 +248,69 @@ impl Plugin for StatsPlugin {
// the NewGameRequestEvent it emits is not visible to
// update_stats_on_new_game in the same frame — otherwise
// 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
.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`
// (Phase E) — this plugin now owns body content, replay
// selector behavior, and scroll.
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
.add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button)
//
// Stats overlay UI: one deterministic chain — every member
// 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(
Update,
(
refresh_replay_history_on_win,
handle_replay_selector_buttons,
repaint_replay_selector_caption,
repaint_replay_selector_detail,
// Marker-filtered Text repaints — disjoint from the
// 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(
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::events::{
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
WarningToastEvent,
};
use crate::game_plugin::RecordingReplay;
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::{
LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath,
};
@@ -106,9 +110,13 @@ impl Plugin for SyncPlugin {
.init_resource::<PullTaskResult>()
.init_resource::<PullTask>()
.init_resource::<PendingReplayUpload>()
.init_resource::<SyncConflictLog>()
.add_message::<ManualSyncRequestEvent>()
.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
// refuses to create threads (resource-limited environments, sandboxed
@@ -198,6 +206,8 @@ fn poll_pull_result(
progress_path: Res<ProgressStoragePath>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut conflict_log: ResMut<SyncConflictLog>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else {
return;
@@ -210,7 +220,21 @@ fn poll_pull_result(
match result {
Ok(remote) => {
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.
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/
// 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,
tick_hint_pile_highlights,
sync_pile_marker_visibility.after(GameMutation),
+32 -4
View File
@@ -109,13 +109,41 @@ impl Plugin for TimeAttackPlugin {
.add_message::<NewGameRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.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(
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);
}
}
@@ -100,7 +100,11 @@ impl Plugin for TouchSelectionPlugin {
update_touch_selection_highlight,
)
.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
// ---------------------------------------------------------------------------
/// 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
/// 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)
.ambiguous_with(crate::ui_theme::UiTextFx)
.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
// 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) {
app.init_resource::<TooltipState>()
.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::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource;
/// 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)
// Run after GameMutation (so GameWonEvent is available) and
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
// Ordering comes from the mode-completion chain configured in
// progress_plugin (#143).
.add_systems(
Update,
evaluate_weekly_goals
.after(GameMutation)
.after(ProgressUpdate),
evaluate_weekly_goals.in_set(crate::progress_plugin::WeeklyGoalsEval),
);
}
}
+133 -9
View File
@@ -18,7 +18,7 @@
//! shake duration elapses.
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use solitaire_core::game_state::{GameMode, GameState};
use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed;
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
/// score-breakdown reveal can format the mode-multiplier row
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
pub mode: GameMode,
/// Per-move-type recap of the winning game, e.g.
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
/// Built from the upstream session counters at win time; empty when
/// every counter is zero (synthesised test wins).
pub move_detail: String,
}
/// Formats the per-move-type recap line for the win modal from the
/// upstream session counters. Zero-valued components are omitted so the
/// line stays short; returns an empty string when nothing was counted
/// (only possible for synthesised test wins).
fn build_move_detail(game: &GameState) -> String {
let mut parts: Vec<String> = Vec::new();
let foundation = game.move_to_foundation_count();
if foundation > 0 {
parts.push(format!("{foundation} to foundation"));
}
let tableau = game.move_to_tableau_count();
if tableau > 0 {
parts.push(format!("{tableau} tableau moves"));
}
let flips = game.flip_up_count();
if flips > 0 {
parts.push(format!("{flips} flips"));
}
let recycles = game.recycle_count();
if recycles > 0 {
parts.push(format!("{recycles} recycles"));
}
let returns = game.move_from_foundation_count();
if returns > 0 {
parts.push(format!("{returns} foundation returns"));
}
parts.join(" \u{00B7} ")
}
/// Builds a human-readable XP breakdown string for the win modal.
@@ -232,20 +266,54 @@ impl Plugin for WinSummaryPlugin {
// the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems(
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(
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,
spawn_win_summary_after_delay,
handle_win_summary_buttons,
collect_session_achievements
.in_set(crate::game_plugin::NewGameRequestWriters)
.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,
handle_win_summary_keyboard,
apply_screen_shake,
reveal_score_breakdown,
handle_win_summary_keyboard
.in_set(crate::game_plugin::NewGameRequestWriters)
.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.undo_count = game.0.undo_count();
pending.mode = game.0.mode;
pending.move_detail = build_move_detail(&game.0);
if is_new_record {
toast.write(InfoToastEvent("New Record!".to_string()));
@@ -913,6 +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;
// excess is summarised with "...and N more".
if !session.names.is_empty() {
@@ -1288,6 +1369,49 @@ mod tests {
assert_eq!(p.mode, GameMode::Classic);
}
#[test]
fn build_move_detail_fresh_game_is_empty() {
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
assert!(
build_move_detail(&game).is_empty(),
"no moves yet, so the recap line must be empty"
);
}
#[test]
fn build_move_detail_reports_played_move_types() {
use solitaire_core::KlondikeInstruction;
// Drive a real deal forward so the upstream counters accumulate:
// prefer foundation moves, then anything else, drawing as needed.
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
for _ in 0..80 {
let instructions = game.possible_instructions();
let next = instructions
.iter()
.copied()
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
.or_else(|| instructions.into_iter().next());
match next {
Some(i) => {
let _ = game.apply_instruction(i);
}
None => break,
}
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
break;
}
}
let detail = build_move_detail(&game);
assert!(
detail.contains("to foundation"),
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
);
assert!(
!detail.contains("0 "),
"zero-valued components must be omitted; got: {detail}"
);
}
#[test]
fn build_xp_detail_slow_win_with_undo() {
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
+4
View File
@@ -53,6 +53,10 @@ RUN cargo fetch --locked
# src/) because solitaire_engine embeds theme/audio/font assets at compile
# time from its own assets/ and the workspace assets/.
COPY build_wasm.sh ./
# whats_new_plugin embeds the changelog at compile time
# (include_str!("../../CHANGELOG.md")) — omit it and the engine build
# fails inside this stage while full-checkout builds stay green.
COPY CHANGELOG.md ./
COPY solitaire_core ./solitaire_core
COPY solitaire_sync ./solitaire_sync
COPY solitaire_data ./solitaire_data
+4 -10
View File
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS);
});
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
/// forwards to (step_idx - 1) without rendering intermediate frames, then
/// renders once so the CSS transition animates each card to its previous
/// position.
/// Step the player back one move via the wasm-side seek (rewinds to the
/// recorded deal and fast-forwards internally), then renders once so the
/// CSS transition animates each card to its previous position.
function stepBack() {
if (!player || player.step_idx() === 0) return;
if (playInterval) {
@@ -326,12 +325,7 @@ function stepBack() {
playInterval = null;
btnPlay.textContent = "▶ Play";
}
const target = player.step_idx() - 1;
player = new ReplayPlayer(replayJson);
for (let i = 0; i < target; i++) {
player.step();
}
render(player.state());
render(player.seek(player.step_idx() - 1));
btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false;
+91
View File
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
#[wasm_bindgen]
pub struct ReplayPlayer {
game: GameState,
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
/// can rewind without reparsing the replay JSON.
initial: GameState,
moves: Vec<KlondikeInstruction>,
step_idx: usize,
}
@@ -144,12 +147,30 @@ impl ReplayPlayer {
// the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self {
initial: game.clone(),
game,
moves,
step_idx: 0,
})
}
/// Jump to `step` (clamped to the move count): the board state after
/// `step` moves have been applied. Rewinds by resetting to the stored
/// initial deal, then fast-forwards — a few hundred instruction
/// applications, microseconds in practice.
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
let target = step.min(self.moves.len());
if target < self.step_idx {
self.game = self.initial.clone();
self.step_idx = 0;
}
while self.step_idx < target {
self.game.apply_instruction(self.moves[self.step_idx])?;
self.step_idx += 1;
}
Ok(self.snapshot())
}
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
if self.step_idx >= self.moves.len() {
@@ -236,6 +257,25 @@ impl ReplayPlayer {
}
}
/// Jump directly to `step` moves applied (clamped to the move count)
/// and return the snapshot there. Backwards seeks rewind to the
/// recorded deal and fast-forward, so any position is O(replay length)
/// at worst — no JSON reparse, no intermediate renders.
///
/// Throws `"replay_desync"` if a recorded move is illegal during the
/// fast-forward (corrupt recording).
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
match self.seek_native(step) {
Ok(snap) => {
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => {
log_replay_move_error(&e);
Err(JsValue::from_str("replay_desync"))
}
}
}
/// Total number of moves the replay contains.
pub fn total_steps(&self) -> usize {
self.moves.len()
@@ -1062,6 +1102,57 @@ mod tests {
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
}
/// `seek` must land on exactly the state produced by stepping — both
/// forwards (fast-forward from the current position) and backwards
/// (rewind to the recorded deal, then fast-forward).
#[test]
fn seek_matches_stepping_in_both_directions() {
let mut game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
for _ in 0..24 {
let legal_moves = game.legal_moves_native();
if legal_moves.is_empty() {
break;
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
game.apply_legal_move_native(idx).expect("advance game");
}
let replay_json = game
.replay_export_native(60, "2026-07-10")
.expect("export replay");
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
let total = stepped.total_steps();
assert!(total >= 4, "test needs a few moves, got {total}");
// Forward: step A to k, seek B to k, compare snapshots.
let k = total / 2;
for _ in 0..k {
stepped.step_native().expect("step").expect("mid-replay");
}
let sought = seeker.seek_native(k).expect("seek forward");
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
// Backward: seek B to k - 2 and compare against a fresh stepper.
let back = k - 2;
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
for _ in 0..back {
fresh.step_native().expect("step").expect("mid-replay");
}
let sought_back = seeker.seek_native(back).expect("seek backward");
assert_eq!(
sought_back,
fresh.snapshot(),
"backward seek diverged at {back}"
);
// Clamping: past-the-end seeks stop at the final state.
let end = seeker.seek_native(usize::MAX).expect("seek to end");
assert_eq!(end.step_idx, total);
}
#[test]
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {