Compare commits
11 Commits
19647b5209
...
ac002d8255
| Author | SHA1 | Date | |
|---|---|---|---|
| ac002d8255 | |||
| 0fc1fa139e | |||
| 4f0c5bb808 | |||
| a6b22df666 | |||
| b402c01918 | |||
| be478acde7 | |||
| 379873765d | |||
| 713a292057 | |||
| 58c2dfd0a9 | |||
| 710555bd7e | |||
| 42a5f3bc3b |
@@ -6,6 +6,47 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.42.0] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CI workspace gate.** New `test.yml` workflow runs clippy (deny warnings)
|
||||||
|
and the full test suite on every master push and PR — previously no CI ran
|
||||||
|
tests at all. Caught its own first bug (missing Bevy native deps) on its
|
||||||
|
own PR. (#135)
|
||||||
|
- **Schedule ambiguity gate.** A headless test builds the gameplay plugin
|
||||||
|
cluster with Bevy ambiguity detection promoted to error. The initial
|
||||||
|
measurement found 302 system pairs with conflicting data access and no
|
||||||
|
ordering; four burn-down batches (PRs #146–#149) took it to ZERO the same
|
||||||
|
day, and the gate now enforces 0. Keyboard consumption, board painting,
|
||||||
|
and HUD updates all have deterministic order for the first time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Browser canvas 36% smaller.** `canvas_bg.wasm` shrank 36.2 MB → 23.2 MB
|
||||||
|
via a size-focused `wasm-release` profile (fat LTO, single codegen unit,
|
||||||
|
opt-level "s"); verified visually identical in production. (#134)
|
||||||
|
- **Quaternions API adoption.** Canonical `FOUNDATIONS`/`TABLEAUS` consts in
|
||||||
|
`solitaire_core` replace five scattered enum lists; upstream
|
||||||
|
`Suit::SUITS`/`Rank::RANKS` replace nine hand-rolled arrays, with the
|
||||||
|
texture-atlas indexing re-keyed through tested canonical helpers. Net
|
||||||
|
−177 lines. (#137)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sync push race.** The server's load→merge→store cycle now runs in one
|
||||||
|
transaction; concurrent pushes from two devices can no longer overwrite
|
||||||
|
each other's merge. (#136)
|
||||||
|
- **Refresh-token rotation is single-use under concurrency** — rotation
|
||||||
|
gates on the DELETE's row count, so a stolen-then-replayed refresh token
|
||||||
|
loses the race and gets 401. (#136)
|
||||||
|
- **Exit sync push actually completes.** Was a detached task killed by
|
||||||
|
process teardown; now a bounded 2-second blocking wait on the app's final
|
||||||
|
frame. (#138)
|
||||||
|
- **Server auth hardening.** Login timing no longer reveals whether a
|
||||||
|
username exists; concurrent duplicate registration returns 409 instead of
|
||||||
|
500; avatar uploads are magic-byte checked. (#144, issues #139–#141)
|
||||||
|
|
||||||
## [0.41.1] — 2026-07-06
|
## [0.41.1] — 2026-07-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub struct AutoCompleteState {
|
|||||||
/// Plugin that drives the auto-complete sequence.
|
/// Plugin that drives the auto-complete sequence.
|
||||||
pub struct AutoCompletePlugin;
|
pub struct AutoCompletePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the auto-complete detect/drive chain; HUD readers of
|
||||||
|
/// [`AutoCompleteState`] order themselves after it (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AutoComplete;
|
||||||
|
|
||||||
impl Plugin for AutoCompletePlugin {
|
impl Plugin for AutoCompletePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>()
|
||||||
@@ -58,7 +63,9 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
drive_auto_complete,
|
drive_auto_complete,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.in_set(AutoComplete)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,6 +533,17 @@ fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
|
|||||||
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||||
pub struct CardPlugin;
|
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 {
|
impl Plugin for CardPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||||
@@ -558,33 +569,45 @@ impl Plugin for CardPlugin {
|
|||||||
update_stock_empty_indicator_startup,
|
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(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_tableau_fan_frac
|
update_tableau_fan_frac,
|
||||||
.after(GameMutation)
|
resync_cards_on_settings_change,
|
||||||
.before(sync_cards_on_change),
|
sync_cards_on_change,
|
||||||
sync_cards_on_change.after(GameMutation),
|
start_flip_anim,
|
||||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
|
||||||
start_flip_anim.after(GameMutation),
|
|
||||||
tick_flip_anim,
|
tick_flip_anim,
|
||||||
update_drag_shadow,
|
update_drag_shadow,
|
||||||
update_card_shadows_on_drag.after(sync_cards_on_change),
|
update_card_shadows_on_drag,
|
||||||
tick_hint_highlight,
|
|
||||||
handle_right_click,
|
handle_right_click,
|
||||||
tick_right_click_highlights,
|
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,
|
clear_right_click_highlights_on_pause,
|
||||||
update_stock_empty_indicator.after(GameMutation),
|
tick_hint_highlight,
|
||||||
|
update_stock_empty_indicator,
|
||||||
update_stock_count_badge
|
update_stock_count_badge
|
||||||
.after(GameMutation)
|
|
||||||
.run_if(resource_changed::<GameStateResource>),
|
.run_if(resource_changed::<GameStateResource>),
|
||||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
collect_resize_events,
|
||||||
snap_cards_on_window_resize.after(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);
|
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ pub struct GameOverScreen;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct GameMutation;
|
pub struct GameMutation;
|
||||||
|
|
||||||
|
/// System set for every writer of [`crate::events::NewGameRequestEvent`].
|
||||||
|
///
|
||||||
|
/// Many UI entry points fire this trigger (buttons, keyboard, modals,
|
||||||
|
/// mode pickers). Their relative append order within a frame is
|
||||||
|
/// meaningless — consumers drain the whole queue — so members are
|
||||||
|
/// registered `.in_set(NewGameRequestWriters).ambiguous_with(NewGameRequestWriters)`
|
||||||
|
/// to declare writer-vs-writer order irrelevant instead of leaving it as an
|
||||||
|
/// ambiguity (#143). Only ever combine with `.ambiguous_with` on the same
|
||||||
|
/// set; do NOT hang ordering edges off this set.
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NewGameRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `UndoRequestEvent` — same rationale as
|
||||||
|
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||||||
|
/// meaningless (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct UndoRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
||||||
|
/// arrival order and any same-frame order is fine (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct InfoToastWriters;
|
||||||
|
|
||||||
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct GameStatePath(pub Option<PathBuf>);
|
pub struct GameStatePath(pub Option<PathBuf>);
|
||||||
@@ -208,28 +231,66 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<AppLifecycle>()
|
.add_message::<AppLifecycle>()
|
||||||
// add_message is idempotent; SettingsPlugin also registers this.
|
// add_message is idempotent; SettingsPlugin also registers this.
|
||||||
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
||||||
.add_systems(Update, poll_pending_new_game_seed.before(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
poll_pending_new_game_seed
|
||||||
|
.before(GameMutation)
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_new_game, handle_draw, handle_move, handle_undo)
|
(handle_new_game, handle_draw, handle_move, handle_undo)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(GameMutation),
|
.in_set(GameMutation),
|
||||||
)
|
)
|
||||||
.add_systems(Update, check_no_moves.after(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
check_no_moves
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals)
|
||||||
|
.in_set(InfoToastWriters)
|
||||||
|
.ambiguous_with(InfoToastWriters),
|
||||||
|
)
|
||||||
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
||||||
.add_systems(Update, handle_confirm_input.after(GameMutation))
|
.add_systems(
|
||||||
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
|
Update,
|
||||||
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
(
|
||||||
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
|
handle_confirm_input,
|
||||||
|
handle_confirm_button_input,
|
||||||
|
handle_game_over_input,
|
||||||
|
handle_game_over_button_input,
|
||||||
|
)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::ui_focus::FocusKeys)
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters)
|
||||||
|
.in_set(UndoRequestWriters)
|
||||||
|
.ambiguous_with(UndoRequestWriters),
|
||||||
|
)
|
||||||
// Restore prompt: spawn the modal once the splash is gone,
|
// Restore prompt: spawn the modal once the splash is gone,
|
||||||
// route Continue / New Game intents back into the existing
|
// route Continue / New Game intents back into the existing
|
||||||
// GameMutation flow.
|
// GameMutation flow.
|
||||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
// All pre-mutation game-state writers are chained: elapsed
|
||||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
// time ticks first, settings sync next, then the restore prompt —
|
||||||
.add_systems(Update, sync_settings_to_game.before(GameMutation))
|
// a deterministic spine instead of three unordered ResMut holders
|
||||||
|
// (ambiguity burn-down, #143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
tick_elapsed_time,
|
||||||
|
sync_settings_to_game,
|
||||||
|
spawn_restore_prompt_if_pending,
|
||||||
|
handle_restore_prompt
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.before(GameMutation),
|
||||||
|
)
|
||||||
.init_resource::<AutoSaveTimer>()
|
.init_resource::<AutoSaveTimer>()
|
||||||
.add_systems(Update, tick_elapsed_time)
|
.add_systems(Update, auto_save_game_state.after(GameMutation))
|
||||||
.add_systems(Update, auto_save_game_state)
|
|
||||||
.add_systems(Last, save_game_state_on_exit);
|
.add_systems(Last, save_game_state_on_exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use crate::events::{
|
|||||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use crate::input_plugin::TouchDragSet;
|
use crate::input_plugin::TouchDragSet;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
@@ -54,6 +54,7 @@ use crate::time_attack_plugin::TimeAttackResource;
|
|||||||
use crate::ui_focus::{FocusGroup, Focusable};
|
use crate::ui_focus::{FocusGroup, Focusable};
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::SPACE_2;
|
use crate::ui_theme::SPACE_2;
|
||||||
|
use crate::ui_theme::UiTextFx;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
||||||
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||||
@@ -153,6 +154,13 @@ pub struct HudColumn;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudActionBar;
|
pub struct HudActionBar;
|
||||||
|
|
||||||
|
/// Set wrapping the chained HUD button/popover interaction systems. Other
|
||||||
|
/// keyboard consumers order themselves around it (e.g.
|
||||||
|
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
|
||||||
|
/// deterministic (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct HudButtons;
|
||||||
|
|
||||||
/// Marker on the text node inside each touch-layout action-bar button.
|
/// Marker on the text node inside each touch-layout action-bar button.
|
||||||
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -467,23 +475,56 @@ impl Plugin for HudPlugin {
|
|||||||
// defensively so the HUD plugin works standalone in tests.
|
// defensively so the HUD plugin works standalone in tests.
|
||||||
.add_message::<WindowResized>()
|
.add_message::<WindowResized>()
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
// HUD text updaters run as one deterministic chain (they write
|
||||||
|
// disjoint Text nodes, but Bevy can't prove it); update_hud also
|
||||||
|
// reads AutoCompleteState, so the chain sits after the
|
||||||
|
// auto-complete detect/drive chain (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
(
|
||||||
|
update_hud,
|
||||||
|
update_selection_hud.run_if(
|
||||||
|
resource_exists_and_changed::<SelectionState>
|
||||||
|
.or(resource_exists_and_changed::<GameStateResource>),
|
||||||
|
),
|
||||||
|
update_won_previously,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx),
|
||||||
)
|
)
|
||||||
.add_systems(Update, restore_hud_on_modal)
|
// HUD chrome visibility: modal-restore writes HudVisibility, the
|
||||||
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
// applier consumes it, and the layout recompute reads it — a
|
||||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
// fixed chain instead of three racing systems (#143).
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
update_selection_hud.run_if(
|
(restore_hud_on_modal, apply_hud_visibility)
|
||||||
resource_exists_and_changed::<SelectionState>
|
.chain()
|
||||||
.or(resource_exists_and_changed::<GameStateResource>),
|
.before(LayoutSystem::UpdateOnResize),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
|
||||||
|
handle_avatar_button.ambiguous_with(HudButtons),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.add_systems(Update, update_hud_typography)
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
announce_auto_complete
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// Typography rescale touches HUD TextFont only, but orders after
|
||||||
|
// the board painters that resize card/label text (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
update_hud_typography.after(crate::card_plugin::BoardVisuals),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -492,24 +533,40 @@ impl Plugin for HudPlugin {
|
|||||||
advance_score_floater,
|
advance_score_floater,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(start_streak_flourish, advance_streak_flourish)
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_new_game_button,
|
handle_new_game_button
|
||||||
handle_undo_button,
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
handle_undo_button
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_hint_button,
|
handle_hint_button
|
||||||
|
.after(GameMutation)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
handle_modes_button,
|
handle_modes_button,
|
||||||
handle_mode_option_click,
|
handle_mode_option_click
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
handle_modes_backdrop_click,
|
handle_modes_backdrop_click,
|
||||||
close_modes_popover_on_escape,
|
close_modes_popover_on_escape,
|
||||||
handle_menu_button,
|
handle_menu_button,
|
||||||
@@ -517,7 +574,10 @@ impl Plugin for HudPlugin {
|
|||||||
handle_menu_backdrop_click,
|
handle_menu_backdrop_click,
|
||||||
close_menu_popover_on_escape,
|
close_menu_popover_on_escape,
|
||||||
paint_action_buttons,
|
paint_action_buttons,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(HudButtons)
|
||||||
|
.before(crate::ui_focus::FocusKeys),
|
||||||
)
|
)
|
||||||
// Fade lives in `Last` so it always overrides whatever the
|
// Fade lives in `Last` so it always overrides whatever the
|
||||||
// hover/paint pass set on `BackgroundColor` this frame.
|
// hover/paint pass set on `BackgroundColor` this frame.
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ mod tests {
|
|||||||
use crate::ui_focus::UiFocusPlugin;
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
use crate::ui_modal::UiModalPlugin;
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
|
||||||
/// Legacy ambiguity backlog measured 2026-07-06 (issue #143). This
|
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
|
||||||
/// number may only decrease. If your change trips this assertion you
|
/// same day (#143, PRs #146–#149) — this is now a hard gate. If your
|
||||||
/// have added a pair of systems with conflicting data access and no
|
/// change trips this assertion you have added a pair of systems with
|
||||||
/// ordering edge — add `.before`/`.after` (order matters) or
|
/// conflicting data access and no ordering edge: add `.before`/`.after`
|
||||||
/// `.ambiguous_with` (provably order-independent) at the registration
|
/// where order matters, or `.ambiguous_with` the relevant domain set
|
||||||
/// site. When triage lowers the real count, lower this constant too.
|
/// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets)
|
||||||
const AMBIGUITY_BASELINE: usize = 302;
|
/// where it provably does not. Do not raise this constant.
|
||||||
|
const AMBIGUITY_BASELINE: usize = 0;
|
||||||
|
|
||||||
fn cluster_app() -> App {
|
fn cluster_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -96,10 +97,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(
|
assert_eq!(
|
||||||
count <= AMBIGUITY_BASELINE,
|
count, AMBIGUITY_BASELINE,
|
||||||
"system-order ambiguities grew: {count} > baseline {AMBIGUITY_BASELINE}. \
|
"system-order ambiguities changed from the enforced baseline. \
|
||||||
Add .before/.after or .ambiguous_with at the new registration site.",
|
Add .before/.after or .ambiguous_with at the new registration site \
|
||||||
|
(or, if the count legitimately dropped below a nonzero baseline, \
|
||||||
|
lower AMBIGUITY_BASELINE).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ pub struct PendingWindowGeometry {
|
|||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct SettingsChangedEvent(pub Settings);
|
pub struct SettingsChangedEvent(pub Settings);
|
||||||
|
|
||||||
|
/// System set for the systems that mutate [`SettingsResource`] every frame
|
||||||
|
/// (hotkeys and window-geometry persistence). Ordered before
|
||||||
|
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
|
||||||
|
/// after this set (directly, or transitively via `.after(GameMutation)`)
|
||||||
|
/// so they observe the current frame's settings deterministically (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SettingsMutation;
|
||||||
|
|
||||||
/// Marker on the root Settings panel entity.
|
/// Marker on the root Settings panel entity.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct SettingsPanel;
|
pub struct SettingsPanel;
|
||||||
@@ -372,16 +380,37 @@ impl Plugin for SettingsPlugin {
|
|||||||
// also runs cleanly under `MinimalPlugins` (tests).
|
// also runs cleanly under `MinimalPlugins` (tests).
|
||||||
.add_message::<WindowResized>()
|
.add_message::<WindowResized>()
|
||||||
.add_message::<WindowMoved>()
|
.add_message::<WindowMoved>()
|
||||||
|
// Settings changes land before game logic runs: the mutator
|
||||||
|
// chain (volume keys → geometry record → geometry persist) is a
|
||||||
|
// deterministic spine, and the whole set precedes GameMutation so
|
||||||
|
// every reader already ordered after GameMutation sees this
|
||||||
|
// frame's settings transitively (ambiguity burn-down, #143).
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
SettingsMutation
|
||||||
|
.after(crate::layout::LayoutSystem::UpdateOnResize)
|
||||||
|
.before(crate::game_plugin::GameMutation),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_volume_keys,
|
handle_volume_keys,
|
||||||
toggle_settings_screen,
|
|
||||||
scroll_settings_panel,
|
|
||||||
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
|
|
||||||
record_window_geometry_changes,
|
record_window_geometry_changes,
|
||||||
persist_window_geometry_after_debounce,
|
persist_window_geometry_after_debounce,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(SettingsMutation),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
toggle_settings_screen
|
||||||
|
.before(crate::ui_focus::FocusKeys)
|
||||||
|
.ambiguous_with(crate::hud_plugin::HudButtons),
|
||||||
|
scroll_settings_panel,
|
||||||
|
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if self.ui_enabled {
|
if self.ui_enabled {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
|||||||
use solitaire_core::Suit;
|
use solitaire_core::Suit;
|
||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::hud_plugin::HudVisibility;
|
use crate::hud_plugin::HudVisibility;
|
||||||
use crate::layout::{
|
use crate::layout::{
|
||||||
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
||||||
@@ -84,6 +85,13 @@ pub struct HintPileHighlight {
|
|||||||
/// Registers the table background and pile-marker rendering.
|
/// Registers the table background and pile-marker rendering.
|
||||||
pub struct TablePlugin;
|
pub struct TablePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
|
||||||
|
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
|
||||||
|
/// systems that touch `Visibility` on UI entities declare themselves
|
||||||
|
/// ambiguous with it (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MarkerVisuals;
|
||||||
|
|
||||||
impl Plugin for TablePlugin {
|
impl Plugin for TablePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register WindowResized so the plugin works under MinimalPlugins in
|
// Register WindowResized so the plugin works under MinimalPlugins in
|
||||||
@@ -100,10 +108,18 @@ impl Plugin for TablePlugin {
|
|||||||
(
|
(
|
||||||
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
||||||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||||||
apply_theme_on_settings_change,
|
// Marker painters: deterministic chain after the card
|
||||||
apply_hint_pile_highlight,
|
// paint pipeline — markers and cards share Sprite/
|
||||||
tick_hint_pile_highlights,
|
// Transform access (#143).
|
||||||
sync_pile_marker_visibility,
|
(
|
||||||
|
apply_theme_on_settings_change,
|
||||||
|
apply_hint_pile_highlight,
|
||||||
|
tick_hint_pile_highlights,
|
||||||
|
sync_pile_marker_visibility.after(GameMutation),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(MarkerVisuals)
|
||||||
|
.after(crate::card_plugin::BoardVisuals),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ pub struct FocusedButton(pub Option<Entity>);
|
|||||||
/// gains keyboard navigation without per-plugin wiring.
|
/// gains keyboard navigation without per-plugin wiring.
|
||||||
pub struct UiFocusPlugin;
|
pub struct UiFocusPlugin;
|
||||||
|
|
||||||
|
/// Set on [`handle_focus_keys`], the focus-ring keyboard navigator. It runs
|
||||||
|
/// AFTER every app-level keyboard consumer (HUD buttons/popovers, restore
|
||||||
|
/// prompt, settings toggle) so Esc/Tab consumption order is defined instead
|
||||||
|
/// of scheduler-dependent (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct FocusKeys;
|
||||||
|
|
||||||
impl Plugin for UiFocusPlugin {
|
impl Plugin for UiFocusPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<FocusedButton>()
|
app.init_resource::<FocusedButton>()
|
||||||
@@ -147,9 +154,19 @@ impl Plugin for UiFocusPlugin {
|
|||||||
(
|
(
|
||||||
sync_focus_on_mouse_click,
|
sync_focus_on_mouse_click,
|
||||||
clear_hud_focus_on_unhover,
|
clear_hud_focus_on_unhover,
|
||||||
handle_focus_keys,
|
handle_focus_keys
|
||||||
update_focus_overlay,
|
.in_set(FocusKeys)
|
||||||
pulse_focus_overlay,
|
.after(crate::game_plugin::GameMutation),
|
||||||
|
update_focus_overlay
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::table_plugin::MarkerVisuals),
|
||||||
|
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(),
|
.chain(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -695,7 +695,12 @@ impl Plugin for UiModalPlugin {
|
|||||||
advance_modal_enter,
|
advance_modal_enter,
|
||||||
paint_modal_buttons,
|
paint_modal_buttons,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::hud_plugin::HudButtons),
|
||||||
);
|
);
|
||||||
// Click-outside-to-dismiss is independent of the open
|
// Click-outside-to-dismiss is independent of the open
|
||||||
// animation chain — it reads `just_pressed(Left)` and runs
|
// animation chain — it reads `just_pressed(Left)` and runs
|
||||||
|
|||||||
@@ -698,3 +698,12 @@ mod tests {
|
|||||||
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user