Files
OpenFUT-Core/src/routes/cards.rs
T
funman300 a034e74c16
CI / Build, lint & test (push) Successful in 2m19s
style(core): apply cargo fmt across routes, services, models, tests
Pure rustfmt reflow (import grouping, array/match-arm/call-arg wrapping,
alphabetized module decls, comment realignment). No semantic change:
full-diff and `git diff -w` both confirm logic byte-identical to 271c363;
workspace builds and all 74 core tests pass. Retained pre-existing WIP
brought forward after verification.
2026-08-20 16:05:41 +00:00

205 lines
6.7 KiB
Rust

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,
services::{
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_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<String>,
pub position: Option<String>,
pub nation: Option<String>,
pub league: Option<String>,
pub club: Option<String>,
pub min_overall: Option<u8>,
pub max_overall: Option<u8>,
pub limit: Option<usize>,
}
pub async fn get_card(
State(state): State<AppState>,
Path(card_id): Path<String>,
) -> AppResult<Json<Value>> {
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<AppState>,
Query(query): Query<CardQuery>,
) -> AppResult<Json<Value>> {
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<AppState>,
game: GameId,
Query(query): Query<OwnedItemQuery>,
) -> AppResult<Json<Value>> {
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>(
"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)
.await?;
let views: Vec<OwnedItemView> = owned
.iter()
.filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
let body = json!({
"owned_card_id": o.id,
"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,
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def,
});
OwnedItemView {
owned_card_id: o.id.clone(),
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
}
})
})
.collect();
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,
})))
}
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
pub async fn delete_owned_card(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
) -> AppResult<Json<Value>> {
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>(
"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)
.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,
})))
}