use crate::extractors::GameId; use axum::{ extract::{Path, Query, State}, Json, }; use serde::Deserialize; use serde_json::{json, Value}; use crate::{ app::AppState, error::{AppError, AppResult}, models::card::{OwnedCard, OWNED_CARD_SELECT}, services::{ club as club_svc, economy as economy_svc, inventory::{self, OwnedItemQuery, OwnedItemView}, profile as profile_svc, training as training_svc, }, }; /// Quick-sell value for a card based on overall rating. fn quick_sell_coins(overall: u8) -> i64 { if overall >= 85 { 1500 } else if overall >= 80 { 900 } else if overall >= 75 { 600 } else if overall >= 65 { 300 } else { 150 } } #[derive(Debug, Deserialize)] pub struct CardQuery { pub rarity: Option, pub position: Option, pub nation: Option, pub league: Option, pub club: Option, pub min_overall: Option, pub max_overall: Option, pub limit: Option, } pub async fn get_card( State(state): State, Path(card_id): Path, ) -> AppResult> { let card = state .card_db .get(&card_id) .ok_or_else(|| AppError::NotFound(format!("card '{card_id}' not found")))?; Ok(Json(json!({ "card": card }))) } pub async fn get_cards( State(state): State, Query(query): Query, ) -> AppResult> { let mut cards: Vec<_> = state .card_db .all() .into_iter() .filter(|c| { let rarity_ok = query .rarity .as_ref() .map(|r| c.rarity.as_str().eq_ignore_ascii_case(r)) .unwrap_or(true); let pos_ok = query .position .as_ref() .map(|p| c.position.eq_ignore_ascii_case(p)) .unwrap_or(true); let nation_ok = query .nation .as_ref() .map(|n| c.nation.eq_ignore_ascii_case(n)) .unwrap_or(true); let league_ok = query .league .as_ref() .map(|l| c.league.eq_ignore_ascii_case(l)) .unwrap_or(true); let club_ok = query .club .as_ref() .map(|cl| c.club.eq_ignore_ascii_case(cl)) .unwrap_or(true); let min_ok = query.min_overall.map(|m| c.overall >= m).unwrap_or(true); let max_ok = query.max_overall.map(|m| c.overall <= m).unwrap_or(true); rarity_ok && pos_ok && nation_ok && league_ok && club_ok && min_ok && max_ok }) .collect(); cards.sort_by_key(|c| std::cmp::Reverse(c.overall)); let total = cards.len(); if let Some(limit) = query.limit { cards.truncate(limit); } Ok(Json( json!({ "cards": cards, "total": total, "returned": cards.len() }), )) } pub async fn get_collection( State(state): State, game: GameId, Query(query): Query, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE club_id = ?")) .bind(&club.id) .fetch_all(&state.pool) .await?; // One query for the whole club, not one per item: this projection walks // every owned row, and a per-item lookup here is the N+1 it has suffered // before. let training = training_svc::load_for_club(&state.pool, &club.id).await?; // An owned row whose definition is absent from the loaded content CANNOT be // projected (there is nothing to project), but it must never vanish in // silence: that silent `filter_map` drop is how a real club once served // `total: 0` while 1986 owned rows sat in the DB. So: keep the drop (a // missing definition is not a 500), but LOG each one and report the count in // the envelope so a caller and an operator both see it. let mut unresolved: Vec<&str> = Vec::new(); let mut views: Vec = Vec::with_capacity(owned.len()); for o in &owned { let Some(def) = state.card_db.get(&o.card_id) else { tracing::warn!( owned_card_id = %o.id, card_id = %o.card_id, content_kind = %o.content_kind, club_id = %club.id, "owned item dropped from /collection: no card definition loaded" ); unresolved.push(o.card_id.as_str()); continue; }; let effective_overall = def.overall as i64 + o.training_bonus; let effective_position = o.position_override.as_deref().unwrap_or(&def.position); // Attribute training is per-instance state, so the finished attributes // belong in the envelope beside the finished rating. The raw effects go // out too: a caller that needs to show WHICH attribute was trained // cannot recover that by differencing against a definition it may not // have. const NO_TRAINING: &[training_svc::TrainingEffect] = &[]; let effects = training .get(&o.id) .map(Vec::as_slice) .unwrap_or(NO_TRAINING); let body = json!({ "owned_card_id": o.id, "content_kind": o.content_kind, "quantity": o.quantity, "is_loan": o.is_loan, "loan_matches_remaining": o.loan_matches_remaining, "acquired_at": o.acquired_at, "chemistry_style": o.chemistry_style, "position_override": o.position_override, "training_bonus": o.training_bonus, // Core's stored value verbatim: `null` means Core tracks no contract // for this instance, which is NOT zero. Substituting a default here // would bake one game's pack-fresh number into every game's envelope. "contract_matches": o.contract_matches, "effective_overall": effective_overall, "effective_position": effective_position, "effective_attributes": training_svc::effective_attributes_json(def, effects), "training": effects, "card": def, }); views.push(OwnedItemView { owned_card_id: o.id.clone(), content_kind: o.content_kind, base_overall: def.overall, effective_overall, position: effective_position.to_string(), nation: def.nation.clone(), league: def.league.clone(), club: def.club.clone(), body, }); } if !unresolved.is_empty() { unresolved.sort_unstable(); unresolved.dedup(); tracing::warn!( club_id = %club.id, owned_rows = owned.len(), dropped = owned.len() - views.len(), definitions = ?unresolved, "/collection dropped owned items with missing definitions" ); } let owned_rows = owned.len(); let unresolved_items = owned_rows - views.len(); let page = inventory::apply_query(views, &query); let returned = page.items.len(); Ok(Json(json!({ "collection": page.items, "total": page.total, "returned": returned, "offset": page.offset, "limit": page.limit, // Ownership truth vs. what could be projected. `owned_rows` counts every // row Core actually owns for this club; `unresolved_items` counts those // dropped for want of a definition. Both zero-cost when nothing is wrong. "owned_rows": owned_rows, "unresolved_items": unresolved_items, "unresolved_definitions": unresolved, }))) } /// Quick-sell an owned card for instant coins. The card is removed from the collection. pub async fn delete_owned_card( State(state): State, game: GameId, Path(owned_card_id): Path, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let owned = sqlx::query_as::<_, OwnedCard>(&format!( "{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?" )) .bind(&owned_card_id) .bind(&club.id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?; let card = state.card_db.get(&owned.card_id).ok_or_else(|| { AppError::NotFound(format!("card definition '{}' missing", owned.card_id)) })?; let coins = quick_sell_coins(card.overall); // Delegate to the economy authority rather than hand-rolling DELETE + add_coins: // that pair ran on the pool with NO transaction (a failed credit left the card // destroyed for nothing) and it skipped `squad_players`, whose FK onto // `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787. economy_svc::sell_item(&state.pool, &club.id, &owned_card_id, coins).await?; Ok(Json(json!({ "quick_sold": owned_card_id, "card_id": owned.card_id, "coins_received": coins, }))) }