19c7b9989d
CI / Build, lint & test (push) Failing after 1m37s
- GET /chemistry-styles — lists 18 FUT-style chemistry styles with stat boost breakdowns - POST /collection/:id/chemistry-style — apply a style (Shadow, Hunter, Anchor, etc.) - POST /collection/:id/position — override a player's position for 500 coins - POST /collection/:id/training — add +1/+2/+3 OVR training bonus (max +3 total) - GET /collection now includes chemistry_style, position_override, training_bonus, effective_overall and effective_position fields per owned card - Migration 0007 adds three columns to owned_cards with safe defaults - 10 new integration tests — all 55 pass, clippy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
211 lines
6.7 KiB
Rust
211 lines
6.7 KiB
Rust
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<Vec<SbcDefinition>> {
|
|
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<SbcDefinition> =
|
|
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<SbcResult> {
|
|
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<CardDefinition> = 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<String>) {
|
|
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::<i64>() / 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
|
|
})
|
|
}
|