feat: Phase 2 — game feel complete
Chemistry & squads: - Chemistry calculation on GET /squad (club/league/nation links, max 100) - Formation validation on POST /squad (exactly 11 starters, exactly 1 GK) Objectives: - Weekly objectives JSON (4 objectives: warrior, goals, dedicated, SBC) - Daily objectives auto-reset at midnight UTC (background task) SBC validation expanded: - max_overall per-player enforcement - required_clubs validation - min_players_from_same_nation validation - min_players_from_same_club validation Market: - GET /market?min_overall=X&position=Y filtering - Expiry cleanup runs before every NPC refresh - NPC market auto-refresh every 24h (background task, runs at startup) Matches: - GET /matches/opponent?difficulty=beginner|professional|world_class|legendary generates a random AI opponent squad from the card pool Statistics: - win_streak and best_win_streak tracking (migration 0002) - GET /statistics/history?limit=N — last N matches with summary stats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+113
-3
@@ -1,7 +1,11 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::squad::{SaveSquadRequest, Squad, SquadPlayer},
|
||||
models::{
|
||||
card::{CardDefinition, OwnedCard},
|
||||
squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput},
|
||||
},
|
||||
services::card_db::CardDb,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -24,6 +28,112 @@ pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<Squa
|
||||
Ok((squad, players))
|
||||
}
|
||||
|
||||
/// Validate that the squad has exactly 11 starters with exactly one GK.
|
||||
pub async fn validate_formation(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
players: &[SquadPlayerInput],
|
||||
) -> AppResult<()> {
|
||||
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||
|
||||
if starters.len() != 11 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"need exactly 11 starters, got {}",
|
||||
starters.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut gk_count = 0usize;
|
||||
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 = ?",
|
||||
)
|
||||
.bind(&sp.owned_card_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
|
||||
|
||||
if let Some(card) = card_db.get(&owned.card_id) {
|
||||
if card.position == "GK" {
|
||||
gk_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gk_count == 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"squad must include exactly one goalkeeper (GK)".into(),
|
||||
));
|
||||
}
|
||||
if gk_count > 1 {
|
||||
return Err(AppError::BadRequest(
|
||||
"squad cannot have more than one starting goalkeeper".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate chemistry for the starting XI. Returns total and per-player scores.
|
||||
pub async fn calculate_chemistry(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
players: &[SquadPlayer],
|
||||
) -> AppResult<serde_json::Value> {
|
||||
let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||
|
||||
let mut cards: Vec<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 = ?",
|
||||
)
|
||||
.bind(&sp.owned_card_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(o) = owned {
|
||||
if let Some(card) = card_db.get(&o.card_id) {
|
||||
cards.push(card.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-player chemistry: club links (max 4) + league links (max 3) + nation links (max 3), capped at 10
|
||||
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 total: i64 = player_chems.iter().sum::<i64>().min(100);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total": total,
|
||||
"max": 100,
|
||||
"player_chemistries": player_chems,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
@@ -54,7 +164,7 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A
|
||||
req.formation.as_deref().unwrap_or("4-4-2"),
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.bind(&squad.club_id)
|
||||
@@ -70,7 +180,7 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A
|
||||
for player in &req.players {
|
||||
let sp_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&sp_id)
|
||||
.bind(&squad_id)
|
||||
|
||||
Reference in New Issue
Block a user