a45155e0c5
CI / Build, lint & test (push) Successful in 3m4s
FIFA 17 training cards boost ONE attribute of ONE owned player. Core gains
the state to hold that and the vocabulary to be asked for it, without
learning any FIFA rule.
`owned_card_training` (migration 0029) keys on (owned_card_id,
attribute_index), so a second training on a slot that already carries one is
a constraint violation rather than a silent choice between stacking and
replacing. Whether FIFA 17 stacks, replaces, merges or refuses is UNKNOWN --
no shipped table describes it and the client holds no consumable-effect
logic to reverse it from -- so the schema enforces the unknown and the apply
turns it into a refusal that consumes nothing. Relaxing that later is one
line; unpicking accumulated wrong state would not be.
`InstanceEffect::ApplyTraining { attribute_index, amount, max_amount }`
names a SLOT in Core's own six-attribute model, never a FIFA attribute: that
"GK speed is slot 4" is the adapter's reversed knowledge and stays there.
The caller declares its family's authored ceiling and Core holds it to it,
which is what stops a host describing a boost no card could grant through a
vocabulary that exists to prevent exactly that.
The immutable definition is never written. `/collection` gains
`effective_attributes` (base + training, clamped to the 1..=99 domain) and
the raw effects, loaded for the whole club in one query rather than the N+1
this projection has suffered before. The legacy `training_bonus` column --
an overall-rating upgrade written only by a non-transactional route no
adapter calls -- is deliberately not reused.
Tests cover the happy path, same-slot refusal leaving the card intact,
distinct slots coexisting, over-ceiling and out-of-range refusals, loan
refusal, replay, FK cascade, and two 12-round races: apply vs quick-sell on
one card, and two concurrent applies of one card. Both prove exactly one
winner, one effect, one audit row.
258 lines
9.2 KiB
Rust
258 lines
9.2 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, 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<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>(&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<OwnedItemView> = 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<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>(&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,
|
|
})))
|
|
}
|