use crate::{ db::Pool, error::AppResult, models::{ achievement::AchievementDefinition, card::OwnedCard, match_result::{Match, MatchRewardResult, SubmitMatchRequest}, objective::ObjectiveDefinition, }, services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics}, }; use rand::{seq::SliceRandom, Rng}; const FORMATIONS: &[&str] = &[ "4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2", ]; /// Generate a random AI opponent squad for Squad Battles. /// /// Difficulty bands: /// beginner — overall 55+ (same as "any") /// professional — overall 70+ /// world_class — overall 78+ /// legendary — overall 85+ /// ultimate — overall 90+ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value { let (min_overall, names): (u8, &[&str]) = match difficulty { "professional" => ( 70, &["Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC"], ), "world_class" => ( 78, &["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"], ), "legendary" => ( 85, &["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"], ), "ultimate" => ( 90, &["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"], ), _ => ( 55, &["Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI"], ), }; let mut rng = rand::thread_rng(); let all = card_db.by_min_overall(min_overall); // Fall back to lower overall band if not enough cards at this difficulty let pool: Vec<_> = if all.len() >= 11 { all } else { card_db.by_min_overall(55) }; let mut indices: Vec = (0..pool.len()).collect(); indices.shuffle(&mut rng); let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect(); let squad_rating = if cards.is_empty() { 0 } else { cards.iter().map(|c| c.overall as i64).sum::() / cards.len() as i64 }; let name = names[rng.gen_range(0..names.len())]; let formation = FORMATIONS[rng.gen_range(0..FORMATIONS.len())]; serde_json::json!({ "opponent_name": name, "difficulty": difficulty, "squad_rating": squad_rating, "formation": formation, "cards": cards, }) } const COINS_WIN: i64 = 400; const COINS_DRAW: i64 = 150; const COINS_LOSS: i64 = 75; const XP_WIN: i64 = 200; const XP_DRAW: i64 = 75; const XP_LOSS: i64 = 30; pub async fn process_match( pool: &Pool, profile_id: &str, club_id: &str, req: &SubmitMatchRequest, obj_defs: &[ObjectiveDefinition], ach_defs: &[AchievementDefinition], ) -> AppResult { if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 { return Err(crate::error::AppError::BadRequest( "goals_for and goals_against must each be between 0 and 99".into(), )); } let outcome = if req.goals_for > req.goals_against { "win" } else if req.goals_for == req.goals_against { "draw" } else { "loss" }; let (coins, xp) = match outcome { "win" => (COINS_WIN, XP_WIN), "draw" => (COINS_DRAW, XP_DRAW), _ => (COINS_LOSS, XP_LOSS), }; let match_record = Match::new( profile_id, &req.squad_id, &req.opponent_name, req.goals_for, req.goals_against, &req.mode, coins, xp, ); sqlx::query( "INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(&match_record.id) .bind(&match_record.profile_id) .bind(&match_record.squad_id) .bind(&match_record.opponent_name) .bind(match_record.goals_for) .bind(match_record.goals_against) .bind(&match_record.outcome) .bind(match_record.coins_awarded) .bind(match_record.xp_awarded) .bind(&match_record.mode) .bind(&match_record.played_at) .execute(pool) .await?; club::add_coins(pool, club_id, coins).await?; let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?; for ev in &level_ups { let body = if let Some(ref pack) = ev.pack_granted { format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted) } else { format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted) }; let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await; } statistics::record_match( pool, profile_id, outcome, req.goals_for, req.goals_against, coins, ) .await?; if let Some(positions) = &req.goal_positions { statistics::record_position_goals(pool, profile_id, positions).await?; } let mut objectives_updated = Vec::new(); let mut completed = objective::increment_metric(pool, profile_id, obj_defs, "matchesplayed", 1).await?; objectives_updated.append(&mut completed); if outcome == "win" { let mut c = objective::increment_metric(pool, profile_id, obj_defs, "matcheswon", 1).await?; objectives_updated.append(&mut c); } let mut c = objective::increment_metric(pool, profile_id, obj_defs, "goalsscored", req.goals_for) .await?; objectives_updated.append(&mut c); let mut c = objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?; objectives_updated.append(&mut c); for obj_id in &objectives_updated { let display_name = obj_defs .iter() .find(|d| &d.id == obj_id) .map(|d| d.title.as_str()) .unwrap_or(obj_id.as_str()); let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name); let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await; } // Decrement loan matches remaining for squad starters; collect expired loan IDs. let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?; for owned_id in &expired_loans { let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club."); let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await; } // Update season progress (creates the season row if it doesn't exist yet). season_svc::get_or_create(pool, profile_id).await?; let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?; if let Some(ref se) = season_end { use crate::models::season::SeasonResult; let direction = match se.result { SeasonResult::Promoted => "Promoted", SeasonResult::Relegated => "Relegated", SeasonResult::Maintained => "Maintained", }; let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default()); let _ = notification::create(pool, "season_end", "Season complete!", &body).await; } let achievements_unlocked = achievement::check_and_unlock(pool, ach_defs, profile_id, club_id) .await .unwrap_or_default(); Ok(MatchRewardResult { match_record, coins_awarded: coins, xp_awarded: xp, objectives_updated, expired_loans, season_end, level_ups, achievements_unlocked, }) } /// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI. /// Removes cards whose remaining count hits 0 and returns their owned_card_ids. async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult> { // Get starters (is_on_bench = 0) for this squad let starters: Vec<(String, String)> = sqlx::query_as( "SELECT sp.id, sp.owned_card_id FROM squad_players sp \ JOIN squads s ON s.id = sp.squad_id \ WHERE sp.squad_id = ? AND sp.is_on_bench = 0 AND s.club_id = ?", ) .bind(squad_id) .bind(club_id) .fetch_all(pool) .await?; let mut expired = Vec::new(); for (_sp_id, owned_id) in starters { let card = sqlx::query_as::<_, OwnedCard>( "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ FROM owned_cards WHERE id = ? AND is_loan = 1", ) .bind(&owned_id) .fetch_optional(pool) .await?; if let Some(c) = card { let remaining = c.loan_matches_remaining.unwrap_or(0); if remaining <= 1 { // Loan expired — remove from collection sqlx::query("DELETE FROM owned_cards WHERE id = ?") .bind(&owned_id) .execute(pool) .await?; expired.push(owned_id); } else { sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?") .bind(remaining - 1) .bind(&owned_id) .execute(pool) .await?; } } } Ok(expired) }