Phase 7 (Core): chemistry v2, expanded card pool & search filters
CI / Build, lint & test (push) Failing after 1m46s
CI / Build, lint & test (push) Failing after 1m46s
- Chemistry v2: FUT-style link scoring with per-player breakdown (club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3; player cap 10, team cap 100); chemistry response now includes per-player breakdown with club/league/nation link counts and pts - Card pool: 34 new cards across Premier League, La Liga, Bundesliga with overlapping clubs/nations for meaningful chemistry testing - Card search: extended GET /cards with nation, league, club, min_overall, max_overall, limit query params; results sorted by overall descending - Match opponent: added "ultimate" difficulty band (85+ OVR); random formation selection from 6 tactical formations; falls back to lower OVR pool when not enough cards at the requested band - Tests: 9 new integration tests (28 total, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
+40
-10
@@ -16,6 +16,12 @@ use crate::{
|
||||
pub struct CardQuery {
|
||||
pub rarity: Option<String>,
|
||||
pub position: Option<String>,
|
||||
pub nation: Option<String>,
|
||||
pub league: Option<String>,
|
||||
pub club: Option<String>,
|
||||
pub min_overall: Option<u8>,
|
||||
pub max_overall: Option<u8>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn get_card(
|
||||
@@ -33,25 +39,49 @@ pub async fn get_cards(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<CardQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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::<Vec<_>>();
|
||||
.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<AppState>) -> AppResult<Json<Value>> {
|
||||
|
||||
@@ -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<usize> = (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<usize> = (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::<i64>() / 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,
|
||||
})
|
||||
}
|
||||
|
||||
+59
-31
@@ -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<serde_json::Value> {
|
||||
let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||
|
||||
let mut cards: Vec<CardDefinition> = 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<i64> = 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::<i64>().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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user