fix(fifa17): implement rare=SP 'Special' club filter via rareflag
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.
This commit is contained in:
@@ -193,6 +193,10 @@ pub struct CoreOwnedQuery {
|
|||||||
pub club: Option<String>,
|
pub club: Option<String>,
|
||||||
pub offset: Option<i64>,
|
pub offset: Option<i64>,
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
|
/// "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
|
/// Wire filters that were parsed but deliberately NOT applied because their
|
||||||
/// semantics are unproven (currently: `rare`/Special). Recorded, never guessed.
|
/// semantics are unproven (currently: `rare`/Special). Recorded, never guessed.
|
||||||
pub unsupported: Vec<&'static str>,
|
pub unsupported: Vec<&'static str>,
|
||||||
@@ -245,9 +249,13 @@ pub fn map_to_core(
|
|||||||
_ => None,
|
_ => 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();
|
let mut unsupported = Vec::new();
|
||||||
if q.rare.is_some() {
|
if q.rare.is_some() && !special {
|
||||||
unsupported.push("rare");
|
unsupported.push("rare");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +291,19 @@ pub fn map_to_core(
|
|||||||
club,
|
club,
|
||||||
offset: q.start.map(|s| s as i64),
|
offset: q.start.map(|s| s as i64),
|
||||||
limit: q.count.map(|c| c as i64),
|
limit: q.count.map(|c| c as i64),
|
||||||
|
special,
|
||||||
unsupported,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -365,18 +382,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap();
|
||||||
assert!(
|
// rare=SP is now GROUNDED: a host-applied special flag, not "unsupported".
|
||||||
core.unsupported.contains(&"rare"),
|
assert!(core.special, "rare=SP must set the special flag");
|
||||||
"rare must be recorded unsupported"
|
assert!(!core.unsupported.contains(&"rare"));
|
||||||
);
|
// still NEVER a Core predicate (Core has no rareflag) and no quality guess.
|
||||||
// never guessed into a Core predicate
|
|
||||||
assert_eq!(core.quality, None);
|
assert_eq!(core.quality, None);
|
||||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||||
assert!(!keys.contains(&"rare") && !keys.contains(&"special"));
|
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]
|
#[test]
|
||||||
fn map_resolves_ids_to_semantic_names() {
|
fn map_resolves_ids_to_semantic_names() {
|
||||||
let core =
|
let core =
|
||||||
@@ -456,6 +487,7 @@ mod tests {
|
|||||||
club: Some("Chelsea".into()),
|
club: Some("Chelsea".into()),
|
||||||
offset: Some(10),
|
offset: Some(10),
|
||||||
limit: Some(11),
|
limit: Some(11),
|
||||||
|
special: false,
|
||||||
unsupported: vec![],
|
unsupported: vec![],
|
||||||
};
|
};
|
||||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||||
@@ -485,6 +517,7 @@ mod tests {
|
|||||||
club: Some("Chelsea".into()),
|
club: Some("Chelsea".into()),
|
||||||
offset: Some(10),
|
offset: Some(10),
|
||||||
limit: Some(11),
|
limit: Some(11),
|
||||||
|
special: false,
|
||||||
unsupported: vec![],
|
unsupported: vec![],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ use openfut_adapter_fifa17::fut::club_response::{
|
|||||||
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
||||||
};
|
};
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
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::{parse_squad_put, save_ack, SquadWireResolver};
|
||||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
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),
|
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<i64>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
) -> (Vec<Value>, 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<Value> = 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 →
|
/// 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
|
/// 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).
|
/// 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 pairs = core_q.to_query_pairs();
|
||||||
let filter = summarize(&pairs);
|
let filter = summarize(&pairs);
|
||||||
let (offset, limit) = (core_q.offset, core_q.limit);
|
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) {
|
match deps.core.query_owned(&pairs) {
|
||||||
Ok(page) => {
|
Ok(page) => {
|
||||||
let (body, stats): (Value, ShapeStats) =
|
let (body, stats): (Value, ShapeStats) =
|
||||||
@@ -1470,6 +1553,39 @@ fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<_>>(),
|
||||||
|
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<_>>(),
|
||||||
|
vec![2, 4]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p1.iter()
|
||||||
|
.map(|it| it["id"].as_i64().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![6]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_club_only_on_exact_get() {
|
fn classify_club_only_on_exact_get() {
|
||||||
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
||||||
|
|||||||
Reference in New Issue
Block a user