Files
OpenFUT-Core/src/services/sbc.rs
T
funman300 19c7b9989d
CI / Build, lint & test (push) Failing after 1m37s
Phase 10: card upgrade system (chemistry styles, position change, training)
- 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>
2026-06-25 17:02:44 -07:00

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
})
}