33b5ac1908
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.
Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.
Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
197 lines
6.4 KiB
Rust
197 lines
6.4 KiB
Rust
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,
|
|
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| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
|
.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>,
|
|
Query(query): Query<OwnedItemQuery>,
|
|
) -> AppResult<Json<Value>> {
|
|
let profile = profile_svc::get_active_profile(&state.pool).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>,
|
|
Path(owned_card_id): Path<String>,
|
|
) -> AppResult<Json<Value>> {
|
|
let profile = profile_svc::get_active_profile(&state.pool).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);
|
|
|
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
|
.bind(&owned_card_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
club_svc::add_coins(&state.pool, &club.id, coins).await?;
|
|
|
|
Ok(Json(json!({
|
|
"quick_sold": owned_card_id,
|
|
"card_id": owned.card_id,
|
|
"coins_received": coins,
|
|
})))
|
|
}
|