use crate::{ db::Pool, error::{AppError, AppResult}, models::card::OwnedCard, models::chemistry_style::ChemistryStyle, }; const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \ chemistry_style, position_override, training_bonus \ FROM owned_cards"; pub const MAX_TRAINING_BONUS: i64 = 3; /// Cost in coins to change a player's position. pub const POSITION_CHANGE_COST: i64 = 500; async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult { sqlx::query_as::<_, OwnedCard>(&format!( "{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?" )) .bind(owned_card_id) .bind(club_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound("owned card not found".into())) } /// Apply a chemistry style to an owned card. /// /// The style is validated against the loaded definitions. The card is not /// mutated in memory — callers should re-fetch if they need the updated state. pub async fn apply_chemistry_style( pool: &Pool, club_id: &str, owned_card_id: &str, style_id: &str, styles: &[ChemistryStyle], ) -> AppResult { // Validate style exists if !styles.iter().any(|s| s.id == style_id) { return Err(AppError::NotFound(format!( "chemistry style '{style_id}' not found" ))); } // Ownership check fetch_owned(pool, owned_card_id, club_id).await?; sqlx::query("UPDATE owned_cards SET chemistry_style = ? WHERE id = ?") .bind(style_id) .bind(owned_card_id) .execute(pool) .await?; fetch_owned(pool, owned_card_id, club_id).await } /// Override a player's position. Costs POSITION_CHANGE_COST coins. pub async fn change_position( pool: &Pool, club_id: &str, owned_card_id: &str, new_position: &str, ) -> AppResult { let valid_positions = [ "GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF", "ST", ]; if !valid_positions.contains(&new_position) { return Err(AppError::BadRequest(format!( "unknown position '{new_position}'" ))); } // Ownership check fetch_owned(pool, owned_card_id, club_id).await?; // Deduct coins crate::services::club::spend_coins(pool, club_id, POSITION_CHANGE_COST).await?; sqlx::query("UPDATE owned_cards SET position_override = ? WHERE id = ?") .bind(new_position) .bind(owned_card_id) .execute(pool) .await?; fetch_owned(pool, owned_card_id, club_id).await } /// Apply a training boost to an owned card. /// /// `boost` is the number of OVR points to add (1–3). /// The total training_bonus is capped at MAX_TRAINING_BONUS. /// Training is free — the "cost" is consuming a training card item, which is /// handled at the route layer (future: deduct a training_card from inventory). pub async fn apply_training( pool: &Pool, club_id: &str, owned_card_id: &str, boost: i64, ) -> AppResult { if !(1..=3).contains(&boost) { return Err(AppError::BadRequest( "training boost must be between 1 and 3".into(), )); } let card = fetch_owned(pool, owned_card_id, club_id).await?; let new_bonus = (card.training_bonus + boost).min(MAX_TRAINING_BONUS); if new_bonus == card.training_bonus { return Err(AppError::BadRequest(format!( "card has already reached the maximum training bonus of +{MAX_TRAINING_BONUS}" ))); } sqlx::query("UPDATE owned_cards SET training_bonus = ? WHERE id = ?") .bind(new_bonus) .bind(owned_card_id) .execute(pool) .await?; fetch_owned(pool, owned_card_id, club_id).await }