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:
@@ -11,7 +11,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
|||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
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::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -94,6 +94,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_event::<NewGameConfirmEvent>()
|
.add_event::<NewGameConfirmEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_event::<InfoToastEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.add_systems(Startup, init_slide_duration)
|
.add_systems(Startup, init_slide_duration)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -113,6 +114,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_new_game_confirm_toast,
|
handle_new_game_confirm_toast,
|
||||||
handle_info_toast,
|
handle_info_toast,
|
||||||
|
handle_xp_awarded_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.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(
|
fn tick_toasts(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
|||||||
@@ -80,3 +80,10 @@ pub struct NewGameConfirmEvent;
|
|||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Event, Debug, Clone)]
|
||||||
pub struct InfoToastEvent(pub String);
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
|||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
|
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
|
||||||
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use solitaire_data::{
|
|||||||
load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress,
|
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::game_plugin::GameMutation;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ impl Plugin for ProgressPlugin {
|
|||||||
app.insert_resource(ProgressResource(loaded))
|
app.insert_resource(ProgressResource(loaded))
|
||||||
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
|
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<LevelUpEvent>()
|
.add_event::<LevelUpEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -78,6 +79,7 @@ impl Plugin for ProgressPlugin {
|
|||||||
fn award_xp_on_win(
|
fn award_xp_on_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
mut levelups: EventWriter<LevelUpEvent>,
|
mut levelups: EventWriter<LevelUpEvent>,
|
||||||
|
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
@@ -86,6 +88,7 @@ fn award_xp_on_win(
|
|||||||
let used_undo = game.0.undo_count > 0;
|
let used_undo = game.0.undo_count > 0;
|
||||||
let amount = xp_for_win(ev.time_seconds, used_undo);
|
let amount = xp_for_win(ev.time_seconds, used_undo);
|
||||||
let prev_level = progress.0.add_xp(amount);
|
let prev_level = progress.0.add_xp(amount);
|
||||||
|
xp_awarded.send(XpAwardedEvent { amount });
|
||||||
if progress.0.leveled_up_from(prev_level) {
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
levelups.send(LevelUpEvent {
|
levelups.send(LevelUpEvent {
|
||||||
previous_level: prev_level,
|
previous_level: prev_level,
|
||||||
|
|||||||
@@ -107,14 +107,24 @@ pub async fn opt_out(
|
|||||||
///
|
///
|
||||||
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
||||||
/// leaderboard entry with the supplied display name.
|
/// 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(
|
pub async fn opt_in(
|
||||||
State(pool): State<SqlitePool>,
|
State(pool): State<SqlitePool>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
Json(body): Json<OptInRequest>,
|
Json(body): Json<OptInRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> 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()));
|
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.
|
// Mark the user as opted in.
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -134,7 +144,7 @@ pub async fn opt_in(
|
|||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
recorded_at = excluded.recorded_at"#,
|
recorded_at = excluded.recorded_at"#,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
body.display_name,
|
display_name,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
|
|||||||
Reference in New Issue
Block a user