Compare commits
3 Commits
eb6c93fb55
...
v0.35.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 87aec5bdf2 | |||
| 6f5cebdb02 | |||
| 9c96e2fade |
@@ -20,4 +20,4 @@ resources:
|
|||||||
images:
|
images:
|
||||||
- name: solitaire-server
|
- name: solitaire-server
|
||||||
newName: git.aleshym.co/funman300/solitaire-server
|
newName: git.aleshym.co/funman300/solitaire-server
|
||||||
newTag: 858012d9
|
newTag: eb6c93fb
|
||||||
|
|||||||
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
fn start_shake_anim(
|
fn start_shake_anim(
|
||||||
mut events: MessageReader<MoveRejectedEvent>,
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let dest_pile = &ev.to;
|
let dest_pile = &ev.to;
|
||||||
// Collect the card ids that belong to the destination pile.
|
// Collect the card ids that belong to the destination pile.
|
||||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
||||||
@@ -489,11 +494,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
|||||||
fn start_foundation_flourish(
|
fn start_foundation_flourish(
|
||||||
mut events: MessageReader<FoundationCompletedEvent>,
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let pile_type = PileType::Foundation(ev.slot);
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
// Top card of the completed foundation is the King.
|
// Top card of the completed foundation is the King.
|
||||||
let Some(king_id) = game
|
let Some(king_id) = game
|
||||||
@@ -785,7 +795,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
// 52 cards should produce more than a couple distinct jitter factors;
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
// a constant function would return one value for all ids.
|
// a constant function would return one function for all ids.
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
let unique: HashSet<u64> = (0u32..52)
|
let unique: HashSet<u64> = (0u32..52)
|
||||||
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||||
@@ -796,4 +806,96 @@ mod tests {
|
|||||||
unique.len()
|
unique.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ShakeAnim, FoundationFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
|
||||||
|
/// is on, even when the event targets a pile that has card entities present.
|
||||||
|
#[test]
|
||||||
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||||
|
let dest_pile = PileType::Tableau(0);
|
||||||
|
let card_id = app
|
||||||
|
.world()
|
||||||
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get(&dest_pile)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
|
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||||
|
|
||||||
|
// Spawn a minimal CardEntity matching that id so the system would
|
||||||
|
// find it and insert ShakeAnim if the gate were absent.
|
||||||
|
app.world_mut().spawn((
|
||||||
|
CardEntity { card_id },
|
||||||
|
Transform::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||||
|
.write(MoveRejectedEvent {
|
||||||
|
from: PileType::Stock,
|
||||||
|
to: dest_pile,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let shake_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ShakeAnim>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
|
||||||
|
/// `reduce_motion_mode` is on.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||||
|
.write(FoundationCompletedEvent {
|
||||||
|
slot: 0,
|
||||||
|
suit: solitaire_core::card::Suit::Spades,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let flourish_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&FoundationFlourish>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1755,6 +1755,11 @@ fn detect_score_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -1928,6 +1933,9 @@ fn start_streak_flourish(
|
|||||||
let Some(latest) = events.read().last() else {
|
let Some(latest) = events.read().last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -3011,6 +3019,35 @@ mod tests {
|
|||||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||||
|
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||||
|
#[test]
|
||||||
|
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScorePulse must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScoreFloater must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
|||||||
|
|
||||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||||
SyncConfigureRequestEvent,
|
WarningToastEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
@@ -109,7 +109,7 @@ impl Plugin for SyncPlugin {
|
|||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
.add_message::<SyncCompleteEvent>()
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_systems(Startup, start_pull)
|
.add_systems(Startup, start_pull)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -191,7 +191,7 @@ fn poll_pull_result(
|
|||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else {
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
return;
|
return;
|
||||||
@@ -245,13 +245,13 @@ fn poll_pull_result(
|
|||||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
|
warning_toast.write(WarningToastEvent(msg.clone()));
|
||||||
// On auth failure, reopen the Connect modal so the player can
|
// On auth failure, reopen the Connect modal so the player can
|
||||||
// re-enter credentials without having to navigate through Settings.
|
// re-enter credentials without having to navigate through Settings.
|
||||||
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||||
// the modal is already on screen, so repeated pull failures don't
|
// the modal is already on screen, so repeated pull failures don't
|
||||||
// stack multiple modals.
|
// stack multiple modals.
|
||||||
if matches!(e, SyncError::Auth(_)) {
|
if matches!(e, SyncError::Auth(_)) {
|
||||||
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
|
||||||
configure_sync.write(SyncConfigureRequestEvent);
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
}
|
}
|
||||||
status.0 = SyncStatus::Error(msg.clone());
|
status.0 = SyncStatus::Error(msg.clone());
|
||||||
@@ -550,6 +550,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_failure_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
let mut app = headless_app_with(FailingProvider);
|
||||||
|
let deadline =
|
||||||
|
std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
if matches!(
|
||||||
|
app.world().resource::<SyncStatusResource>().0,
|
||||||
|
SyncStatus::Error(_)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"a WarningToastEvent must fire when the pull fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_payload_sets_nil_user_id() {
|
fn build_payload_sets_nil_user_id() {
|
||||||
let payload = build_payload(
|
let payload = build_payload(
|
||||||
|
|||||||
Reference in New Issue
Block a user