feat(core): one instance-based ownership model for every kind of owned content
CI / Build, lint & test (push) Successful in 3m21s
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:
+75
-43
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user