diff --git a/data/chemistry_styles.json b/data/chemistry_styles.json new file mode 100644 index 0000000..ee6f6f5 --- /dev/null +++ b/data/chemistry_styles.json @@ -0,0 +1,110 @@ +[ + { + "id": "basic", + "name": "Basic", + "description": "Small boosts across all attributes", + "boosts": { "pace": 3, "shooting": 3, "passing": 3, "dribbling": 3, "defending": 3, "physical": 3 } + }, + { + "id": "shadow", + "name": "Shadow", + "description": "Maximises pace and defending — ideal for full-backs", + "boosts": { "pace": 10, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 0 } + }, + { + "id": "anchor", + "name": "Anchor", + "description": "Boosts defending and physicality — built for centre-backs", + "boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 10 } + }, + { + "id": "hunter", + "name": "Hunter", + "description": "Pace and shooting — the striker's best friend", + "boosts": { "pace": 10, "shooting": 10, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0 } + }, + { + "id": "catalyst", + "name": "Catalyst", + "description": "Pace and passing for box-to-box and wide midfielders", + "boosts": { "pace": 10, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 } + }, + { + "id": "engine", + "name": "Engine", + "description": "Pace, passing and dribbling for creative midfielders", + "boosts": { "pace": 10, "shooting": 0, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 } + }, + { + "id": "hawk", + "name": "Hawk", + "description": "Balanced pace, shooting and physicality", + "boosts": { "pace": 5, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 0, "physical": 5 } + }, + { + "id": "marksman", + "name": "Marksman", + "description": "Shooting, dribbling and physicality for physical strikers", + "boosts": { "pace": 0, "shooting": 10, "passing": 0, "dribbling": 5, "defending": 0, "physical": 5 } + }, + { + "id": "deadeye", + "name": "Deadeye", + "description": "Shooting and passing — classic number 10 style", + "boosts": { "pace": 0, "shooting": 10, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 } + }, + { + "id": "artist", + "name": "Artist", + "description": "Passing and dribbling for technical playmakers", + "boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 10, "defending": 0, "physical": 0 } + }, + { + "id": "sniper", + "name": "Sniper", + "description": "Shooting and dribbling for agile forwards", + "boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 5, "defending": 0, "physical": 0 } + }, + { + "id": "finisher", + "name": "Finisher", + "description": "Shooting and dribbling — precision over power", + "boosts": { "pace": 0, "shooting": 8, "passing": 0, "dribbling": 7, "defending": 0, "physical": 0 } + }, + { + "id": "guardian", + "name": "Guardian", + "description": "Dribbling and defending for defensive midfielders", + "boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 5, "defending": 10, "physical": 0 } + }, + { + "id": "backbone", + "name": "Backbone", + "description": "Passing, defending and physicality for the defensive shield", + "boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 0, "defending": 5, "physical": 5 } + }, + { + "id": "sentinel", + "name": "Sentinel", + "description": "Defending and physicality for no-nonsense defenders", + "boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 5, "physical": 5 } + }, + { + "id": "powerhouse", + "name": "Powerhouse", + "description": "Passing and defending for dominant central midfielders", + "boosts": { "pace": 0, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 10, "physical": 0 } + }, + { + "id": "maestro", + "name": "Maestro", + "description": "Shooting, passing and dribbling — the complete midfielder", + "boosts": { "pace": 0, "shooting": 5, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 } + }, + { + "id": "gladiator", + "name": "Gladiator", + "description": "Shooting and defending for all-action midfielders", + "boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 5, "physical": 0 } + } +] diff --git a/migrations/0007_card_upgrades.sql b/migrations/0007_card_upgrades.sql new file mode 100644 index 0000000..73bd570 --- /dev/null +++ b/migrations/0007_card_upgrades.sql @@ -0,0 +1,7 @@ +-- Phase 10: card upgrade system +-- chemistry_style: applied cosmetic/stat modifier (default "basic") +-- position_override: player moved to a different position from their card default +-- training_bonus: extra OVR points from training cards (capped at +3) +ALTER TABLE owned_cards ADD COLUMN chemistry_style TEXT NOT NULL DEFAULT 'basic'; +ALTER TABLE owned_cards ADD COLUMN position_override TEXT; +ALTER TABLE owned_cards ADD COLUMN training_bonus INTEGER NOT NULL DEFAULT 0; diff --git a/src/app.rs b/src/app.rs index 28bf31d..1f74143 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid}; use crate::{ config::Config, db::Pool, + models::chemistry_style::{load_chemistry_styles, ChemistryStyle}, models::event::EventDefinition, models::objective::{ObjectiveDefinition, ObjectiveType}, models::pack::PackDefinition, @@ -35,6 +36,7 @@ pub struct AppState { pub obj_defs: Arc>, pub sbc_defs: Arc>, pub event_defs: Arc>, + pub chem_styles: Arc>, } pub async fn build(pool: Pool, cfg: Config) -> Result { @@ -43,14 +45,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?); let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?); + let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?); tracing::info!( - "Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events", + "Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles", card_db.cards.len(), pack_defs.len(), obj_defs.len(), sbc_defs.len(), event_defs.len(), + chem_styles.len(), ); let state = AppState { @@ -60,6 +64,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { obj_defs: obj_defs.clone(), sbc_defs, event_defs: event_defs.clone(), + chem_styles, }; // Background task: refresh NPC market at startup and every 24 hours @@ -130,6 +135,22 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { "/collection/:owned_card_id", delete(routes::cards::delete_owned_card), ) + .route( + "/collection/:owned_card_id/chemistry-style", + post(routes::upgrades::post_apply_chemistry_style), + ) + .route( + "/collection/:owned_card_id/position", + post(routes::upgrades::post_change_position), + ) + .route( + "/collection/:owned_card_id/training", + post(routes::upgrades::post_apply_training), + ) + .route( + "/chemistry-styles", + get(routes::upgrades::get_chemistry_styles), + ) .route("/packs", get(routes::packs::get_packs)) .route("/packs/history", get(routes::packs::get_pack_history)) .route("/packs/buy", post(routes::packs::post_buy_pack)) diff --git a/src/models/card.rs b/src/models/card.rs index 57bfe61..4422b3f 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -42,4 +42,7 @@ pub struct OwnedCard { pub is_loan: bool, pub loan_matches_remaining: Option, pub acquired_at: String, + pub chemistry_style: String, + pub position_override: Option, + pub training_bonus: i64, } diff --git a/src/models/chemistry_style.rs b/src/models/chemistry_style.rs new file mode 100644 index 0000000..81bd4ea --- /dev/null +++ b/src/models/chemistry_style.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatBoosts { + pub pace: i32, + pub shooting: i32, + pub passing: i32, + pub dribbling: i32, + pub defending: i32, + pub physical: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChemistryStyle { + pub id: String, + pub name: String, + pub description: String, + pub boosts: StatBoosts, +} + +pub fn load_chemistry_styles(data_dir: &str) -> anyhow::Result> { + let path = format!("{data_dir}/chemistry_styles.json"); + let raw = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("failed to read {path}: {e}"))?; + Ok(serde_json::from_str(&raw)?) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 395fce3..a64742a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,7 +1,9 @@ pub mod card; +pub mod chemistry_style; pub mod club; pub mod draft; pub mod event; +pub mod season; pub mod market; pub mod match_result; pub mod objective; diff --git a/src/routes/cards.rs b/src/routes/cards.rs index a4ad7bb..adc2a27 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -98,7 +98,7 @@ pub async fn get_collection(State(state): State) -> AppResult( - "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE club_id = ?" + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE club_id = ?" ) .bind(&club.id) .fetch_all(&state.pool) @@ -108,11 +108,19 @@ pub async fn get_collection(State(state): State) -> AppResult( - "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ + "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_card_id) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5d4bad6..80209c7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -15,3 +15,4 @@ pub mod sbc; pub mod settings; pub mod squad; pub mod statistics; +pub mod upgrades; diff --git a/src/routes/upgrades.rs b/src/routes/upgrades.rs new file mode 100644 index 0000000..a77f33a --- /dev/null +++ b/src/routes/upgrades.rs @@ -0,0 +1,120 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + services::{club as club_svc, profile as profile_svc, upgrades as upgrade_svc}, +}; + +/// GET /chemistry-styles — list all available styles with their stat boosts. +pub async fn get_chemistry_styles(State(state): State) -> AppResult> { + Ok(Json(json!({ + "chemistry_styles": *state.chem_styles, + "total": state.chem_styles.len(), + }))) +} + +#[derive(Deserialize)] +pub struct ApplyChemStyleRequest { + pub style_id: String, +} + +/// POST /collection/:owned_card_id/chemistry-style +pub async fn post_apply_chemistry_style( + State(state): State, + Path(owned_card_id): Path, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let updated = upgrade_svc::apply_chemistry_style( + &state.pool, + &club.id, + &owned_card_id, + &req.style_id, + &state.chem_styles, + ) + .await?; + + let style = state + .chem_styles + .iter() + .find(|s| s.id == updated.chemistry_style); + + Ok(Json(json!({ + "owned_card_id": updated.id, + "card_id": updated.card_id, + "chemistry_style": updated.chemistry_style, + "style_details": style, + }))) +} + +#[derive(Deserialize)] +pub struct ChangePositionRequest { + pub position: String, +} + +/// POST /collection/:owned_card_id/position — costs 500 coins. +pub async fn post_change_position( + State(state): State, + Path(owned_card_id): Path, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let updated = upgrade_svc::change_position( + &state.pool, + &club.id, + &owned_card_id, + &req.position, + ) + .await?; + + let card_def = state.card_db.get(&updated.card_id); + + Ok(Json(json!({ + "owned_card_id": updated.id, + "card_id": updated.card_id, + "original_position": card_def.map(|c| &c.position), + "position_override": updated.position_override, + "cost_coins": upgrade_svc::POSITION_CHANGE_COST, + }))) +} + +#[derive(Deserialize)] +pub struct ApplyTrainingRequest { + /// OVR points to add. Must be 1–3. Total capped at +3. + pub boost: i64, +} + +/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total). +pub async fn post_apply_training( + State(state): State, + Path(owned_card_id): Path, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let updated = + upgrade_svc::apply_training(&state.pool, &club.id, &owned_card_id, req.boost).await?; + + let card_def = state.card_db.get(&updated.card_id); + let base_overall = card_def.map(|c| c.overall as i64).unwrap_or(0); + + Ok(Json(json!({ + "owned_card_id": updated.id, + "card_id": updated.card_id, + "base_overall": base_overall, + "training_bonus": updated.training_bonus, + "effective_overall": base_overall + updated.training_bonus, + "max_bonus": upgrade_svc::MAX_TRAINING_BONUS, + }))) +} diff --git a/src/services/market.rs b/src/services/market.rs index 7ca670c..dfa7ac0 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -162,7 +162,8 @@ pub async fn buy_listing( pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult { let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>( - "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ + "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(&req.owned_card_id) diff --git a/src/services/mod.rs b/src/services/mod.rs index 20521aa..d33dcbb 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod card_db; pub mod club; pub mod draft; pub mod event; +pub mod season; pub mod market; pub mod match_service; pub mod objective; @@ -11,3 +12,4 @@ pub mod sbc; pub mod settings; pub mod squad; pub mod statistics; +pub mod upgrades; diff --git a/src/services/sbc.rs b/src/services/sbc.rs index 4c260d8..65199b5 100644 --- a/src/services/sbc.rs +++ b/src/services/sbc.rs @@ -50,7 +50,7 @@ pub async fn submit_sbc( let mut cards: Vec = 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 FROM owned_cards WHERE id = ? AND club_id = ?" + "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) diff --git a/src/services/squad.rs b/src/services/squad.rs index 97ca992..2d550a2 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -77,7 +77,7 @@ pub async fn validate_formation( 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 = ?", + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?", ) .bind(&sp.owned_card_id) .fetch_optional(pool) diff --git a/src/services/upgrades.rs b/src/services/upgrades.rs new file mode 100644 index 0000000..761b04d --- /dev/null +++ b/src/services/upgrades.rs @@ -0,0 +1,125 @@ +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 +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 3205f32..320db82 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -984,3 +984,187 @@ async fn test_match_result_returns_season_info() { // season_end is None for non-final match assert!(result["season_end"].is_null()); } + +// ── Phase 10: Card Upgrades ─────────────────────────────────────────────────── + +async fn get_first_owned_card_id(app: &axum::Router) -> String { + // Open the starter pack to get a card + let (_, packs) = json_get(app, "/packs").await; + let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string(); + json_post(app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await; + let (_, coll) = json_get(app, "/collection").await; + coll["collection"][0]["owned_card_id"] + .as_str() + .unwrap() + .to_string() +} + +#[tokio::test] +async fn test_chemistry_styles_list() { + let app = build_test_app().await; + let (s, json) = json_get(&app, "/chemistry-styles").await; + assert_eq!(s, StatusCode::OK); + assert!(json["chemistry_styles"].is_array()); + assert!(json["total"].as_u64().unwrap() >= 18, "expect 18 built-in styles"); + // "basic" must always be present + let styles = json["chemistry_styles"].as_array().unwrap(); + assert!(styles.iter().any(|s| s["id"] == "basic")); + assert!(styles.iter().any(|s| s["id"] == "shadow")); + assert!(styles.iter().any(|s| s["id"] == "hunter")); +} + +#[tokio::test] +async fn test_apply_chemistry_style() { + let app = build_test_app().await; + auth(&app, "ChemPlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + let (s, json) = json_post( + &app, + &format!("/collection/{owned_id}/chemistry-style"), + serde_json::json!({ "style_id": "hunter" }), + ).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["chemistry_style"], "hunter"); + assert_eq!(json["style_details"]["name"], "Hunter"); + assert!(json["style_details"]["boosts"]["pace"].as_i64().unwrap() > 0); +} + +#[tokio::test] +async fn test_apply_invalid_chemistry_style_returns_404() { + let app = build_test_app().await; + auth(&app, "BadChemPlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + let (s, _) = json_post( + &app, + &format!("/collection/{owned_id}/chemistry-style"), + serde_json::json!({ "style_id": "nonexistent_style" }), + ).await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_collection_includes_upgrade_fields() { + let app = build_test_app().await; + auth(&app, "UpgradeFieldPlayer").await; + get_first_owned_card_id(&app).await; // opens starter pack + + let (s, coll) = json_get(&app, "/collection").await; + assert_eq!(s, StatusCode::OK); + let card = &coll["collection"][0]; + assert!(card["chemistry_style"].is_string()); + assert!(card["training_bonus"].is_number()); + assert!(card["effective_overall"].is_number()); + assert!(card["effective_position"].is_string()); +} + +#[tokio::test] +async fn test_position_change_deducts_coins() { + let app = build_test_app().await; + auth(&app, "PosChangePlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + let (_, club_before) = json_get(&app, "/club").await; + let coins_before = club_before["coins"].as_i64().unwrap(); + + let (s, json) = json_post( + &app, + &format!("/collection/{owned_id}/position"), + serde_json::json!({ "position": "CM" }), + ).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["position_override"], "CM"); + assert_eq!(json["cost_coins"], 500); + + let (_, club_after) = json_get(&app, "/club").await; + assert_eq!( + club_after["coins"].as_i64().unwrap(), + coins_before - 500 + ); +} + +#[tokio::test] +async fn test_position_change_invalid_position() { + let app = build_test_app().await; + auth(&app, "BadPosPlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + let (s, _) = json_post( + &app, + &format!("/collection/{owned_id}/position"), + serde_json::json!({ "position": "STRIKER" }), + ).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_training_boost_increases_effective_overall() { + let app = build_test_app().await; + auth(&app, "TrainingPlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + let (s, json) = json_post( + &app, + &format!("/collection/{owned_id}/training"), + serde_json::json!({ "boost": 2 }), + ).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["training_bonus"], 2); + assert_eq!( + json["effective_overall"].as_i64().unwrap(), + json["base_overall"].as_i64().unwrap() + 2 + ); + assert_eq!(json["max_bonus"], 3); +} + +#[tokio::test] +async fn test_training_capped_at_max() { + let app = build_test_app().await; + auth(&app, "CapTrainPlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + // Apply +3 (the max) + let (s, _) = json_post( + &app, + &format!("/collection/{owned_id}/training"), + serde_json::json!({ "boost": 3 }), + ).await; + assert_eq!(s, StatusCode::OK); + + // Trying to add more should fail + let (s, json) = json_post( + &app, + &format!("/collection/{owned_id}/training"), + serde_json::json!({ "boost": 1 }), + ).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "{json}"); +} + +#[tokio::test] +async fn test_training_invalid_boost_value() { + let app = build_test_app().await; + auth(&app, "BadTrainPlayer").await; + let owned_id = get_first_owned_card_id(&app).await; + + let (s, _) = json_post( + &app, + &format!("/collection/{owned_id}/training"), + serde_json::json!({ "boost": 5 }), + ).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_upgrades_on_nonexistent_card_return_404() { + let app = build_test_app().await; + auth(&app, "OwnerAlpha").await; + + // Use a made-up owned_card_id that doesn't belong to this club + let (s, _) = json_post( + &app, + "/collection/nonexistent-owned-card-id/chemistry-style", + serde_json::json!({ "style_id": "basic" }), + ).await; + assert_eq!(s, StatusCode::NOT_FOUND); +}