8b1081019f
CI / Build, lint & test (push) Successful in 3m21s
Core could only own players. Everything else a FUT club holds — managers, staff, consumables, kits, badges, balls, stadiums — had no representation, so the only way to show one to a client was to synthesise it on read. That is the failure mode this commit exists to make impossible: read authority, write authority and persistent ownership authority are now the same rows. MODEL. There is deliberately NO parallel items table. A manager, a consumable, a kit and a player are all rows in `owned_cards`, differing only by a new game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball| stadium|misc). A game adapter translates its own taxonomy — FIFA 17's `cardsubtypeid` and resource ranges — into one of those tokens before ownership reaches Core; no game's numerics land here. Ownership stays INSTANCE-based: `card_id` is the definition, `id` is the instance, and two copies of one definition remain two rows. `quantity` is a nullable per-instance attribute, not a replacement for the instance. The real profile settles this: its 17 consumables are instance-based and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of definition 5003068 exist as two distinct instances. So NULL means "not a stack" and a positive integer is the stack size; collapsing instances into counts is forbidden by the model. ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes `club_active_items` over the five slots that correspond exactly to the client's recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101, activeAwayKit 102, activeBall 103, activeStadium 104). There is no activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The invariants are schema-enforced rather than conventional: PK(club_id, slot) allows at most one item per role, `owned_card_id UNIQUE` makes "the same card is both home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or consumed item cannot be projected back as active. 0024's trigger is preserved in semantics — and dropped EXPLICITLY before its table, because it lives ON `owned_cards`, so DROP TABLE would have orphaned it and broken every later ownership transfer. It still exists because the market moves ownership by UPDATE, which no foreign key can observe. CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive — validate source ownership and kind, validate target, mutate, consume the source exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in migration 0027, the same discipline as `match_completions`. It supports both deleting the row and decrementing a stack, chosen by the caller, inside the one transaction and the one replay guard. It deliberately contains NO category formulas: an unreversed effect must not be invented, so callers supply the mutation and category validation stays explicit. `/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection` now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and — importantly — stops dropping an owned card with a missing definition silently: the envelope reports `owned_rows`, `unresolved_items` and the offending definition ids. That silent `filter_map` is the documented cause of a club that looks empty while the rows are all present. Verified against a REAL populated club, not a fixture: the production snapshot (migration 19) is copied to a tempdir, migrated to 0024, given two kit designations on real owned instances, then migrated to head. 1986 owned rows survive as content_kind='player', both designations land in `club_active_items`, no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
119 lines
3.6 KiB
Rust
119 lines
3.6 KiB
Rust
use crate::{
|
||
db::Pool,
|
||
error::{AppError, AppResult},
|
||
models::card::{OwnedCard, OWNED_CARD_SELECT},
|
||
models::chemistry_style::ChemistryStyle,
|
||
};
|
||
|
||
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<OwnedCard> {
|
||
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<OwnedCard> {
|
||
// 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<OwnedCard> {
|
||
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<OwnedCard> {
|
||
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
|
||
}
|