108 lines
3.5 KiB
Rust
108 lines
3.5 KiB
Rust
use crate::{
|
|
db::Pool,
|
|
error::{AppError, AppResult},
|
|
models::profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent, Profile},
|
|
};
|
|
use chrono::Utc;
|
|
|
|
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
|
|
/// one profile row per `game_id`, so we take the earliest for that game.
|
|
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
|
|
sqlx::query_as::<_, Profile>(
|
|
"SELECT id, username, level, xp, game_id, created_at, updated_at \
|
|
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
)
|
|
.bind(game_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
|
}
|
|
|
|
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
|
|
// Single-player per game: one profile per game_id, not one globally.
|
|
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
|
.bind(game_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
if existing > 0 {
|
|
return Err(AppError::Conflict(
|
|
"a profile already exists for this game; OpenFUT is single-player per game".into(),
|
|
));
|
|
}
|
|
|
|
let profile = Profile::new(username, game_id);
|
|
sqlx::query(
|
|
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&profile.id)
|
|
.bind(&profile.username)
|
|
.bind(profile.level)
|
|
.bind(profile.xp)
|
|
.bind(&profile.game_id)
|
|
.bind(profile.created_at)
|
|
.bind(profile.updated_at)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(profile)
|
|
}
|
|
|
|
pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> {
|
|
let now = Utc::now();
|
|
sqlx::query("UPDATE profiles SET xp = xp + ?, updated_at = ? WHERE id = ?")
|
|
.bind(xp)
|
|
.bind(now)
|
|
.bind(profile_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Add XP, check for level-ups, grant rewards, and return every level gained.
|
|
/// Callers should use this instead of `add_xp` when level-up feedback matters.
|
|
pub async fn add_xp_with_levelup(
|
|
pool: &Pool,
|
|
profile_id: &str,
|
|
club_id: &str,
|
|
xp_to_add: i64,
|
|
) -> AppResult<Vec<LevelUpEvent>> {
|
|
let profile = sqlx::query_as::<_, Profile>(
|
|
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
|
|
)
|
|
.bind(profile_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
|
let old_level = level_for_xp(profile.xp);
|
|
let new_total_xp = profile.xp + xp_to_add;
|
|
let new_level = level_for_xp(new_total_xp);
|
|
|
|
let now = Utc::now();
|
|
sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?")
|
|
.bind(new_total_xp)
|
|
.bind(new_level)
|
|
.bind(now)
|
|
.bind(profile_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
let mut events = Vec::new();
|
|
for lvl in (old_level + 1)..=new_level {
|
|
let coins = coins_for_level(lvl);
|
|
let pack = pack_for_level(lvl).map(String::from);
|
|
|
|
if coins > 0 {
|
|
crate::services::club::add_coins(pool, club_id, coins).await?;
|
|
}
|
|
if let Some(ref pack_id) = pack {
|
|
crate::services::pack::grant_pack(pool, club_id, pack_id).await?;
|
|
}
|
|
|
|
tracing::info!(profile_id, new_level = lvl, coins, "level up");
|
|
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
|
|
}
|
|
|
|
Ok(events)
|
|
}
|