8b1081019f
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.
350 lines
11 KiB
Rust
Executable File
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
|
|
}
|
|
}
|