use crate::{ db::Pool, error::{AppError, AppResult}, models::{ card::CardDefinition, objective::ObjectiveDefinition, sbc::{SbcDefinition, SbcResult, SubmitSbcRequest}, }, services::{card_db::CardDb, club, objective, statistics}, }; use anyhow::Context; use std::path::Path; use uuid::Uuid; pub fn load_sbc_definitions(data_dir: &str) -> anyhow::Result> { let dir = Path::new(data_dir).join("sbcs"); let mut defs = Vec::new(); if !dir.exists() { return Ok(defs); } for entry in std::fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { let content = std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; let batch: Vec = serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; defs.extend(batch); } } Ok(defs) } pub async fn submit_sbc( pool: &Pool, card_db: &CardDb, sbc_defs: &[SbcDefinition], obj_defs: &[ObjectiveDefinition], profile_id: &str, club_id: &str, req: &SubmitSbcRequest, ) -> AppResult { let def = sbc_defs .iter() .find(|d| d.id == req.sbc_id) .ok_or_else(|| AppError::NotFound(format!("SBC {} not found", req.sbc_id)))?; // Resolve cards from DB let mut cards: Vec = Vec::new(); for owned_id in &req.owned_card_ids { let row = sqlx::query_as::<_, crate::models::card::OwnedCard>( "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?" ) .bind(owned_id) .bind(club_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound(format!("owned card {owned_id} not found")))?; let card = card_db.get(&row.card_id).ok_or_else(|| { AppError::NotFound(format!("card definition {} not found", row.card_id)) })?; cards.push(card.clone()); } let (passed, failures) = validate_sbc(def, &cards); if passed { // Consume cards for owned_id in &req.owned_card_ids { sqlx::query("DELETE FROM owned_cards WHERE id = ?") .bind(owned_id) .execute(pool) .await?; } // Record submission let sub_id = Uuid::new_v4().to_string(); let card_ids_json = serde_json::to_string(&req.owned_card_ids)?; sqlx::query( "INSERT INTO sbc_submissions (id, profile_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, 1, ?)" ) .bind(&sub_id) .bind(profile_id) .bind(&req.sbc_id) .bind(&card_ids_json) .bind(chrono::Utc::now().to_rfc3339()) .execute(pool) .await?; // Grant reward if def.reward.coins > 0 { club::add_coins(pool, club_id, def.reward.coins).await?; } if let Some(pack_id) = &def.reward.pack_id { crate::services::pack::grant_pack(pool, club_id, pack_id).await?; } statistics::increment_sbcs_completed(pool, profile_id).await?; objective::increment_metric(pool, profile_id, obj_defs, "sbcscompleted", 1).await?; Ok(SbcResult { passed: true, failures: vec![], reward: Some(def.reward.clone()), }) } else { Ok(SbcResult { passed: false, failures, reward: None, }) } } fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec) { let mut failures = Vec::new(); let req = &def.requirements; if cards.len() != req.squad_size as usize { failures.push(format!( "need exactly {} cards, got {}", req.squad_size, cards.len() )); return (false, failures); } if let Some(min) = req.min_overall { let avg = cards.iter().map(|c| c.overall as i64).sum::() / cards.len() as i64; if avg < min as i64 { failures.push(format!("average overall {avg} < required minimum {min}")); } } if let Some(max) = req.max_overall { if let Some(card) = cards.iter().find(|c| c.overall > max) { failures.push(format!( "'{}' has overall {} which exceeds maximum {max}", card.name, card.overall )); } } if !req.required_leagues.is_empty() { for league in &req.required_leagues { if !cards.iter().any(|c| &c.league == league) { failures.push(format!("need at least one player from league '{league}'")); } } } if !req.required_nations.is_empty() { for nation in &req.required_nations { if !cards.iter().any(|c| &c.nation == nation) { failures.push(format!("need at least one player from nation '{nation}'")); } } } if !req.required_clubs.is_empty() { for club in &req.required_clubs { if !cards.iter().any(|c| &c.club == club) { failures.push(format!("need at least one player from club '{club}'")); } } } if let Some(min_same_league) = req.min_players_from_same_league { let counts = count_by(cards, |c| c.league.as_str()); if counts.values().copied().max().unwrap_or(0) < min_same_league as usize { failures.push(format!( "need at least {min_same_league} players from the same league" )); } } if let Some(min_same_nation) = req.min_players_from_same_nation { let counts = count_by(cards, |c| c.nation.as_str()); if counts.values().copied().max().unwrap_or(0) < min_same_nation as usize { failures.push(format!( "need at least {min_same_nation} players from the same nation" )); } } if let Some(min_same_club) = req.min_players_from_same_club { let counts = count_by(cards, |c| c.club.as_str()); if counts.values().copied().max().unwrap_or(0) < min_same_club as usize { failures.push(format!( "need at least {min_same_club} players from the same club" )); } } (failures.is_empty(), failures) } fn count_by<'a, F>(cards: &'a [CardDefinition], key: F) -> std::collections::HashMap<&'a str, usize> where F: Fn(&'a CardDefinition) -> &'a str, { cards .iter() .fold(std::collections::HashMap::new(), |mut m, c| { *m.entry(key(c)).or_default() += 1; m }) }