use rand::seq::SliceRandom; use serde_json::json; use uuid::Uuid; use crate::{ db::Pool, error::{AppError, AppResult}, models::draft::{DraftSession, DRAFT_PICK_ORDER}, services::{card_db::CardDb, pack}, }; const CANDIDATES_PER_SLOT: usize = 5; fn difficulty_min_overall(difficulty: &str) -> u8 { match difficulty { "world_class" => 78, "legendary" => 83, "ultimate" => 88, "professional" => 70, _ => 55, } } /// Pick up to N candidates for a given position slot. /// Tries exact position match first, fills remainder from the general pool. fn pick_candidates(card_db: &CardDb, position: &str, min_overall: u8, n: usize) -> Vec { let mut rng = rand::thread_rng(); let all = card_db.by_min_overall(min_overall); // CDM / CAM → also accept CM as fallback let pos_match = |p: &str| -> bool { p.eq_ignore_ascii_case(position) || matches!( (position.to_uppercase().as_str(), p.to_uppercase().as_str()), ("CDM", "CM") | ("CAM", "CM") | ("CM", "CDM") | ("CM", "CAM") ) }; let mut exact: Vec<_> = all.iter().filter(|c| pos_match(&c.position)).collect(); exact.shuffle(&mut rng); let mut pool: Vec<_> = all.iter().filter(|c| !pos_match(&c.position)).collect(); pool.shuffle(&mut rng); exact .into_iter() .chain(pool) .take(n) .map(|c| c.id.clone()) .collect() } /// Compute reward tier based on average overall of picked cards. fn compute_reward(card_db: &CardDb, picks: &[String]) -> (i64, Option, i64) { if picks.is_empty() { return (0, None, 0); } let overalls: Vec = picks .iter() .filter_map(|id| card_db.get(id).map(|c| c.overall as i64)) .collect(); let avg = if overalls.is_empty() { 0 } else { overalls.iter().sum::() / overalls.len() as i64 }; let (coins, pack_id) = if avg >= 84 { (2000, Some("gold_pack".to_string())) } else if avg >= 78 { (1000, Some("silver_pack".to_string())) } else { (400, None) }; (coins, pack_id, avg) } pub async fn start_draft( pool: &Pool, card_db: &CardDb, profile_id: &str, difficulty: &str, ) -> AppResult { let min_overall = difficulty_min_overall(difficulty); let pick_order: Vec = DRAFT_PICK_ORDER.iter().map(|s| s.to_string()).collect(); let first_position = &pick_order[0]; let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT); let session = DraftSession { id: Uuid::new_v4().to_string(), profile_id: profile_id.to_string(), difficulty: difficulty.to_string(), pick_order: serde_json::to_string(&pick_order).unwrap(), picks: "[]".to_string(), current_candidates: Some(serde_json::to_string(&candidates).unwrap()), status: "active".to_string(), reward_coins: 0, reward_pack_id: None, squad_rating: 0, started_at: chrono::Utc::now().to_rfc3339(), completed_at: None, }; sqlx::query( "INSERT INTO draft_sessions (id, profile_id, difficulty, pick_order, picks, \ current_candidates, status, reward_coins, reward_pack_id, squad_rating, \ started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&session.id) .bind(&session.profile_id) .bind(&session.difficulty) .bind(&session.pick_order) .bind(&session.picks) .bind(&session.current_candidates) .bind(&session.status) .bind(session.reward_coins) .bind(&session.reward_pack_id) .bind(session.squad_rating) .bind(&session.started_at) .bind(&session.completed_at) .execute(pool) .await?; Ok(render_session(&session, card_db)) } pub async fn get_draft( pool: &Pool, card_db: &CardDb, profile_id: &str, session_id: &str, ) -> AppResult { let session = fetch_session(pool, profile_id, session_id).await?; Ok(render_session(&session, card_db)) } pub async fn pick_card( pool: &Pool, card_db: &CardDb, club_id: &str, profile_id: &str, session_id: &str, card_id: &str, ) -> AppResult { let mut session = fetch_session(pool, profile_id, session_id).await?; if session.status != "active" { return Err(AppError::BadRequest(format!( "draft session is '{}'", session.status ))); } // Validate the picked card is in the current candidates list let candidates: Vec = session .current_candidates .as_deref() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); if !candidates.contains(&card_id.to_string()) { return Err(AppError::BadRequest( "card_id is not among the current candidates".into(), )); } let pick_order: Vec = serde_json::from_str(&session.pick_order) .map_err(|e| AppError::Internal(anyhow::anyhow!("bad pick_order json: {e}")))?; let mut picks: Vec = serde_json::from_str(&session.picks) .map_err(|e| AppError::Internal(anyhow::anyhow!("bad picks json: {e}")))?; picks.push(card_id.to_string()); let next_index = picks.len(); let all_filled = next_index >= pick_order.len(); let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) = if all_filled { let (coins, pack, avg) = compute_reward(card_db, &picks); (None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339())) } else { let min_overall = difficulty_min_overall(&session.difficulty); let next_pos = &pick_order[next_index]; let next_candidates = pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT); ( Some(serde_json::to_string(&next_candidates).unwrap()), "active".to_string(), 0, None, 0, None, ) }; let picks_json = serde_json::to_string(&picks).unwrap(); sqlx::query( "UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \ reward_coins = ?, reward_pack_id = ?, squad_rating = ?, completed_at = ? WHERE id = ?", ) .bind(&picks_json) .bind(&new_candidates) .bind(&new_status) .bind(reward_coins) .bind(&reward_pack_id) .bind(squad_rating) .bind(&completed_at) .bind(session_id) .execute(pool) .await?; session.picks = picks_json; session.current_candidates = new_candidates; session.status = new_status.clone(); session.reward_coins = reward_coins; session.reward_pack_id = reward_pack_id.clone(); session.squad_rating = squad_rating; session.completed_at = completed_at; // Grant reward when draft is completed if new_status == "completed" { if reward_coins > 0 { crate::services::club::add_coins(pool, club_id, reward_coins).await?; } if let Some(ref pack_def_id) = reward_pack_id { pack::grant_pack(pool, club_id, pack_def_id).await?; } tracing::info!( session_id, profile_id, reward_coins, squad_rating, "draft completed" ); } let mut rendered = render_session(&session, card_db); rendered["just_completed"] = json!(new_status == "completed"); if new_status == "completed" { rendered["reward"] = json!({ "coins": reward_coins, "pack_id": reward_pack_id, "squad_rating": squad_rating, }); } Ok(rendered) } pub async fn abandon_draft( pool: &Pool, profile_id: &str, session_id: &str, ) -> AppResult { let session = fetch_session(pool, profile_id, session_id).await?; if session.status == "completed" { return Err(AppError::BadRequest( "cannot abandon a completed draft".into(), )); } sqlx::query("UPDATE draft_sessions SET status = 'abandoned' WHERE id = ?") .bind(session_id) .execute(pool) .await?; Ok(json!({ "abandoned": session_id })) } async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppResult { sqlx::query_as::<_, DraftSession>( "SELECT id, profile_id, difficulty, pick_order, picks, current_candidates, \ status, reward_coins, reward_pack_id, squad_rating, started_at, completed_at \ FROM draft_sessions WHERE id = ? AND profile_id = ?", ) .bind(session_id) .bind(profile_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound(format!("draft session '{session_id}' not found"))) } fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value { let pick_order: Vec = serde_json::from_str(&session.pick_order).unwrap_or_default(); let picks: Vec = serde_json::from_str(&session.picks).unwrap_or_default(); let candidates: Vec = session .current_candidates .as_deref() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); let picks_with_defs: Vec = picks .iter() .enumerate() .map(|(i, card_id)| { json!({ "slot": i, "position": pick_order.get(i).cloned().unwrap_or_default(), "card_id": card_id, "card": card_db.get(card_id), }) }) .collect(); let current_position = pick_order.get(picks.len()).cloned(); let candidates_with_defs: Vec = candidates .iter() .map(|id| json!({ "card_id": id, "card": card_db.get(id) })) .collect(); json!({ "session_id": session.id, "difficulty": session.difficulty, "status": session.status, "progress": { "filled": picks.len(), "total": pick_order.len() }, "current_position": current_position, "candidates": candidates_with_defs, "picks": picks_with_defs, "squad_rating": session.squad_rating, "started_at": session.started_at, "completed_at": session.completed_at, }) }