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:
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user