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:
+29
-2
@@ -20,6 +20,7 @@
|
||||
//! - The whole thing commits together or not at all.
|
||||
|
||||
use crate::db::Pool;
|
||||
use crate::models::card::ContentKind;
|
||||
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
||||
use crate::services::card_db::CardDb;
|
||||
use crate::services::squad::squad_fingerprint;
|
||||
@@ -49,6 +50,15 @@ pub struct ImportOwnedCard {
|
||||
pub owned_item_id: String,
|
||||
/// CardDefinitionId that MUST resolve in loaded production content.
|
||||
pub card_id: String,
|
||||
/// Generic content classification. Absent = `player`, which is what every
|
||||
/// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17
|
||||
/// `cardsubtypeid`, resource ranges, …) onto this before calling Core.
|
||||
#[serde(default)]
|
||||
pub content_kind: ContentKind,
|
||||
/// Optional per-instance stack size (a consumable's wire `amount`). Absent /
|
||||
/// `null` means "not a stack"; it never collapses two instances into one row.
|
||||
#[serde(default)]
|
||||
pub quantity: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -127,6 +137,19 @@ pub async fn apply_profile_import(
|
||||
if req.owned.is_empty() {
|
||||
bail!("import request has zero owned cards; refusing to import an empty profile");
|
||||
}
|
||||
// A stack size is either absent ("not a stack") or a real positive count.
|
||||
// Reject an explicit 0/negative up front rather than letting the column
|
||||
// CHECK surface it as an opaque constraint failure mid-transaction.
|
||||
for o in &req.owned {
|
||||
if let Some(q) = o.quantity {
|
||||
if q < 1 {
|
||||
bail!(
|
||||
"owned card {} has quantity {q}; a stack size must be omitted or >= 1",
|
||||
o.owned_item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. rerun identity / single-profile-per-game ──
|
||||
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
@@ -242,13 +265,17 @@ pub async fn apply_profile_import(
|
||||
|
||||
for o in &req.owned {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||
"INSERT INTO owned_cards \
|
||||
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||
content_kind, quantity) \
|
||||
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
|
||||
)
|
||||
.bind(&o.owned_item_id)
|
||||
.bind(&club_id)
|
||||
.bind(&o.card_id)
|
||||
.bind(&now)
|
||||
.bind(o.content_kind.as_str())
|
||||
.bind(o.quantity)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||
|
||||
Reference in New Issue
Block a user