fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -23,20 +23,20 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{save_settings_to, Settings};
|
||||
use solitaire_data::{Settings, save_settings_to};
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant,
|
||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use crate::ui_theme::{
|
||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -101,16 +101,46 @@ struct HotkeyRow {
|
||||
/// refactor the help plugin.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||
HotkeyRow { keys: "Tab → Enter", description: "Pick a card; arrows pick where; Enter to drop" },
|
||||
HotkeyRow { keys: "N", description: "New Classic game" },
|
||||
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" },
|
||||
HotkeyRow { keys: "S", description: "Stats & progression" },
|
||||
HotkeyRow { keys: "A", description: "Achievements" },
|
||||
HotkeyRow { keys: "O", description: "Settings" },
|
||||
HotkeyRow { keys: "Esc", description: "Pause / resume" },
|
||||
HotkeyRow { keys: "F1", description: "Help / controls" },
|
||||
HotkeyRow {
|
||||
keys: "D / Space",
|
||||
description: "Draw from stock",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "U",
|
||||
description: "Undo last move",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Tab → Enter",
|
||||
description: "Pick a card; arrows pick where; Enter to drop",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "N",
|
||||
description: "New Classic game",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "M",
|
||||
description: "Open Mode Launcher (then 1–5 to pick)",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "S",
|
||||
description: "Stats & progression",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "A",
|
||||
description: "Achievements",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "O",
|
||||
description: "Settings",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Esc",
|
||||
description: "Pause / resume",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F1",
|
||||
description: "Help / controls",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -126,11 +156,7 @@ impl Plugin for OnboardingPlugin {
|
||||
.add_systems(PostStartup, spawn_if_first_run)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_onboarding_buttons,
|
||||
handle_onboarding_keyboard,
|
||||
)
|
||||
.chain(),
|
||||
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -523,11 +549,16 @@ mod tests {
|
||||
assert_eq!(current_slide(&app), 0);
|
||||
|
||||
// Spawn a Next button with Pressed interaction.
|
||||
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
|
||||
app.world_mut()
|
||||
.spawn((OnboardingNextButton, Button, Interaction::Pressed));
|
||||
app.update();
|
||||
|
||||
assert_eq!(current_slide(&app), 1, "Next must advance to slide 1");
|
||||
assert_eq!(count_screens(&mut app), 1, "exactly one modal must be visible");
|
||||
assert_eq!(
|
||||
count_screens(&mut app),
|
||||
1,
|
||||
"exactly one modal must be visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -548,10 +579,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
|
||||
app.world_mut()
|
||||
.spawn((OnboardingBackButton, Button, Interaction::Pressed));
|
||||
app.update();
|
||||
|
||||
assert_eq!(current_slide(&app), 1, "Back must retreat from slide 2 to slide 1");
|
||||
assert_eq!(
|
||||
current_slide(&app),
|
||||
1,
|
||||
"Back must retreat from slide 2 to slide 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -561,7 +597,8 @@ mod tests {
|
||||
assert_eq!(current_slide(&app), 0);
|
||||
|
||||
// Pressing Back on slide 0 must be a no-op (no underflow to u8::MAX).
|
||||
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
|
||||
app.world_mut()
|
||||
.spawn((OnboardingBackButton, Button, Interaction::Pressed));
|
||||
app.update();
|
||||
|
||||
assert_eq!(current_slide(&app), 0, "Back on slide 0 must not underflow");
|
||||
@@ -576,15 +613,23 @@ mod tests {
|
||||
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = SLIDE_COUNT - 1;
|
||||
|
||||
// Next on the last slide should complete onboarding, not advance further.
|
||||
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
|
||||
app.world_mut()
|
||||
.spawn((OnboardingNextButton, Button, Interaction::Pressed));
|
||||
app.update();
|
||||
|
||||
// first_run_complete must be set.
|
||||
assert!(
|
||||
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete,
|
||||
"Next on last slide must set first_run_complete"
|
||||
);
|
||||
assert_eq!(count_screens(&mut app), 0, "modal must be gone after completion");
|
||||
assert_eq!(
|
||||
count_screens(&mut app),
|
||||
0,
|
||||
"modal must be gone after completion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -596,11 +641,15 @@ mod tests {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
|
||||
app.world_mut().spawn((OnboardingSkipButton, Button, Interaction::Pressed));
|
||||
app.world_mut()
|
||||
.spawn((OnboardingSkipButton, Button, Interaction::Pressed));
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete,
|
||||
"Skip must set first_run_complete"
|
||||
);
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
@@ -658,7 +707,10 @@ mod tests {
|
||||
|
||||
assert_eq!(count_screens(&mut app), 0, "Esc must dismiss onboarding");
|
||||
assert!(
|
||||
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete,
|
||||
"Esc must set first_run_complete"
|
||||
);
|
||||
}
|
||||
@@ -675,7 +727,10 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete,
|
||||
"Enter on last slide must complete onboarding"
|
||||
);
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
@@ -694,7 +749,10 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(target_os = "android")]
|
||||
fn slide_count_constant_is_two_on_android() {
|
||||
assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)");
|
||||
assert_eq!(
|
||||
SLIDE_COUNT, 2,
|
||||
"SLIDE_COUNT must be 2 on Android (no keyboard slide)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -727,7 +785,10 @@ mod tests {
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||
app.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.first_run_complete,
|
||||
"completing the last slide must set first_run_complete"
|
||||
);
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
@@ -746,7 +807,10 @@ mod tests {
|
||||
fn all_hotkey_rows_have_non_empty_fields() {
|
||||
for row in HOTKEYS {
|
||||
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
||||
assert!(!row.description.is_empty(), "hotkey description must not be empty");
|
||||
assert!(
|
||||
!row.description.is_empty(),
|
||||
"hotkey description must not be empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user