Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f86d66ffe | |||
| 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
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ pub struct Settings {
|
|||||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub leaderboard_display_name: Option<String>,
|
pub leaderboard_display_name: Option<String>,
|
||||||
|
/// `true` once the player has successfully opted in to the leaderboard on
|
||||||
|
/// the server. Used to decide whether a display-name change should also
|
||||||
|
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
|
||||||
|
/// deserialize cleanly to `false` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub leaderboard_opted_in: bool,
|
||||||
/// When `true`, the player may drag the top card of a foundation pile back
|
/// When `true`, the player may drag the top card of a foundation pile back
|
||||||
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
||||||
/// rules). Older `settings.json` files without this key deserialize to
|
/// rules). Older `settings.json` files without this key deserialize to
|
||||||
@@ -387,6 +393,7 @@ impl Default for Settings {
|
|||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
analytics_enabled: false,
|
analytics_enabled: false,
|
||||||
matomo_url: None,
|
matomo_url: None,
|
||||||
|
|||||||
@@ -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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
|||||||
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
@@ -138,6 +138,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<OptOutTask>()
|
.init_resource::<OptOutTask>()
|
||||||
.init_resource::<DisplayNameBuffer>()
|
.init_resource::<DisplayNameBuffer>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||||
@@ -159,6 +160,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
handle_display_name_text_input,
|
handle_display_name_text_input,
|
||||||
handle_display_name_confirm,
|
handle_display_name_confirm,
|
||||||
handle_display_name_cancel,
|
handle_display_name_cancel,
|
||||||
|
update_leaderboard_public_name_label,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
)
|
)
|
||||||
@@ -361,10 +363,13 @@ fn handle_opt_in_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
/// Polls the opt-in task; fires a toast and persists opted-in state on completion.
|
||||||
fn poll_opt_in_task(
|
fn poll_opt_in_task(
|
||||||
mut task_res: ResMut<OptInTask>,
|
mut task_res: ResMut<OptInTask>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else { return };
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
@@ -372,10 +377,18 @@ fn poll_opt_in_task(
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||||
|
if let Some(mut s) = settings {
|
||||||
|
s.0.leaderboard_opted_in = true;
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings after opt-in: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-in failed: {e}");
|
warn!("leaderboard opt-in failed: {e}");
|
||||||
toast.write(InfoToastEvent("Failed to join leaderboard".to_string()));
|
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,10 +414,13 @@ fn handle_opt_out_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
/// Polls the opt-out task; fires a toast and clears opted-in state on completion.
|
||||||
fn poll_opt_out_task(
|
fn poll_opt_out_task(
|
||||||
mut task_res: ResMut<OptOutTask>,
|
mut task_res: ResMut<OptOutTask>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else { return };
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
@@ -412,10 +428,18 @@ fn poll_opt_out_task(
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||||
|
if let Some(mut s) = settings {
|
||||||
|
s.0.leaderboard_opted_in = false;
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings after opt-out: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-out failed: {e}");
|
warn!("leaderboard opt-out failed: {e}");
|
||||||
toast.write(InfoToastEvent("Failed to leave leaderboard".to_string()));
|
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,6 +452,12 @@ fn poll_opt_out_task(
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct LeaderboardCloseButton;
|
pub struct LeaderboardCloseButton;
|
||||||
|
|
||||||
|
/// Marker on the "Public name: …" label inside the leaderboard panel so it
|
||||||
|
/// can be updated reactively when the player changes their display name
|
||||||
|
/// without a full panel rebuild.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct LeaderboardPublicNameText;
|
||||||
|
|
||||||
fn spawn_leaderboard_screen(
|
fn spawn_leaderboard_screen(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
data: &LeaderboardResource,
|
data: &LeaderboardResource,
|
||||||
@@ -481,6 +511,7 @@ fn spawn_leaderboard_screen(
|
|||||||
None => "Public name: (same as username)".to_string(),
|
None => "Public name: (same as username)".to_string(),
|
||||||
};
|
};
|
||||||
row.spawn((
|
row.spawn((
|
||||||
|
LeaderboardPublicNameText,
|
||||||
Text::new(label),
|
Text::new(label),
|
||||||
font_caption.clone(),
|
font_caption.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
@@ -733,7 +764,9 @@ fn handle_display_name_text_input(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the typed display name to `SettingsResource` and closes the modal.
|
/// Saves the typed display name to `SettingsResource`, closes the modal, and
|
||||||
|
/// pushes the new name to the server when the player is already opted in.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_display_name_confirm(
|
fn handle_display_name_confirm(
|
||||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||||
screens: Query<Entity, With<DisplayNameModal>>,
|
screens: Query<Entity, With<DisplayNameModal>>,
|
||||||
@@ -741,6 +774,8 @@ fn handle_display_name_confirm(
|
|||||||
buf: Res<DisplayNameBuffer>,
|
buf: Res<DisplayNameBuffer>,
|
||||||
settings: Option<ResMut<SettingsResource>>,
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
settings_path: Option<Res<SettingsStoragePath>>,
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<OptInTask>,
|
||||||
) {
|
) {
|
||||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
@@ -750,13 +785,47 @@ fn handle_display_name_confirm(
|
|||||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(trimmed)
|
Some(trimmed.clone())
|
||||||
};
|
};
|
||||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
{
|
{
|
||||||
warn!("failed to save settings: {e}");
|
warn!("failed to save settings: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push updated name to the server when already opted in and no task
|
||||||
|
// is in flight. The server's opt-in endpoint is an upsert, so calling
|
||||||
|
// it a second time only updates the display_name column.
|
||||||
|
let is_remote = provider
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
|
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
|
||||||
|
let display_name = settings
|
||||||
|
.0
|
||||||
|
.leaderboard_display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
||||||
|
ref username,
|
||||||
|
..
|
||||||
|
} = settings.0.sync_backend
|
||||||
|
{
|
||||||
|
username.chars().take(32).collect()
|
||||||
|
} else {
|
||||||
|
"Player".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(p) = provider {
|
||||||
|
let provider = p.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
provider
|
||||||
|
.opt_in_leaderboard(&display_name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -857,6 +926,25 @@ fn spawn_display_name_modal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
|
||||||
|
/// `SettingsResource` after the player saves a new display name. No-op when
|
||||||
|
/// the panel is closed (`labels.is_empty()` exits immediately).
|
||||||
|
fn update_leaderboard_public_name_label(
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
|
||||||
|
) {
|
||||||
|
if labels.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
|
||||||
|
Some(n) => format!("Public name: {n}"),
|
||||||
|
None => "Public name: (same as username)".to_string(),
|
||||||
|
};
|
||||||
|
for mut text in &mut labels {
|
||||||
|
text.0 = new_label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
||||||
fn printable_char_dn(text: &str) -> Option<char> {
|
fn printable_char_dn(text: &str) -> Option<char> {
|
||||||
let ch = text.chars().next()?;
|
let ch = text.chars().next()?;
|
||||||
@@ -1048,4 +1136,171 @@ mod tests {
|
|||||||
// 65 seconds = 1:05, not 1:5
|
// 65 seconds = 1:05, not 1:5
|
||||||
assert_eq!(format_secs(65), "1:05");
|
assert_eq!(format_secs(65), "1:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Bug-fix regression tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn headless_app_with_settings() -> App {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||||
|
#[test]
|
||||||
|
fn opt_in_error_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Inject a pre-resolved failed task directly into OptInTask.
|
||||||
|
let failed_task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
|
// Allow the task to complete and be polled.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"WarningToastEvent must be fired when opt-in fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 1: opt-out errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||||
|
#[test]
|
||||||
|
fn opt_out_error_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
let failed_task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"WarningToastEvent must be fired when opt-out fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 2: successful opt-in must set `leaderboard_opted_in = true` in Settings.
|
||||||
|
#[test]
|
||||||
|
fn opt_in_success_sets_opted_in_flag() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Confirm the flag starts false.
|
||||||
|
assert!(!app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in);
|
||||||
|
|
||||||
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in,
|
||||||
|
"leaderboard_opted_in must be true after successful opt-in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 2: successful opt-out must clear `leaderboard_opted_in`.
|
||||||
|
#[test]
|
||||||
|
fn opt_out_success_clears_opted_in_flag() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Seed as opted in.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in = true;
|
||||||
|
|
||||||
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in,
|
||||||
|
"leaderboard_opted_in must be false after successful opt-out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 3: `LeaderboardPublicNameText` label must reflect a display-name
|
||||||
|
/// change applied to `SettingsResource` without a panel rebuild.
|
||||||
|
#[test]
|
||||||
|
fn public_name_label_updates_reactively() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Open the panel.
|
||||||
|
press(&mut app, KeyCode::KeyL);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Verify the label starts with the default copy.
|
||||||
|
let initial: String = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("LeaderboardPublicNameText must exist while panel is open")
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
initial.contains("same as username"),
|
||||||
|
"initial label should say '(same as username)' when no display name is set"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear just-pressed state so `toggle_leaderboard_screen` doesn't
|
||||||
|
// re-fire in the next frame (MinimalPlugins has no input-tick system).
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyL);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the display name in SettingsResource.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_display_name = Some("TestPlayer".to_string());
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let updated: String = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("LeaderboardPublicNameText must still exist")
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
updated.contains("TestPlayer"),
|
||||||
|
"label must reflect new display name after settings change"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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