From 379873765d6e985f92ec97c0cc6c639d3c876419 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 6 Jul 2026 20:57:15 -0700 Subject: [PATCH] =?UTF-8?q?refactor(engine):=20ambiguity=20burn-down=20bat?= =?UTF-8?q?ch=203=20=E2=80=94=20board=20paint=20chain,=20171=20=E2=86=92?= =?UTF-8?q?=2047?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sprite/Transform cluster (112 pairs) was every board painter racing every other one. Two structural moves: - BoardVisuals set (card_plugin): all card/marker painters run as ONE deterministic chain in data-flow order — layout refinement → card authority (sync_cards_on_change) → flip anims → shadows → highlights → stock indicators → resize snapping → corner labels — with LayoutSystem::UpdateOnResize ordered before the whole set and the table plugin's marker painters chained after it. Paint order is now identical every frame instead of scheduler-dependent. - UiTextFx set (ui_theme): chrome text effects (score pulse/floater, streak flourish, modal enter, focus-ring pulse) animate Transform on UI entities only; they are declared ambiguous with BoardVisuals and with each other — the entity domains are disjoint by construction. All chain members are cheap and change-gated; sequential execution is not a measurable cost for a card game. Existing ordering constraints (fan-frac before sync, shadows after sync, snap after collect) are preserved inside the chain. Baseline ratchets 171 → 47. Remaining: long tail of small subjects (input, event writers, AutoCompleteState, HudVisibility). Refs #143 Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/card_plugin/mod.rs | 57 +++++++++++++++++-------- solitaire_engine/src/hud_plugin/mod.rs | 11 ++++- solitaire_engine/src/schedule_checks.rs | 2 +- solitaire_engine/src/table_plugin.rs | 15 +++++-- solitaire_engine/src/ui_focus.rs | 6 ++- solitaire_engine/src/ui_modal.rs | 5 ++- solitaire_engine/src/ui_theme.rs | 9 ++++ 7 files changed, 78 insertions(+), 27 deletions(-) diff --git a/solitaire_engine/src/card_plugin/mod.rs b/solitaire_engine/src/card_plugin/mod.rs index 2e793a6..743a647 100644 --- a/solitaire_engine/src/card_plugin/mod.rs +++ b/solitaire_engine/src/card_plugin/mod.rs @@ -533,6 +533,17 @@ fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool { /// Renders cards by reading `GameStateResource` on `StateChangedEvent`. pub struct CardPlugin; +/// System set for everything that paints the board: card sprites, pile +/// markers, shadows, highlights, badges. Members mutate `Sprite` / +/// `Transform` on board entities and run as a deterministic chain (see the +/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters +/// order themselves after this set. UI-domain systems that touch `Sprite`/ +/// `Transform` on non-board entities (HUD text pulses, modal cards) declare +/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are +/// disjoint by design (#143). +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub struct BoardVisuals; + impl Plugin for CardPlugin { fn build(&self, app: &mut App) { // PostStartup ensures TablePlugin's Startup system has inserted @@ -558,35 +569,45 @@ impl Plugin for CardPlugin { update_stock_empty_indicator_startup, ), ) + // Layout recompute (UpdateOnResize) always precedes board + // painting, and the painters run as ONE deterministic chain in + // data-flow order: layout refinement → card authority → anims → + // shadows → highlights → indicators → resize snapping → labels. + // Every painter mutates card/marker Sprite+Transform, so without + // the chain each pair is a scheduler ambiguity (#143). All + // members are cheap and mostly change-gated; sequential + // execution is not a cost that matters here. + .configure_sets( + Update, + LayoutSystem::UpdateOnResize.before(BoardVisuals), + ) .add_systems( Update, ( - update_tableau_fan_frac - .after(GameMutation) - .before(sync_cards_on_change), - sync_cards_on_change.after(GameMutation), - resync_cards_on_settings_change.before(sync_cards_on_change), - start_flip_anim.after(GameMutation), + update_tableau_fan_frac, + resync_cards_on_settings_change, + sync_cards_on_change, + start_flip_anim, tick_flip_anim, update_drag_shadow, - update_card_shadows_on_drag.after(sync_cards_on_change), - tick_hint_highlight.after(GameMutation), - handle_right_click.after(GameMutation), + update_card_shadows_on_drag, + handle_right_click, tick_right_click_highlights, - clear_right_click_highlights_on_state_change.after(GameMutation), + clear_right_click_highlights_on_state_change, clear_right_click_highlights_on_pause, - update_stock_empty_indicator.after(GameMutation), + tick_hint_highlight, + update_stock_empty_indicator, update_stock_count_badge - .after(GameMutation) .run_if(resource_changed::), - collect_resize_events.after(LayoutSystem::UpdateOnResize), - snap_cards_on_window_resize - .after(collect_resize_events) - .after(GameMutation), - ), + collect_resize_events, + snap_cards_on_window_resize, + resize_android_corner_labels, + ) + .chain() + .in_set(BoardVisuals) + .after(GameMutation), ); - app.add_systems(Update, resize_android_corner_labels); app.add_systems(PostUpdate, rebuild_card_entity_index); } } diff --git a/solitaire_engine/src/hud_plugin/mod.rs b/solitaire_engine/src/hud_plugin/mod.rs index 06c03a4..8c1d4cd 100644 --- a/solitaire_engine/src/hud_plugin/mod.rs +++ b/solitaire_engine/src/hud_plugin/mod.rs @@ -54,6 +54,7 @@ use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_modal::ModalScrim; use crate::ui_theme::SPACE_2; +use crate::ui_theme::UiTextFx; use crate::ui_theme::{ ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS, @@ -500,13 +501,19 @@ impl Plugin for HudPlugin { advance_score_floater, ) .chain() - .after(GameMutation), + .after(GameMutation) + .in_set(UiTextFx) + .ambiguous_with(UiTextFx) + .ambiguous_with(crate::card_plugin::BoardVisuals), ) .add_systems( Update, (start_streak_flourish, advance_streak_flourish) .chain() - .after(GameMutation), + .after(GameMutation) + .in_set(UiTextFx) + .ambiguous_with(UiTextFx) + .ambiguous_with(crate::card_plugin::BoardVisuals), ) .add_systems( Update, diff --git a/solitaire_engine/src/schedule_checks.rs b/solitaire_engine/src/schedule_checks.rs index 8680681..378d5e7 100644 --- a/solitaire_engine/src/schedule_checks.rs +++ b/solitaire_engine/src/schedule_checks.rs @@ -38,7 +38,7 @@ mod tests { /// ordering edge — add `.before`/`.after` (order matters) or /// `.ambiguous_with` (provably order-independent) at the registration /// site. When triage lowers the real count, lower this constant too. - const AMBIGUITY_BASELINE: usize = 171; + const AMBIGUITY_BASELINE: usize = 47; fn cluster_app() -> App { let mut app = App::new(); diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index d3190da..0448976 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -101,10 +101,17 @@ impl Plugin for TablePlugin { ( on_safe_area_changed.before(LayoutSystem::UpdateOnResize), on_window_resized.in_set(LayoutSystem::UpdateOnResize), - apply_theme_on_settings_change, - apply_hint_pile_highlight, - tick_hint_pile_highlights, - sync_pile_marker_visibility.after(GameMutation), + // Marker painters: deterministic chain after the card + // paint pipeline — markers and cards share Sprite/ + // Transform access (#143). + ( + apply_theme_on_settings_change, + apply_hint_pile_highlight, + tick_hint_pile_highlights, + sync_pile_marker_visibility.after(GameMutation), + ) + .chain() + .after(crate::card_plugin::BoardVisuals), ), ); } diff --git a/solitaire_engine/src/ui_focus.rs b/solitaire_engine/src/ui_focus.rs index 553fc38..9cd3fc9 100644 --- a/solitaire_engine/src/ui_focus.rs +++ b/solitaire_engine/src/ui_focus.rs @@ -149,7 +149,11 @@ impl Plugin for UiFocusPlugin { clear_hud_focus_on_unhover, handle_focus_keys, update_focus_overlay, - pulse_focus_overlay.after(crate::settings_plugin::SettingsMutation), + pulse_focus_overlay + .after(crate::settings_plugin::SettingsMutation) + .in_set(crate::ui_theme::UiTextFx) + .ambiguous_with(crate::ui_theme::UiTextFx) + .ambiguous_with(crate::card_plugin::BoardVisuals), ) .chain(), ); diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 450c173..3def57a 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -696,7 +696,10 @@ impl Plugin for UiModalPlugin { paint_modal_buttons, ) .chain() - .after(crate::settings_plugin::SettingsMutation), + .after(crate::settings_plugin::SettingsMutation) + .in_set(crate::ui_theme::UiTextFx) + .ambiguous_with(crate::ui_theme::UiTextFx) + .ambiguous_with(crate::card_plugin::BoardVisuals), ); // 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_theme.rs b/solitaire_engine/src/ui_theme.rs index 9f57784..e1f5cb7 100644 --- a/solitaire_engine/src/ui_theme.rs +++ b/solitaire_engine/src/ui_theme.rs @@ -698,3 +698,12 @@ mod tests { assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0); } } + +/// System set for text/UI visual effects that animate `Transform`/`Sprite` +/// on chrome entities (HUD score pulse, streak flourish, modal enter, focus +/// ring). These never touch board entities, so members are declared +/// `.ambiguous_with(BoardVisuals)` and `.ambiguous_with(UiTextFx)` — the +/// entity domains are disjoint by construction and relative order within a +/// frame is invisible (#143). +#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub struct UiTextFx;