feat(core): one instance-based ownership model for every kind of owned content
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.
This commit is contained in:
funman300
2026-08-21 19:10:54 +00:00
parent bae0a2bdaa
commit 8b1081019f
20 changed files with 2513 additions and 248 deletions
+75 -43
View File
@@ -9,7 +9,7 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
models::card::OwnedCard,
models::card::{OwnedCard, OWNED_CARD_SELECT},
services::{
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
@@ -114,45 +114,73 @@ pub async fn get_collection(
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 owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} 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();
// 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);
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,
"effective_overall": effective_overall,
"effective_position": effective_position,
"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!({
@@ -161,6 +189,12 @@ pub async fn get_collection(
"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,
})))
}
@@ -173,11 +207,9 @@ pub async fn delete_owned_card(
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 = ?",
)
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)