//! 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::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, /// Playing position, e.g. "ST" (matched case-insensitively). #[serde(default)] pub position: Option, /// Nation name, e.g. "Argentina" (matched case-insensitively). #[serde(default)] pub nation: Option, /// League name, e.g. "Premier League" (matched case-insensitively). #[serde(default)] pub league: Option, /// Club name, e.g. "Chelsea" (matched case-insensitively). #[serde(default)] pub club: Option, /// Number of leading items to skip after filtering + ordering. #[serde(default)] pub offset: Option, /// Maximum number of items to return in the page. #[serde(default)] pub limit: Option, } /// One owned item projected to the attributes needed for querying, plus the /// response body to hand back verbatim once it survives the filter+page. pub struct OwnedItemView { pub owned_card_id: String, /// 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, pub total: usize, pub offset: usize, pub limit: Option, } /// 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 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 && 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, 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 = 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(), 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 { page.items .iter() .map(|b| b["owned_card_id"].as_str().unwrap().to_string()) .collect() } fn fixture() -> Vec { 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"), ] } #[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 } }