diff --git a/data/cards/bundesliga.json b/data/cards/bundesliga.json new file mode 100644 index 0000000..f2e24f8 --- /dev/null +++ b/data/cards/bundesliga.json @@ -0,0 +1,172 @@ +[ + { + "id": "card_bl_001", + "name": "Klaus Berger", + "overall": 84, + "position": "ST", + "nation": "Germany", + "league": "Bundesliga", + "club": "Röder FC", + "pace": 82, + "shooting": 87, + "passing": 72, + "dribbling": 80, + "defending": 36, + "physical": 84, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_002", + "name": "Felix Hartmann", + "overall": 83, + "position": "CM", + "nation": "Germany", + "league": "Bundesliga", + "club": "Röder FC", + "pace": 74, + "shooting": 74, + "passing": 86, + "dribbling": 80, + "defending": 72, + "physical": 76, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_003", + "name": "Matteo Ricci", + "overall": 82, + "position": "CAM", + "nation": "Italy", + "league": "Bundesliga", + "club": "Röder FC", + "pace": 76, + "shooting": 80, + "passing": 85, + "dribbling": 84, + "defending": 42, + "physical": 66, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_004", + "name": "Jan Schreiber", + "overall": 81, + "position": "CB", + "nation": "Germany", + "league": "Bundesliga", + "club": "Röder FC", + "pace": 74, + "shooting": 40, + "passing": 66, + "dribbling": 56, + "defending": 85, + "physical": 87, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_005", + "name": "Lucas Fonseca", + "overall": 83, + "position": "LW", + "nation": "Brazil", + "league": "Bundesliga", + "club": "Blau Stern München", + "pace": 90, + "shooting": 78, + "passing": 77, + "dribbling": 88, + "defending": 32, + "physical": 66, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_006", + "name": "Stefan Wolf", + "overall": 82, + "position": "CDM", + "nation": "Germany", + "league": "Bundesliga", + "club": "Blau Stern München", + "pace": 72, + "shooting": 64, + "passing": 78, + "dribbling": 74, + "defending": 85, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_007", + "name": "Marco Bauer", + "overall": 80, + "position": "RB", + "nation": "Germany", + "league": "Bundesliga", + "club": "Blau Stern München", + "pace": 82, + "shooting": 54, + "passing": 74, + "dribbling": 74, + "defending": 80, + "physical": 76, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_008", + "name": "Henri Delacroix", + "overall": 84, + "position": "ST", + "nation": "France", + "league": "Bundesliga", + "club": "Rhein United", + "pace": 86, + "shooting": 87, + "passing": 73, + "dribbling": 82, + "defending": 38, + "physical": 80, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_009", + "name": "Tobias Kohl", + "overall": 80, + "position": "GK", + "nation": "Germany", + "league": "Bundesliga", + "club": "Rhein United", + "pace": 56, + "shooting": 16, + "passing": 60, + "dribbling": 30, + "defending": 82, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_bl_010", + "name": "Nicolas Braun", + "overall": 81, + "position": "LB", + "nation": "Germany", + "league": "Bundesliga", + "club": "Rhein United", + "pace": 78, + "shooting": 52, + "passing": 74, + "dribbling": 72, + "defending": 81, + "physical": 78, + "rarity": "gold", + "image_path": null + } +] diff --git a/data/cards/la_liga.json b/data/cards/la_liga.json new file mode 100644 index 0000000..62742b0 --- /dev/null +++ b/data/cards/la_liga.json @@ -0,0 +1,206 @@ +[ + { + "id": "card_ll_001", + "name": "Carlos Mendez", + "overall": 85, + "position": "ST", + "nation": "Spain", + "league": "La Liga", + "club": "Atletico Fuego", + "pace": 86, + "shooting": 89, + "passing": 72, + "dribbling": 84, + "defending": 36, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_002", + "name": "Pablo Serrano", + "overall": 83, + "position": "CAM", + "nation": "Spain", + "league": "La Liga", + "club": "Atletico Fuego", + "pace": 78, + "shooting": 80, + "passing": 86, + "dribbling": 86, + "defending": 46, + "physical": 70, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_003", + "name": "Rodrigo Vidal", + "overall": 82, + "position": "CM", + "nation": "Spain", + "league": "La Liga", + "club": "Atletico Fuego", + "pace": 74, + "shooting": 70, + "passing": 84, + "dribbling": 80, + "defending": 72, + "physical": 74, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_004", + "name": "Andrés Torres", + "overall": 81, + "position": "CB", + "nation": "Spain", + "league": "La Liga", + "club": "Atletico Fuego", + "pace": 73, + "shooting": 38, + "passing": 68, + "dribbling": 58, + "defending": 84, + "physical": 86, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_005", + "name": "Gabriel Costa", + "overall": 84, + "position": "LW", + "nation": "Brazil", + "league": "La Liga", + "club": "Real Viento", + "pace": 92, + "shooting": 80, + "passing": 78, + "dribbling": 90, + "defending": 32, + "physical": 64, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_006", + "name": "Javier Ruiz", + "overall": 83, + "position": "CDM", + "nation": "Spain", + "league": "La Liga", + "club": "Real Viento", + "pace": 72, + "shooting": 62, + "passing": 80, + "dribbling": 76, + "defending": 86, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_007", + "name": "Miguel Ángel Prieto", + "overall": 80, + "position": "RB", + "nation": "Spain", + "league": "La Liga", + "club": "Real Viento", + "pace": 80, + "shooting": 52, + "passing": 76, + "dribbling": 74, + "defending": 80, + "physical": 74, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_008", + "name": "Diego Reyes", + "overall": 82, + "position": "ST", + "nation": "Argentina", + "league": "La Liga", + "club": "Valencia Azul", + "pace": 85, + "shooting": 84, + "passing": 70, + "dribbling": 82, + "defending": 36, + "physical": 78, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_009", + "name": "Marc Gutiérrez", + "overall": 80, + "position": "GK", + "nation": "Spain", + "league": "La Liga", + "club": "Valencia Azul", + "pace": 54, + "shooting": 14, + "passing": 62, + "dribbling": 28, + "defending": 81, + "physical": 80, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_010", + "name": "Emilio Navarro", + "overall": 81, + "position": "LB", + "nation": "Spain", + "league": "La Liga", + "club": "Valencia Azul", + "pace": 79, + "shooting": 52, + "passing": 74, + "dribbling": 72, + "defending": 82, + "physical": 76, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_011", + "name": "Thiago Drummond", + "overall": 83, + "position": "CB", + "nation": "Brazil", + "league": "La Liga", + "club": "Atletico Fuego", + "pace": 76, + "shooting": 38, + "passing": 68, + "dribbling": 62, + "defending": 86, + "physical": 88, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_ll_012", + "name": "Fran Molina", + "overall": 82, + "position": "RW", + "nation": "Spain", + "league": "La Liga", + "club": "Real Viento", + "pace": 87, + "shooting": 76, + "passing": 76, + "dribbling": 85, + "defending": 34, + "physical": 66, + "rarity": "gold", + "image_path": null + } +] diff --git a/data/cards/premier_league.json b/data/cards/premier_league.json new file mode 100644 index 0000000..d078e50 --- /dev/null +++ b/data/cards/premier_league.json @@ -0,0 +1,206 @@ +[ + { + "id": "card_pl_001", + "name": "James Caldwell", + "overall": 84, + "position": "ST", + "nation": "England", + "league": "Premier League", + "club": "Northgate United", + "pace": 84, + "shooting": 87, + "passing": 70, + "dribbling": 80, + "defending": 36, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_002", + "name": "Danny Walsh", + "overall": 82, + "position": "CM", + "nation": "England", + "league": "Premier League", + "club": "Northgate United", + "pace": 74, + "shooting": 72, + "passing": 84, + "dribbling": 79, + "defending": 70, + "physical": 74, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_003", + "name": "Reece Okafor", + "overall": 83, + "position": "LW", + "nation": "England", + "league": "Premier League", + "club": "Northgate United", + "pace": 91, + "shooting": 78, + "passing": 75, + "dribbling": 88, + "defending": 34, + "physical": 68, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_004", + "name": "Tom Briggs", + "overall": 80, + "position": "CB", + "nation": "England", + "league": "Premier League", + "club": "Northgate United", + "pace": 72, + "shooting": 36, + "passing": 62, + "dribbling": 52, + "defending": 83, + "physical": 86, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_005", + "name": "Pierre-Marc Dubois", + "overall": 84, + "position": "CAM", + "nation": "France", + "league": "Premier League", + "club": "Southfield City", + "pace": 79, + "shooting": 80, + "passing": 87, + "dribbling": 85, + "defending": 44, + "physical": 70, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_006", + "name": "Kwame Asante", + "overall": 85, + "position": "ST", + "nation": "Ghana", + "league": "Premier League", + "club": "Southfield City", + "pace": 88, + "shooting": 88, + "passing": 70, + "dribbling": 84, + "defending": 38, + "physical": 86, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_007", + "name": "Liam Foster", + "overall": 81, + "position": "CDM", + "nation": "England", + "league": "Premier League", + "club": "Southfield City", + "pace": 74, + "shooting": 62, + "passing": 78, + "dribbling": 74, + "defending": 84, + "physical": 80, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_008", + "name": "Sergio Rojas", + "overall": 83, + "position": "RW", + "nation": "Argentina", + "league": "Premier League", + "club": "Westbrook FC", + "pace": 89, + "shooting": 80, + "passing": 78, + "dribbling": 90, + "defending": 30, + "physical": 62, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_009", + "name": "Ben Archer", + "overall": 79, + "position": "GK", + "nation": "England", + "league": "Premier League", + "club": "Westbrook FC", + "pace": 52, + "shooting": 14, + "passing": 62, + "dribbling": 28, + "defending": 80, + "physical": 80, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_010", + "name": "Nathan Cross", + "overall": 80, + "position": "RB", + "nation": "England", + "league": "Premier League", + "club": "Westbrook FC", + "pace": 81, + "shooting": 50, + "passing": 74, + "dribbling": 72, + "defending": 80, + "physical": 76, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_011", + "name": "Marco Silva Jr.", + "overall": 83, + "position": "CB", + "nation": "Brazil", + "league": "Premier League", + "club": "Westbrook FC", + "pace": 77, + "shooting": 40, + "passing": 66, + "dribbling": 60, + "defending": 86, + "physical": 88, + "rarity": "gold", + "image_path": null + }, + { + "id": "card_pl_012", + "name": "Erik Strand", + "overall": 82, + "position": "LB", + "nation": "Norway", + "league": "Premier League", + "club": "Northgate United", + "pace": 80, + "shooting": 54, + "passing": 76, + "dribbling": 74, + "defending": 82, + "physical": 78, + "rarity": "gold", + "image_path": null + } +] diff --git a/src/routes/cards.rs b/src/routes/cards.rs index 8921964..41553e8 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -16,6 +16,12 @@ use crate::{ pub struct CardQuery { pub rarity: Option, pub position: Option, + pub nation: Option, + pub league: Option, + pub club: Option, + pub min_overall: Option, + pub max_overall: Option, + pub limit: Option, } pub async fn get_card( @@ -33,25 +39,49 @@ pub async fn get_cards( State(state): State, Query(query): Query, ) -> AppResult> { - let cards = state + let mut cards: Vec<_> = state .card_db .all() .into_iter() .filter(|c| { - query + let rarity_ok = query .rarity .as_ref() .map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) - .unwrap_or(true) - && query - .position - .as_ref() - .map(|p| c.position.to_lowercase() == p.to_lowercase()) - .unwrap_or(true) + .unwrap_or(true); + let pos_ok = query + .position + .as_ref() + .map(|p| c.position.eq_ignore_ascii_case(p)) + .unwrap_or(true); + let nation_ok = query + .nation + .as_ref() + .map(|n| c.nation.eq_ignore_ascii_case(n)) + .unwrap_or(true); + let league_ok = query + .league + .as_ref() + .map(|l| c.league.eq_ignore_ascii_case(l)) + .unwrap_or(true); + let club_ok = query + .club + .as_ref() + .map(|cl| c.club.eq_ignore_ascii_case(cl)) + .unwrap_or(true); + let min_ok = query.min_overall.map(|m| c.overall >= m).unwrap_or(true); + let max_ok = query.max_overall.map(|m| c.overall <= m).unwrap_or(true); + rarity_ok && pos_ok && nation_ok && league_ok && club_ok && min_ok && max_ok }) - .collect::>(); + .collect(); - Ok(Json(json!({ "cards": cards, "total": cards.len() }))) + cards.sort_by_key(|c| std::cmp::Reverse(c.overall)); + let total = cards.len(); + if let Some(limit) = query.limit { + cards.truncate(limit); + } + + Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() }))) } pub async fn get_collection(State(state): State) -> AppResult> { diff --git a/src/services/match_service.rs b/src/services/match_service.rs index d9f809f..74317bb 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -9,73 +9,70 @@ use crate::{ }; 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", - ], + &["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", - ], + &["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"], ), "legendary" => ( 85, - &[ - "Legends United", - "Ultimate XI", - "Gold Standard FC", - "The Icons", - "Heritage FC", - ], + &["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"], + ), + "ultimate" => ( + 90, + &["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"], ), _ => ( - 58, - &[ - "Amateur Town FC", - "Sunday League XI", - "Park FC", - "Village Stars", - "Reserve 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); - let mut indices: Vec = (0..all.len()).collect(); + + // 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| all[i].clone()) - .collect(); + 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": "4-4-2", + "formation": formation, "cards": cards, }) } diff --git a/src/services/squad.rs b/src/services/squad.rs index 904aaae..97ca992 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -105,6 +105,15 @@ pub async fn validate_formation( Ok(()) } +/// Chemistry v2: FUT-style link scoring. +/// +/// Each player earns up to 10 chemistry from three link types: +/// - Club links: strongest signal — +3 per shared club teammate (max 6 pts) +/// - League links: medium signal — +1 per shared league mate (max 4 pts) +/// - Nation links: weakest signal — +1 per shared nation mate (max 3 pts) +/// +/// Individual player chemistry is capped at 10. +/// Team chemistry = sum of all player chemistries, capped at 100. pub async fn calculate_chemistry( pool: &Pool, card_db: &CardDb, @@ -112,10 +121,12 @@ pub async fn calculate_chemistry( ) -> AppResult { let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect(); - let mut cards: Vec = Vec::new(); + // Load all starter card definitions (N separate queries, fine for 11 players) + let mut player_cards: Vec<(String, CardDefinition)> = Vec::new(); for sp in &starters { let owned = sqlx::query_as::<_, OwnedCard>( - "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?", + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ + FROM owned_cards WHERE id = ?", ) .bind(&sp.owned_card_id) .fetch_optional(pool) @@ -123,43 +134,60 @@ pub async fn calculate_chemistry( if let Some(o) = owned { if let Some(card) = card_db.get(&o.card_id) { - cards.push(card.clone()); + player_cards.push((sp.owned_card_id.clone(), card.clone())); } } } - let player_chems: Vec = cards - .iter() - .enumerate() - .map(|(i, c)| { - let same_club = cards - .iter() - .enumerate() - .filter(|(j, o)| *j != i && o.club == c.club) - .count() - .min(4) as i64; - let same_league = cards - .iter() - .enumerate() - .filter(|(j, o)| *j != i && o.league == c.league) - .count() - .min(3) as i64; - let same_nation = cards - .iter() - .enumerate() - .filter(|(j, o)| *j != i && o.nation == c.nation) - .count() - .min(3) as i64; - (same_club + same_league + same_nation).min(10) - }) - .collect(); + let mut player_chemistries = Vec::with_capacity(player_cards.len()); + let mut total_chem: i64 = 0; - let total: i64 = player_chems.iter().sum::().min(100); + for (i, (owned_id, card)) in player_cards.iter().enumerate() { + let club_links = player_cards + .iter() + .enumerate() + .filter(|(j, (_, c))| *j != i && c.club == card.club) + .count() as i64; + let league_links = player_cards + .iter() + .enumerate() + .filter(|(j, (_, c))| *j != i && c.league == card.league) + .count() as i64; + let nation_links = player_cards + .iter() + .enumerate() + .filter(|(j, (_, c))| *j != i && c.nation == card.nation) + .count() as i64; + + let club_pts = (club_links * 3).min(6); + let league_pts = league_links.min(4); + let nation_pts = nation_links.min(3); + let player_chem = (club_pts + league_pts + nation_pts).min(10); + + total_chem += player_chem; + player_chemistries.push(serde_json::json!({ + "owned_card_id": owned_id, + "card_id": &card.id, + "name": &card.name, + "chemistry": player_chem, + "breakdown": { + "club_links": club_links, + "league_links": league_links, + "nation_links": nation_links, + "club_pts": club_pts, + "league_pts": league_pts, + "nation_pts": nation_pts, + } + })); + } + + let team_chemistry = total_chem.min(100); Ok(serde_json::json!({ - "total": total, + "total": team_chemistry, "max": 100, - "player_chemistries": player_chems, + "player_count": player_chemistries.len(), + "player_chemistries": player_chemistries, })) } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index f0bb33b..2de1e2a 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -482,3 +482,154 @@ async fn test_event_market_refresh_injects_totw_cards() { "expected at least one [EVENT] market listing" ); } + +// ── Phase 7: Extended card search & chemistry ──────────────────────────────── + +#[tokio::test] +async fn test_cards_filter_by_league() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/cards?league=Premier+League").await; + assert_eq!(status, StatusCode::OK); + let cards = json["cards"].as_array().expect("cards array"); + assert!(!cards.is_empty(), "Premier League cards should be loaded"); + for card in cards { + assert_eq!( + card["league"].as_str().unwrap().to_lowercase(), + "premier league" + ); + } +} + +#[tokio::test] +async fn test_cards_filter_by_nation() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/cards?nation=Spain").await; + assert_eq!(status, StatusCode::OK); + let cards = json["cards"].as_array().expect("cards array"); + assert!(!cards.is_empty(), "Spanish cards should exist in La Liga data"); + for card in cards { + assert_eq!( + card["nation"].as_str().unwrap().to_lowercase(), + "spain" + ); + } +} + +#[tokio::test] +async fn test_cards_filter_by_min_overall() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/cards?min_overall=84").await; + assert_eq!(status, StatusCode::OK); + let cards = json["cards"].as_array().expect("cards array"); + assert!(!cards.is_empty()); + for card in cards { + assert!( + card["overall"].as_u64().unwrap() >= 84, + "card below min_overall threshold" + ); + } +} + +#[tokio::test] +async fn test_cards_filter_by_club() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/cards?club=Northgate+United").await; + assert_eq!(status, StatusCode::OK); + let cards = json["cards"].as_array().expect("cards array"); + assert!(!cards.is_empty(), "Northgate United cards expected"); + for card in cards { + assert_eq!( + card["club"].as_str().unwrap().to_lowercase(), + "northgate united" + ); + } +} + +#[tokio::test] +async fn test_cards_filter_combined_params() { + let app = build_test_app().await; + // La Liga cards with overall >= 82 — should include several + let (status, json) = json_get(&app, "/cards?league=La+Liga&min_overall=82").await; + assert_eq!(status, StatusCode::OK); + let cards = json["cards"].as_array().expect("cards array"); + assert!(!cards.is_empty(), "combined filter should return results"); + for card in cards { + assert_eq!(card["league"].as_str().unwrap().to_lowercase(), "la liga"); + assert!(card["overall"].as_u64().unwrap() >= 82); + } + // total reflects untruncated count, returned reflects actual + assert_eq!(json["total"], json["returned"]); +} + +#[tokio::test] +async fn test_cards_limit_parameter() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/cards?limit=3").await; + assert_eq!(status, StatusCode::OK); + let returned = json["returned"].as_u64().unwrap(); + let total = json["total"].as_u64().unwrap(); + assert!(returned <= 3); + assert!(total >= returned, "total should not be less than returned"); + // Cards should be sorted by overall descending + let cards = json["cards"].as_array().unwrap(); + for w in cards.windows(2) { + assert!( + w[0]["overall"].as_u64().unwrap() >= w[1]["overall"].as_u64().unwrap(), + "cards should be sorted by overall descending" + ); + } +} + +#[tokio::test] +async fn test_cards_max_overall_boundary() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/cards?max_overall=70").await; + assert_eq!(status, StatusCode::OK); + let cards = json["cards"].as_array().expect("cards array"); + // We don't require results (there may be none), just verify none exceed limit + for card in cards { + assert!( + card["overall"].as_u64().unwrap() <= 70, + "card above max_overall threshold" + ); + } +} + +#[tokio::test] +async fn test_draft_squad_ultimate_difficulty() { + let app = build_test_app().await; + auth(&app, "UltimateDrafter").await; + + let (status, json) = json_get(&app, "/draft/squad?difficulty=ultimate").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["difficulty"], "ultimate"); + assert!(json["cards"].is_array()); + // Should fall back to lower band if not enough 90+ cards — just verify 11 or fewer cards + let card_count = json["cards"].as_array().unwrap().len(); + assert!(card_count <= 11); +} + +#[tokio::test] +async fn test_chemistry_response_has_breakdown_structure() { + let app = build_test_app().await; + auth(&app, "ChemPlayer").await; + + // Create an empty squad (no players) + let (s, j) = json_post( + &app, + "/squad", + serde_json::json!({ "name": "ChemSquad", "formation": "4-3-3", "players": [] }), + ) + .await; + assert_eq!(s, StatusCode::OK, "{j}"); + let squad_id = j["squad"]["id"].as_str().unwrap().to_string(); + + let (status, json) = json_get(&app, &format!("/squads/{squad_id}")).await; + assert_eq!(status, StatusCode::OK); + // Chemistry block should always be present + let chem = &json["chemistry"]; + assert!(chem.is_object(), "chemistry should be an object"); + assert_eq!(chem["total"], 0, "empty squad has 0 chemistry"); + assert_eq!(chem["max"], 100); + assert!(chem["player_chemistries"].is_array()); +}