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>
126 lines
3.7 KiB
Rust
126 lines
3.7 KiB
Rust
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<OwnedCard> {
|
||
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<OwnedCard> {
|
||
// 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<OwnedCard> {
|
||
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<OwnedCard> {
|
||
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
|
||
}
|