From 838342649c69c773d9ae3f76f7b31f47ab159c4c Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 16 Jul 2026 17:42:46 -0700 Subject: [PATCH] =?UTF-8?q?refactor(engine):=20ambiguity=20gate=20covers?= =?UTF-8?q?=20the=20full=20cluster=20=E2=80=94=20batches=201-3,=20585=20pa?= =?UTF-8?q?irs=20burned=20to=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_engine/src/achievement_plugin.rs | 54 ++++++++++- solitaire_engine/src/animation_plugin.rs | 49 +++++++++- solitaire_engine/src/card_animation/mod.rs | 35 ++++++- solitaire_engine/src/card_plugin/mod.rs | 4 +- solitaire_engine/src/challenge_plugin.rs | 27 +++++- solitaire_engine/src/cursor_plugin.rs | 25 ++++- .../src/daily_challenge_plugin.rs | 52 ++++++++-- solitaire_engine/src/difficulty_plugin.rs | 12 ++- solitaire_engine/src/feedback_anim_plugin.rs | 61 ++++++++++-- solitaire_engine/src/game_plugin/mod.rs | 52 +++++++++- solitaire_engine/src/help_plugin.rs | 23 +++-- solitaire_engine/src/home_plugin.rs | 40 ++++++-- solitaire_engine/src/hud_plugin/mod.rs | 74 +++++++++++++-- solitaire_engine/src/input_plugin/mod.rs | 62 ++++++++++-- solitaire_engine/src/leaderboard_plugin.rs | 30 +++++- solitaire_engine/src/onboarding_plugin.rs | 26 +++-- solitaire_engine/src/pause_plugin.rs | 62 +++++++++--- solitaire_engine/src/pending_hint.rs | 7 ++ solitaire_engine/src/play_by_seed_plugin.rs | 25 ++++- solitaire_engine/src/profile_plugin.rs | 15 ++- solitaire_engine/src/progress_plugin.rs | 37 +++++++- solitaire_engine/src/radial_menu.rs | 21 ++++- solitaire_engine/src/replay_overlay/mod.rs | 74 ++++++++++++--- solitaire_engine/src/replay_playback.rs | 52 ++++++++-- solitaire_engine/src/safe_area.rs | 26 ++++- solitaire_engine/src/schedule_checks.rs | 64 ++++++++++++- solitaire_engine/src/selection_plugin.rs | 43 +++++++-- solitaire_engine/src/settings_plugin/mod.rs | 28 +++++- solitaire_engine/src/splash_plugin.rs | 13 ++- solitaire_engine/src/stats_plugin.rs | 94 ++++++++++++++++--- solitaire_engine/src/table_plugin.rs | 7 +- solitaire_engine/src/time_attack_plugin.rs | 36 ++++++- .../src/touch_selection_plugin.rs | 6 +- solitaire_engine/src/ui_modal.rs | 15 ++- solitaire_engine/src/ui_tooltip.rs | 18 +++- solitaire_engine/src/weekly_goals_plugin.rs | 10 +- solitaire_engine/src/win_summary_plugin.rs | 50 ++++++++-- 37 files changed, 1155 insertions(+), 174 deletions(-) diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 9743e02..1589394 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -67,6 +67,16 @@ pub struct AchievementsResource(pub Vec); #[derive(Resource, Debug, Clone)] pub struct AchievementsStoragePath(pub Option); +/// 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, } @@ -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::, + scroll_achievements_panel + .in_set(crate::ui_modal::PanelScroll) + .ambiguous_with(crate::ui_modal::PanelScroll), + ) + .add_systems( + Update, + crate::ui_modal::touch_scroll_panel:: + .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>` 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), + ); } } diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index b1d5bef..d2e0567 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -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::() .init_resource::() .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), ); } } diff --git a/solitaire_engine/src/card_animation/mod.rs b/solitaire_engine/src/card_animation/mod.rs index 6b9db8d..04a217b 100644 --- a/solitaire_engine/src/card_animation/mod.rs +++ b/solitaire_engine/src/card_animation/mod.rs @@ -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), ); } } diff --git a/solitaire_engine/src/card_plugin/mod.rs b/solitaire_engine/src/card_plugin/mod.rs index a13ab75..b9b3aca 100644 --- a/solitaire_engine/src/card_plugin/mod.rs +++ b/solitaire_engine/src/card_plugin/mod.rs @@ -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::), collect_resize_events, diff --git a/solitaire_engine/src/challenge_plugin.rs b/solitaire_engine/src/challenge_plugin.rs index e7bfce9..cdd22fc 100644 --- a/solitaire_engine/src/challenge_plugin.rs +++ b/solitaire_engine/src/challenge_plugin.rs @@ -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::() .add_message::() // 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), + ); } } diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index fc6c42b..3f43dcb 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -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::), - 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::), + 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), ), ); } diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index d3ee1a2..8236208 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -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::() .add_message::() .add_message::() - // 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)); } } diff --git a/solitaire_engine/src/difficulty_plugin.rs b/solitaire_engine/src/difficulty_plugin.rs index 33ae211..1e2335d 100644 --- a/solitaire_engine/src/difficulty_plugin.rs +++ b/solitaire_engine/src/difficulty_plugin.rs @@ -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::() .add_message::() .add_message::() - .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), + ); } } diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index fa6cda4..7aa686a 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -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` 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::() .add_message::() .add_message::() + .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 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), ); } } diff --git a/solitaire_engine/src/game_plugin/mod.rs b/solitaire_engine/src/game_plugin/mod.rs index 9a46862..d0c446e 100644 --- a/solitaire_engine/src/game_plugin/mod.rs +++ b/solitaire_engine/src/game_plugin/mod.rs @@ -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); @@ -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::() .add_systems(Update, auto_save_game_state.after(GameMutation)) .add_systems(Last, save_game_state_on_exit); diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 76199ab..99f3bcd 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -54,12 +54,23 @@ impl Plugin for HelpPlugin { .add_message::() .add_systems( Update, - ( - toggle_help_screen, - handle_help_close_button, - scroll_help_panel, - crate::ui_modal::touch_scroll_panel::, - ), + (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:: + .in_set(crate::ui_modal::PanelScroll) + .ambiguous_with(crate::ui_modal::PanelScroll), ); } } diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 591eb37..52975da 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -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), + ); } } diff --git a/solitaire_engine/src/hud_plugin/mod.rs b/solitaire_engine/src/hud_plugin/mod.rs index 7f918fe..fd5b87c 100644 --- a/solitaire_engine/src/hud_plugin/mod.rs +++ b/solitaire_engine/src/hud_plugin/mod.rs @@ -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) diff --git a/solitaire_engine/src/input_plugin/mod.rs b/solitaire_engine/src/input_plugin/mod.rs index 0589ee2..3400f2b 100644 --- a/solitaire_engine/src/input_plugin/mod.rs +++ b/solitaire_engine/src/input_plugin/mod.rs @@ -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), ); } } diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index edd3c84..afbdfce 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -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), + ); } } diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index c168d82..78b3623 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -98,12 +98,26 @@ pub struct OnboardingPlugin; impl Plugin for OnboardingPlugin { fn build(&self, app: &mut App) { - app.init_resource::() - .add_systems(Update, spawn_if_first_run) - .add_systems( - Update, - (handle_onboarding_buttons, handle_onboarding_keyboard).chain(), - ); + app.init_resource::().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), + ); } } diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index 818d35f..220d569 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -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::() .add_message::() .init_resource::() + .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), ); } } diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index a046181..4052e81 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -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. diff --git a/solitaire_engine/src/play_by_seed_plugin.rs b/solitaire_engine/src/play_by_seed_plugin.rs index 2dff997..f9995c9 100644 --- a/solitaire_engine/src/play_by_seed_plugin.rs +++ b/solitaire_engine/src/play_by_seed_plugin.rs @@ -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() diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 8eb0148..10c5892 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -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::() // 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), + ); } } diff --git a/solitaire_engine/src/progress_plugin.rs b/solitaire_engine/src/progress_plugin.rs index 145ac51..a6a48aa 100644 --- a/solitaire_engine/src/progress_plugin.rs +++ b/solitaire_engine/src/progress_plugin.rs @@ -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::() .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), ); } } diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 4061b05..cc3d073 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -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), ); } } diff --git a/solitaire_engine/src/replay_overlay/mod.rs b/solitaire_engine/src/replay_overlay/mod.rs index ab3b475..4426c7b 100644 --- a/solitaire_engine/src/replay_overlay/mod.rs +++ b/solitaire_engine/src/replay_overlay/mod.rs @@ -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), ); } } diff --git a/solitaire_engine/src/replay_playback.rs b/solitaire_engine/src/replay_playback.rs index a4e60ce..173bf31 100644 --- a/solitaire_engine/src/replay_playback.rs +++ b/solitaire_engine/src/replay_playback.rs @@ -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::() .add_message::() - .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), ); } } diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index 17f67b5..56414bc 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -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(), ), ); diff --git a/solitaire_engine/src/schedule_checks.rs b/solitaire_engine/src/schedule_checks.rs index aaef308..e3f5ef9 100644 --- a/solitaire_engine/src/schedule_checks.rs +++ b/solitaire_engine/src/schedule_checks.rs @@ -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::>(); app } diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index ba6c9d1..91981c7 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -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:: - .or(resource_changed::) - .or(resource_changed::), - ), + .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:: + .or(resource_changed::) + .or(resource_changed::), + ), + ) + .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), ); } } diff --git a/solitaire_engine/src/settings_plugin/mod.rs b/solitaire_engine/src/settings_plugin/mod.rs index ea580f9..bc69ad6 100644 --- a/solitaire_engine/src/settings_plugin/mod.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -87,6 +87,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; @@ -459,7 +472,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 +500,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::, + scroll_settings_panel + .in_set(crate::ui_modal::PanelScroll) + .ambiguous_with(crate::ui_modal::PanelScroll), + crate::ui_modal::touch_scroll_panel:: + .in_set(crate::ui_modal::PanelScroll) + .ambiguous_with(crate::ui_modal::PanelScroll), ) .chain(), ); diff --git a/solitaire_engine/src/splash_plugin.rs b/solitaire_engine/src/splash_plugin.rs index d290102..df05863 100644 --- a/solitaire_engine/src/splash_plugin.rs +++ b/solitaire_engine/src/splash_plugin.rs @@ -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), ); } } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 4cf6f0c..d9b3805 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -46,6 +46,14 @@ pub struct StatsStoragePath(pub Option); #[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::, + scroll_stats_panel + .in_set(crate::ui_modal::PanelScroll) + .ambiguous_with(crate::ui_modal::PanelScroll), + ) + .add_systems( + Update, + crate::ui_modal::touch_scroll_panel:: + .in_set(crate::ui_modal::PanelScroll) + .ambiguous_with(crate::ui_modal::PanelScroll), ); } } diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index 5d98fba..22cf57b 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -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), diff --git a/solitaire_engine/src/time_attack_plugin.rs b/solitaire_engine/src/time_attack_plugin.rs index 3738374..97d4425 100644 --- a/solitaire_engine/src/time_attack_plugin.rs +++ b/solitaire_engine/src/time_attack_plugin.rs @@ -109,13 +109,41 @@ impl Plugin for TimeAttackPlugin { .add_message::() .add_message::() .add_message::() + // 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); } } diff --git a/solitaire_engine/src/touch_selection_plugin.rs b/solitaire_engine/src/touch_selection_plugin.rs index afda37c..97067d2 100644 --- a/solitaire_engine/src/touch_selection_plugin.rs +++ b/solitaire_engine/src/touch_selection_plugin.rs @@ -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), ); } } diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index adc8287..70091ee 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -546,6 +546,15 @@ pub fn spawn_tab_chip( // 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 diff --git a/solitaire_engine/src/ui_tooltip.rs b/solitaire_engine/src/ui_tooltip.rs index e7e4f62..fda0af1 100644 --- a/solitaire_engine/src/ui_tooltip.rs +++ b/solitaire_engine/src/ui_tooltip.rs @@ -93,7 +93,23 @@ impl Plugin for UiTooltipPlugin { fn build(&self, app: &mut App) { app.init_resource::() .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), + ); } } diff --git a/solitaire_engine/src/weekly_goals_plugin.rs b/solitaire_engine/src/weekly_goals_plugin.rs index b40d93f..f951a58 100644 --- a/solitaire_engine/src/weekly_goals_plugin.rs +++ b/solitaire_engine/src/weekly_goals_plugin.rs @@ -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), ); } } diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index e8041a8..4e5388a 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -232,20 +232,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), ); } } -- 2.47.3