Files
OpenFUT-Core/src/services/inventory.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

350 lines
11 KiB
Rust
Executable File

//! Game-independent owned-inventory query: semantic filtering, deterministic
//! ordering, and offset/limit pagination over a club's owned items.
//!
//! This layer is deliberately free of any game-specific concepts. It never sees
//! raw FIFA (or any other game's) numeric entity ids — a game adapter is
//! responsible for translating its wire query into the *semantic* values here
//! (quality tier, entity **names**, semantic offset/limit). The canonical
//! ordering is imposed by Core so pagination is correct and repeatable
//! regardless of what (if any) sort the client requests; see the module tests
//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN.
//!
//! Order of operations is load-bearing: **filter → order → paginate**. Paginating
//! before filtering is the production bug this replaces (a client that pages an
//! unfiltered/unsorted set re-reads page one forever and amplifies requests).
use serde::Deserialize;
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
/// optional; combined filters are ANDed. Absent field = no constraint.
#[derive(Debug, Default, Deserialize)]
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>,
/// Nation name, e.g. "Argentina" (matched case-insensitively).
#[serde(default)]
pub nation: Option<String>,
/// League name, e.g. "Premier League" (matched case-insensitively).
#[serde(default)]
pub league: Option<String>,
/// Club name, e.g. "Chelsea" (matched case-insensitively).
#[serde(default)]
pub club: Option<String>,
/// Number of leading items to skip after filtering + ordering.
#[serde(default)]
pub offset: Option<i64>,
/// Maximum number of items to return in the page.
#[serde(default)]
pub limit: Option<i64>,
}
/// 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.
pub effective_overall: i64,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub body: serde_json::Value,
}
impl OwnedItemView {
fn quality(&self) -> Quality {
Quality::from_overall(self.base_overall)
}
}
/// Result of applying a query: the requested page plus the count of items that
/// matched the filter **before** pagination (what a client needs to page).
pub struct QueryPage {
pub items: Vec<serde_json::Value>,
pub total: usize,
pub offset: usize,
pub limit: Option<usize>,
}
/// 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()
.map(|p| item.position.eq_ignore_ascii_case(p))
.unwrap_or(true);
let nation_ok = q
.nation
.as_ref()
.map(|n| item.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = q
.league
.as_ref()
.map(|l| item.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = q
.club
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && kind_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
///
/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so
/// pages never overlap or repeat. `offset`/`limit` are clamped to sane
/// non-negative values (the wire never sends negatives; clamping keeps a
/// malformed request from panicking).
pub fn apply_query(mut items: Vec<OwnedItemView>, q: &OwnedItemQuery) -> QueryPage {
// 1. filter
items.retain(|it| matches(it, q));
let total = items.len();
// 2. deterministic total order (independent of input/DB order)
items.sort_by(|a, b| {
b.effective_overall
.cmp(&a.effective_overall)
.then_with(|| a.owned_card_id.cmp(&b.owned_card_id))
});
// 3. paginate
let offset = q.offset.unwrap_or(0).max(0) as usize;
let limit = q.limit.map(|l| l.max(0) as usize);
let page: Vec<serde_json::Value> = items
.into_iter()
.skip(offset)
.take(limit.unwrap_or(usize::MAX))
.map(|it| it.body)
.collect();
QueryPage {
items: page,
total,
offset,
limit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn view(
id: &str,
overall: u8,
position: &str,
nation: &str,
league: &str,
club: &str,
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
content_kind: ContentKind::Player,
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
nation: nation.to_string(),
league: league.to_string(),
club: club.to_string(),
body: json!({ "owned_card_id": id, "overall": overall }),
}
}
fn ids(page: &QueryPage) -> Vec<String> {
page.items
.iter()
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
.collect()
}
fn fixture() -> Vec<OwnedItemView> {
vec![
view("a", 84, "ST", "England", "Premier League", "Northgate"),
view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"),
view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"),
view("d", 60, "CM", "Italy", "Serie B", "Modena"),
view("e", 89, "LW", "Argentina", "Primera Division", "Boca"),
]
}
/// 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());
assert_eq!(p.total, 5);
assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60
}
#[test]
fn quality_gold_selects_overall_75_plus() {
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["e", "b", "a"]);
}
#[test]
fn filters_are_anded() {
let q = OwnedItemQuery {
league: Some("Premier League".into()),
position: Some("ST".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM
}
#[test]
fn case_insensitive_name_match() {
let q = OwnedItemQuery {
club: Some("chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn empty_when_nothing_matches() {
let q = OwnedItemQuery {
nation: Some("Argentina".into()),
club: Some("Chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 0);
assert!(p.items.is_empty());
}
#[test]
fn filter_runs_before_pagination() {
// Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b].
// If pagination ran first, offset/limit would slice the full 5-item set.
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
offset: Some(1),
limit: Some(1),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 3, "total is the filtered count, not the page size");
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn pages_do_not_overlap_and_advance() {
let page = |off| {
apply_query(
fixture(),
&OwnedItemQuery {
offset: Some(off),
limit: Some(2),
..Default::default()
},
)
};
let p0 = page(0);
let p1 = page(2);
assert_eq!(ids(&p0), ["e", "b"]);
assert_eq!(ids(&p1), ["a", "c"]);
// start advancing must NOT re-serve page one
assert_ne!(ids(&p0), ids(&p1));
assert_eq!(p0.total, 5);
assert_eq!(p1.total, 5);
}
#[test]
fn offset_past_end_is_empty_not_wrapped() {
let q = OwnedItemQuery {
offset: Some(100),
limit: Some(11),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert!(p.items.is_empty());
assert_eq!(p.total, 5);
}
#[test]
fn ordering_is_stable_on_overall_ties() {
let items = vec![
view("z", 80, "ST", "N", "L", "C"),
view("a", 80, "ST", "N", "L", "C"),
view("m", 80, "ST", "N", "L", "C"),
];
let p = apply_query(items, &OwnedItemQuery::default());
assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc
}
}