use uuid::Uuid; use crate::{ db::Pool, error::{AppError, AppResult}, models::fut_champs::{ChampsTier, FutChampsSession, MAX_MATCHES}, models::pack::PackDefinition, services::{club as club_svc, pack as pack_svc}, }; const SESSION_SELECT: &str = "SELECT id, profile_id, week_number, matches_played, wins, draws, losses, \ status, reward_tier, reward_coins, reward_pack_id, reward_claimed, \ started_at, completed_at FROM fut_champs_sessions"; pub async fn get_active_session( pool: &Pool, profile_id: &str, ) -> AppResult> { sqlx::query_as::<_, FutChampsSession>(&format!( "{SESSION_SELECT} WHERE profile_id = ? AND status = 'active' ORDER BY started_at DESC LIMIT 1" )) .bind(profile_id) .fetch_optional(pool) .await .map_err(Into::into) } pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult { sqlx::query_as::<_, FutChampsSession>(&format!( "{SESSION_SELECT} WHERE id = ? AND profile_id = ?" )) .bind(session_id) .bind(profile_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound("FUT Champions session not found".into())) } pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult> { sqlx::query_as::<_, FutChampsSession>(&format!( "{SESSION_SELECT} WHERE profile_id = ? ORDER BY started_at DESC LIMIT 20" )) .bind(profile_id) .fetch_all(pool) .await .map_err(Into::into) } pub async fn start_session(pool: &Pool, profile_id: &str) -> AppResult { // Only one active session at a time if let Some(existing) = get_active_session(pool, profile_id).await? { return Err(AppError::Conflict(format!( "active FUT Champions session already exists: {}", existing.id ))); } // Determine next week number from history let week_number: i64 = sqlx::query_scalar( "SELECT COALESCE(MAX(week_number), 0) + 1 FROM fut_champs_sessions WHERE profile_id = ?", ) .bind(profile_id) .fetch_one(pool) .await?; let id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); sqlx::query( "INSERT INTO fut_champs_sessions \ (id, profile_id, week_number, matches_played, wins, draws, losses, \ status, reward_claimed, started_at) \ VALUES (?, ?, ?, 0, 0, 0, 0, 'active', 0, ?)", ) .bind(&id) .bind(profile_id) .bind(week_number) .bind(&now) .execute(pool) .await?; get_session(pool, &id, profile_id).await } /// Record a FUT Champions match result. Auto-completes the session at 30 matches. pub async fn record_match( pool: &Pool, profile_id: &str, session_id: &str, goals_for: i64, goals_against: i64, ) -> AppResult { let session = get_session(pool, session_id, profile_id).await?; if session.is_complete() { return Err(AppError::BadRequest( "this FUT Champions session is already complete".into(), )); } let (win_delta, draw_delta, loss_delta) = if goals_for > goals_against { (1i64, 0i64, 0i64) } else if goals_for == goals_against { (0, 1, 0) } else { (0, 0, 1) }; sqlx::query( "UPDATE fut_champs_sessions \ SET matches_played = matches_played + 1, \ wins = wins + ?, \ draws = draws + ?, \ losses = losses + ? \ WHERE id = ?", ) .bind(win_delta) .bind(draw_delta) .bind(loss_delta) .bind(session_id) .execute(pool) .await?; let updated = get_session(pool, session_id, profile_id).await?; if updated.matches_played >= MAX_MATCHES { complete_session(pool, &updated).await?; get_session(pool, session_id, profile_id).await } else { Ok(updated) } } /// Compute tier and write reward fields; mark session completed. async fn complete_session(pool: &Pool, session: &FutChampsSession) -> AppResult<()> { let tier = ChampsTier::from_wins(session.wins); let now = chrono::Utc::now().to_rfc3339(); sqlx::query( "UPDATE fut_champs_sessions \ SET status = 'completed', completed_at = ?, \ reward_tier = ?, reward_coins = ?, reward_pack_id = ? \ WHERE id = ?", ) .bind(&now) .bind(tier.label()) .bind(tier.reward_coins()) .bind(tier.reward_pack()) .bind(&session.id) .execute(pool) .await?; Ok(()) } /// Claim the rewards for a completed session. Idempotent — double-claim returns an error. pub async fn claim_rewards( pool: &Pool, profile_id: &str, session_id: &str, club_id: &str, pack_defs: &[PackDefinition], ) -> AppResult { let session = get_session(pool, session_id, profile_id).await?; if !session.is_complete() { return Err(AppError::BadRequest( "session is still active — play all 30 matches first".into(), )); } if session.reward_claimed { return Err(AppError::Conflict("rewards already claimed".into())); } let coins = session.reward_coins.unwrap_or(0); let new_balance = club_svc::add_coins(pool, club_id, coins).await?; let pack = if let Some(ref pack_id) = session.reward_pack_id { // Only grant if the pack definition exists; ignore unknown packs gracefully if pack_defs.iter().any(|d| &d.id == pack_id) { let granted = pack_svc::grant_pack(pool, club_id, pack_id).await?; Some(granted.id) } else { None } } else { None }; sqlx::query("UPDATE fut_champs_sessions SET reward_claimed = 1 WHERE id = ?") .bind(session_id) .execute(pool) .await?; Ok(serde_json::json!({ "session_id": session_id, "tier": session.reward_tier, "coins_awarded": coins, "new_balance": new_balance, "pack_granted": pack, })) } // ── Division Rivals weekly reward ───────────────────────────────────────────── /// Rivals points awarded per Division Rivals match outcome. pub fn rivals_points(goals_for: i64, goals_against: i64) -> i64 { if goals_for > goals_against { 25 } else if goals_for == goals_against { 10 } else { 5 } } /// Weekly coin reward for Division Rivals, scaling by division. pub fn rivals_weekly_coins(division: i64) -> i64 { match division { 1 => 10_000, 2 => 7_500, 3 => 5_000, 4 => 3_500, 5 => 2_500, 6 | 7 => 1_500, _ => 1_000, } } /// Claim the weekly Division Rivals reward. /// /// "Week" is tracked by a monotonic counter — since this is offline, /// the player claims whenever they feel like ending their rivals week. pub async fn claim_rivals_reward( pool: &Pool, profile_id: &str, club_id: &str, pack_defs: &[PackDefinition], ) -> AppResult { // Fetch current season row (must exist) let row: Option<(i64, i64, i64)> = sqlx::query_as( "SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?", ) .bind(profile_id) .fetch_optional(pool) .await?; let (division, week_claimed, total_pts) = row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?; let next_week = week_claimed + 1; let coins = rivals_weekly_coins(division); let new_balance = club_svc::add_coins(pool, club_id, coins).await?; // Division 1–3 get a silver pack; Division 4–5 get a bronze pack let pack_id = match division { 1..=3 => Some("silver_pack"), 4..=5 => Some("bronze_pack"), _ => None, }; let pack = if let Some(pid) = pack_id { if pack_defs.iter().any(|d| d.id == pid) { let granted = pack_svc::grant_pack(pool, club_id, pid).await?; Some(granted.id) } else { None } } else { None }; sqlx::query( "UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \ WHERE profile_id = ?", ) .bind(next_week) .bind(profile_id) .execute(pool) .await?; Ok(serde_json::json!({ "week_number": next_week, "division": division, "rivals_total_points": total_pts + 100, "coins_awarded": coins, "new_balance": new_balance, "pack_granted": pack, })) }