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:
@@ -15,7 +15,7 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::card::Quality;
|
||||
use crate::models::card::{ContentKind, Quality};
|
||||
|
||||
/// Semantic owned-inventory query. All values are game-independent: a quality
|
||||
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
|
||||
@@ -25,6 +25,9 @@ pub struct OwnedItemQuery {
|
||||
/// Quality tier (gold/silver/bronze). Serialized lowercase.
|
||||
#[serde(default)]
|
||||
pub quality: Option<Quality>,
|
||||
/// Owned-content kind (player/consumable/kit/…). Serialized lowercase.
|
||||
#[serde(default)]
|
||||
pub content_kind: Option<ContentKind>,
|
||||
/// Playing position, e.g. "ST" (matched case-insensitively).
|
||||
#[serde(default)]
|
||||
pub position: Option<String>,
|
||||
@@ -47,8 +50,12 @@ pub struct OwnedItemQuery {
|
||||
|
||||
/// One owned item projected to the attributes needed for querying, plus the
|
||||
/// response body to hand back verbatim once it survives the filter+page.
|
||||
#[derive(Clone)]
|
||||
pub struct OwnedItemView {
|
||||
pub owned_card_id: String,
|
||||
/// What kind of content this instance is; lets a caller filter without
|
||||
/// re-deriving the taxonomy from definition fields.
|
||||
pub content_kind: ContentKind,
|
||||
/// Base card overall (drives quality tier).
|
||||
pub base_overall: u8,
|
||||
/// Effective overall (base + training bonus); drives ordering.
|
||||
@@ -78,6 +85,10 @@ pub struct QueryPage {
|
||||
/// Does an item satisfy every present filter (AND semantics)?
|
||||
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
||||
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
|
||||
let kind_ok = q
|
||||
.content_kind
|
||||
.map(|want| item.content_kind == want)
|
||||
.unwrap_or(true);
|
||||
let pos_ok = q
|
||||
.position
|
||||
.as_ref()
|
||||
@@ -98,7 +109,7 @@ fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
||||
.as_ref()
|
||||
.map(|c| item.club.eq_ignore_ascii_case(c))
|
||||
.unwrap_or(true);
|
||||
quality_ok && pos_ok && nation_ok && league_ok && club_ok
|
||||
quality_ok && kind_ok && pos_ok && nation_ok && league_ok && club_ok
|
||||
}
|
||||
|
||||
/// Apply the query: filter (AND) → deterministic order → paginate.
|
||||
@@ -152,6 +163,7 @@ mod tests {
|
||||
) -> OwnedItemView {
|
||||
OwnedItemView {
|
||||
owned_card_id: id.to_string(),
|
||||
content_kind: ContentKind::Player,
|
||||
base_overall: overall,
|
||||
effective_overall: overall as i64,
|
||||
position: position.to_string(),
|
||||
@@ -179,6 +191,52 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// A club holds mixed content; a caller asking for one kind must get exactly
|
||||
/// that kind, and the unfiltered read must still return everything.
|
||||
#[test]
|
||||
fn content_kind_filters_mixed_inventory() {
|
||||
let mut items = fixture();
|
||||
let mut kit = view("k", 0, "", "", "", "");
|
||||
kit.content_kind = ContentKind::Kit;
|
||||
let mut style = view("s", 0, "", "", "", "");
|
||||
style.content_kind = ContentKind::Consumable;
|
||||
items.push(kit);
|
||||
items.push(style);
|
||||
|
||||
let all = apply_query(items.clone(), &OwnedItemQuery::default());
|
||||
assert_eq!(all.total, 7, "no filter returns every kind");
|
||||
|
||||
let kits = apply_query(
|
||||
items.clone(),
|
||||
&OwnedItemQuery {
|
||||
content_kind: Some(ContentKind::Kit),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(ids(&kits), ["k"]);
|
||||
|
||||
let players = apply_query(
|
||||
items.clone(),
|
||||
&OwnedItemQuery {
|
||||
content_kind: Some(ContentKind::Player),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(players.total, 5);
|
||||
|
||||
let none = apply_query(
|
||||
items,
|
||||
&OwnedItemQuery {
|
||||
content_kind: Some(ContentKind::Stadium),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
none.total, 0,
|
||||
"a kind the club owns none of is empty, not everything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_filter_returns_all_in_overall_desc_order() {
|
||||
let p = apply_query(fixture(), &OwnedItemQuery::default());
|
||||
|
||||
Reference in New Issue
Block a user