6acae54f80
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.
Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.
Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
292 lines
9.4 KiB
Rust
Executable File
292 lines
9.4 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::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>,
|
|
/// 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.
|
|
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<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 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<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(),
|
|
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"),
|
|
]
|
|
}
|
|
|
|
#[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
|
|
}
|
|
}
|