From 6f16a231fc19bf5c778a9ae8400925736630e293 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 21:25:18 +0000 Subject: [PATCH] fix(fifa17): implement rare=SP 'Special' club filter via rareflag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The club search 'Quality = Special' sends rare=SP, which was a deliberate no-op ('semantics UNKNOWN'), so it returned every card — base golds included. The rareflag work now grounds it: a special is rareflag > 1 (base rare = 1), evidence-backed by the FIFA17 taxonomy + the observed profile (base Ronaldo/Messi rareflag 1; their informs 11/24). rareflag lives in the FIFA catalog, not Core, so Core cannot filter it: - map_to_core: rare=SP now sets CoreOwnedQuery.special (host-applied), not 'unsupported'; any OTHER rare value stays unsupported. special is NEVER a Core /collection param. is_special_rareflag(rf)=rf>1 lives in the adapter. - handle_club special path: fetch all items matching the OTHER filters (offset/ limit stripped), shape (resolves rareflag), then special_filter_page() keeps rareflag>1 and paginates the FILTERED set locally (start/count over specials, not Core's unfiltered page) — no base leakage, no post-pagination drops. Verified live on the real staged club: rare=SP -> 1665 items (=1949-284 base), rareflag distribution all >1, zero base leaked; pagination page0==full[0:50], page1==full[50:100], no overlap. Tests: adapter map rare=SP->special, unknown rare stays unsupported, is_special_rareflag predicate; host special_filter_page filter+paginate. adapter 113 + host 25 + importer 25 green; clippy -D clean. --- openfut-adapter-fifa17/src/fut/owned_query.rs | 49 ++++++-- openfut-utas-host/src/lib.rs | 118 +++++++++++++++++- 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/owned_query.rs b/openfut-adapter-fifa17/src/fut/owned_query.rs index 0955187..dc89951 100644 --- a/openfut-adapter-fifa17/src/fut/owned_query.rs +++ b/openfut-adapter-fifa17/src/fut/owned_query.rs @@ -193,6 +193,10 @@ pub struct CoreOwnedQuery { pub club: Option, pub offset: Option, pub limit: Option, + /// "Special" filter (`rare=SP`): keep only special cards. Applied by the + /// HOST via the FIFA `rareflag` (which lives in the catalog, not Core) — so + /// it is NEVER a Core `/collection` param. See [`is_special_rareflag`]. + pub special: bool, /// Wire filters that were parsed but deliberately NOT applied because their /// semantics are unproven (currently: `rare`/Special). Recorded, never guessed. pub unsupported: Vec<&'static str>, @@ -245,9 +249,13 @@ pub fn map_to_core( _ => None, }; - // rare=SP: semantics UNKNOWN. Recorded, never turned into a filter. + // rare=SP → "Special" quality. Now GROUNDED via the observed FIFA rareflag + // (carried in the catalog): a special is rareflag > 1 (base rare = 1). The + // host applies it post-shape; Core never sees it. Any OTHER `rare` value + // stays genuinely unsupported (recorded, never guessed). + let special = matches!(q.rare.as_deref(), Some(s) if s.eq_ignore_ascii_case("SP")); let mut unsupported = Vec::new(); - if q.rare.is_some() { + if q.rare.is_some() && !special { unsupported.push("rare"); } @@ -283,10 +291,19 @@ pub fn map_to_core( club, offset: q.start.map(|s| s as i64), limit: q.count.map(|c| c as i64), + special, unsupported, }) } +/// Whether a FIFA `rareflag` denotes a SPECIAL card (in-form/programme), as +/// opposed to a base card. Grounded in the observed profile + the FIFA 17 +/// taxonomy: 0 = common, 1 = rare (both BASE gold/silver/bronze); every value +/// above 1 is a special programme (3 = TOTW, 21..=24 = programmes, etc.). +pub fn is_special_rareflag(rareflag: i64) -> bool { + rareflag > 1 +} + #[cfg(test)] mod tests { use super::*; @@ -365,18 +382,32 @@ mod tests { } #[test] - fn map_rare_sp_is_unsupported_not_a_filter() { + fn map_rare_sp_sets_special_and_never_a_core_param() { let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap(); - assert!( - core.unsupported.contains(&"rare"), - "rare must be recorded unsupported" - ); - // never guessed into a Core predicate + // rare=SP is now GROUNDED: a host-applied special flag, not "unsupported". + assert!(core.special, "rare=SP must set the special flag"); + assert!(!core.unsupported.contains(&"rare")); + // still NEVER a Core predicate (Core has no rareflag) and no quality guess. assert_eq!(core.quality, None); let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); assert!(!keys.contains(&"rare") && !keys.contains(&"special")); } + #[test] + fn map_unknown_rare_value_stays_unsupported() { + let core = map_to_core(&parse_club_query("rare=WAT"), &resolver()).unwrap(); + assert!(!core.special); + assert!(core.unsupported.contains(&"rare")); + } + + #[test] + fn special_predicate_base_vs_special() { + assert!(!is_special_rareflag(0)); // common + assert!(!is_special_rareflag(1)); // rare gold (base) + assert!(is_special_rareflag(3)); // TOTW + assert!(is_special_rareflag(24)); // programme + } + #[test] fn map_resolves_ids_to_semantic_names() { let core = @@ -456,6 +487,7 @@ mod tests { club: Some("Chelsea".into()), offset: Some(10), limit: Some(11), + special: false, unsupported: vec![], }; let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); @@ -485,6 +517,7 @@ mod tests { club: Some("Chelsea".into()), offset: Some(10), limit: Some(11), + special: false, unsupported: vec![], } ); diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 5b9e5b2..74aaf38 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -45,7 +45,9 @@ use openfut_adapter_fifa17::fut::club_response::{ shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, }; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; -use openfut_adapter_fifa17::fut::owned_query::{map_to_core, parse_club_query, MapError}; +use openfut_adapter_fifa17::fut::owned_query::{ + is_special_rareflag, map_to_core, parse_club_query, MapError, +}; use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver}; use openfut_adapter_fifa17::fut::squad_ext::{ build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION, @@ -589,6 +591,38 @@ pub struct ClubDeps<'a> { pub assets: &'a (dyn ItemIdentityResolver + Send + Sync), } +/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate +/// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole +/// point is that "special" pagination is over the filtered set, never Core's +/// unfiltered page (which would drop specials or leak base cards). +fn special_filter_page( + items: &[Value], + offset: Option, + limit: Option, +) -> (Vec, i64) { + let specials: Vec<&Value> = items + .iter() + .filter(|it| { + it.get("rareflag") + .and_then(|v| v.as_i64()) + .map(is_special_rareflag) + .unwrap_or(false) + }) + .collect(); + let total = specials.len() as i64; + let off = offset.unwrap_or(0).max(0) as usize; + let paged: Vec = match limit { + Some(l) => specials + .into_iter() + .skip(off) + .take(l.max(0) as usize) + .cloned() + .collect(), + None => specials.into_iter().skip(off).cloned().collect(), + }; + (paged, total) +} + /// Handle `GET …/club?…` end to end: parse → map ids to names → Core query → /// shape to the FIFA `{itemData:[…]}` envelope. Always returns HTTP 200 with a /// JSON body (UTAS must never 401/403; an empty result is the safe degrade). @@ -615,6 +649,55 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) let pairs = core_q.to_query_pairs(); let filter = summarize(&pairs); let (offset, limit) = (core_q.offset, core_q.limit); + + // "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core, so Core + // cannot filter it. Fetch everything matching the OTHER filters (no + // offset/limit), shape (which resolves each item's rareflag), keep only + // specials (rareflag > 1), then paginate the filtered set locally. + if core_q.special { + let mut base = core_q.clone(); + base.offset = None; + base.limit = None; + return match deps.core.query_owned(&base.to_query_pairs()) { + Ok(page) => { + let (body, stats) = shape_club_response(&page.items, deps.entities, deps.assets); + let all = body + .get("itemData") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let (paged, total) = special_filter_page(&all, offset, limit); + let emitted = paged.len(); + ( + json_response(&json!({ "itemData": paged })), + ClubLog { + outcome: "ok", + filter: format!("{filter},rare=SP"), + total, + emitted, + dropped_no_asset: stats.dropped_no_asset, + offset, + limit, + }, + ) + } + Err(e) => { + eprintln!("utas-host ERROR /club (special) core query failed: {e}"); + ( + json_response(&json!({ "itemData": [] })), + ClubLog { + outcome: "core_error", + filter: format!("{filter},rare=SP"), + total: 0, + emitted: 0, + dropped_no_asset: 0, + offset, + limit, + }, + ) + } + }; + } match deps.core.query_owned(&pairs) { Ok(page) => { let (body, stats): (Value, ShapeStats) = @@ -1470,6 +1553,39 @@ fn write_response(w: &mut W, resp: &WireResponse) -> std::io::Result<( mod tests { use super::*; + #[test] + fn special_filter_keeps_only_specials_and_paginates_filtered_set() { + let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf }); + // base rare(1)/common(0) interleaved with specials(3,11,24). + let items = vec![mk(1, 1), mk(2, 3), mk(3, 1), mk(4, 24), mk(5, 0), mk(6, 11)]; + let (all, total) = special_filter_page(&items, None, None); + assert_eq!(total, 3, "only rareflag>1 counted"); + assert!(all.iter().all(|it| it["rareflag"].as_i64().unwrap() > 1)); + assert_eq!( + all.iter() + .map(|it| it["id"].as_i64().unwrap()) + .collect::>(), + vec![2, 4, 6], + "base rare/common excluded, order preserved" + ); + // pagination is over the FILTERED set, no overlap, no base leakage. + let (p0, t0) = special_filter_page(&items, Some(0), Some(2)); + let (p1, t1) = special_filter_page(&items, Some(2), Some(2)); + assert_eq!((t0, t1), (3, 3)); + assert_eq!( + p0.iter() + .map(|it| it["id"].as_i64().unwrap()) + .collect::>(), + vec![2, 4] + ); + assert_eq!( + p1.iter() + .map(|it| it["id"].as_i64().unwrap()) + .collect::>(), + vec![6] + ); + } + #[test] fn classify_club_only_on_exact_get() { assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);