Files
OpenFUT-Core/src/services/upgrades.rs
T
funman300 8b1081019f
CI / Build, lint & test (push) Successful in 3m21s
feat(core): one instance-based ownership model for every kind of owned content
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.
2026-08-21 19:10:54 +00:00

119 lines
3.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (13).
/// 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
}