feat(engine,server): XP toast on win + display_name max-length validation

ProgressPlugin now fires XpAwardedEvent on every win. AnimationPlugin
shows a "+N XP" toast so players see XP feedback immediately after
winning. Server leaderboard opt-in endpoint also now validates that
display_name is at most 32 characters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 03:02:59 +00:00
parent e3ac494e85
commit de840fb006
5 changed files with 33 additions and 5 deletions
+9 -1
View File
@@ -11,7 +11,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{InfoToastEvent, NewGameConfirmEvent};
use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
@@ -94,6 +94,7 @@ impl Plugin for AnimationPlugin {
.add_event::<SettingsChangedEvent>()
.add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>()
.add_event::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
.add_systems(Startup, init_slide_duration)
.add_systems(
@@ -113,6 +114,7 @@ impl Plugin for AnimationPlugin {
handle_auto_complete_toast,
handle_new_game_confirm_toast,
handle_info_toast,
handle_xp_awarded_toast,
tick_toasts,
)
.after(GameMutation),
@@ -330,6 +332,12 @@ fn handle_info_toast(mut commands: Commands, mut events: EventReader<InfoToastEv
}
}
fn handle_xp_awarded_toast(mut commands: Commands, mut events: EventReader<XpAwardedEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
}
}
fn tick_toasts(
mut commands: Commands,
time: Res<Time>,
+7
View File
@@ -80,3 +80,10 @@ pub struct NewGameConfirmEvent;
/// a short string to the player, e.g. "Locked — reach level 5".
#[derive(Event, Debug, Clone)]
pub struct InfoToastEvent(pub String);
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Event, Debug, Clone, Copy)]
pub struct XpAwardedEvent {
pub amount: u64,
}
+1 -1
View File
@@ -41,7 +41,7 @@ pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
};
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen};
+4 -1
View File
@@ -12,7 +12,7 @@ use solitaire_data::{
load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress,
};
use crate::events::GameWonEvent;
use crate::events::{GameWonEvent, XpAwardedEvent};
use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;
@@ -65,6 +65,7 @@ impl Plugin for ProgressPlugin {
app.insert_resource(ProgressResource(loaded))
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
.add_event::<LevelUpEvent>()
.add_event::<XpAwardedEvent>()
.add_event::<GameWonEvent>()
.add_systems(
Update,
@@ -78,6 +79,7 @@ impl Plugin for ProgressPlugin {
fn award_xp_on_win(
mut wins: EventReader<GameWonEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
game: Res<GameStateResource>,
path: Res<ProgressStoragePath>,
mut progress: ResMut<ProgressResource>,
@@ -86,6 +88,7 @@ fn award_xp_on_win(
let used_undo = game.0.undo_count > 0;
let amount = xp_for_win(ev.time_seconds, used_undo);
let prev_level = progress.0.add_xp(amount);
xp_awarded.send(XpAwardedEvent { amount });
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
previous_level: prev_level,
+12 -2
View File
@@ -107,14 +107,24 @@ pub async fn opt_out(
///
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
/// leaderboard entry with the supplied display name.
/// Maximum allowed length for a leaderboard display name.
const DISPLAY_NAME_MAX: usize = 32;
pub async fn opt_in(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
Json(body): Json<OptInRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.display_name.trim().is_empty() {
let display_name = body.display_name.trim();
if display_name.is_empty() {
return Err(AppError::BadRequest("display_name must not be empty".into()));
}
if display_name.len() > DISPLAY_NAME_MAX {
return Err(AppError::BadRequest(format!(
"display_name must be at most {DISPLAY_NAME_MAX} characters"
)));
}
let display_name = display_name.to_string();
// Mark the user as opted in.
sqlx::query!(
@@ -134,7 +144,7 @@ pub async fn opt_in(
display_name = excluded.display_name,
recorded_at = excluded.recorded_at"#,
user.user_id,
body.display_name,
display_name,
now
)
.execute(&pool)