//! FIFA 17 "My Squad" owned-player search: parse the RS4 `club` query and map //! FIFA 17 wire encodings to the **game-independent** semantic values OpenFUT //! Core understands. //! //! ## The boundary this enforces //! //! The FIFA 17 client sends its owned-player search as query params on //! `GET /ut/game/fifa17/club`, e.g. //! `?year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10`. //! Two encoding families appear: **string enums** (`level`, `rare`, `position`) //! and **numeric FIFA entity ids** (`nation`, `league`, `team`). //! //! **Numeric FIFA ids must never reach Core.** Core filters on semantic names //! ("Premier League", "Chelsea", "Argentina"), so this adapter resolves each id //! to a name via an injected [`EntityResolver`]. An id the resolver cannot map is //! a hard [`MapError`] — never a silent passthrough of the raw number, which is //! exactly how a game-specific id would leak into the generic layer. //! //! ## Evidence-grounded semantics (see vault Protocol Findings / Endpoint Map) //! //! * `level=gold` → semantic quality tier. Grounded in FIFA 17's own convention //! (`fut_cards.py::tier`, gold ≥ 75). `level=any` (the always-present default) //! → no quality constraint. //! * `position` / `nation` / `league` / `team` → applied. The Python oracle //! applied only `league`+`team`; applying the rest is a deliberate correction //! of a proven bug, not a guess (each maps to a card attribute Core already //! stores). FIFA `team` is Core `club`. //! * `start` / `count` → semantic `offset` / `limit`. The oracle ignored both and //! re-served page one forever; Core paginates for real. The client's 11-count / //! 10-step windowing is a UI convention and stays out of Core. //! * `sort=desc` → **dropped**. No sort key was ever proven (the oracle does not //! sort); Core imposes its own deterministic order. We do not invent a named //! FIFA sort mode. //! * `rare=SP` ("Special") → **UNKNOWN and unsupported.** The oracle never reads //! it and no committed metadata grounds "SP" to a card set. It is recorded in //! [`CoreOwnedQuery::unsupported`] and deliberately produces **no** Core filter. //! //! ## Decoupling //! //! This module does not depend on `openfut-core`. The contract between the two is //! the set of Core `/collection` query-parameter *names* emitted by //! [`CoreOwnedQuery::to_query_pairs`]; they mirror Core's `OwnedItemQuery` fields //! and are pinned by a test so drift is caught. use std::collections::HashMap; /// The FIFA 17 club-search query exactly as it arrives on the wire. Numeric /// fields are FIFA entity ids that MUST be resolved before reaching Core. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Fifa17ClubQuery { /// Quality filter: `any` (default, always present) or `gold`. pub level: Option, /// "Special" filter (`SP`). Semantics UNKNOWN — never applied. pub rare: Option, /// Playing position, e.g. `ST`. pub position: Option, /// FIFA nation id (e.g. 52 = Argentina). pub nation: Option, /// FIFA league id (e.g. 13 = Premier League). pub league: Option, /// FIFA team id (e.g. 5 = Chelsea). Core calls this "club". pub team: Option, /// Client sort token (`desc`). No proven key; dropped. pub sort: Option, /// Pagination offset. pub start: Option, /// Pagination page size. pub count: Option, } /// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and /// numeric ids, but names in general may be percent-encoded. fn percent_decode(s: &str) -> String { let b = s.as_bytes(); let mut out = Vec::with_capacity(b.len()); let hex = |c: u8| (c as char).to_digit(16); let mut i = 0; while i < b.len() { match b[i] { b'+' => { out.push(b' '); i += 1; } b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) { (Some(hi), Some(lo)) => { out.push((hi * 16 + lo) as u8); i += 3; } _ => { out.push(b'%'); i += 1; } }, c => { out.push(c); i += 1; } } } String::from_utf8_lossy(&out).into_owned() } /// Parse the raw query string into a [`Fifa17ClubQuery`]. /// /// Order-independent by construction (each key sets its own field), so HTTP /// parameter order can never change the result. Unknown keys (`year`, `type`, …) /// are ignored. A present-but-unparseable numeric id is treated as absent (the /// retail client never sends one; absent is the safe, non-amplifying choice). pub fn parse_club_query(query: &str) -> Fifa17ClubQuery { let q = query.strip_prefix('?').unwrap_or(query); let mut out = Fifa17ClubQuery::default(); for pair in q.split('&').filter(|p| !p.is_empty()) { let (k, v) = match pair.split_once('=') { Some((k, v)) => (k, percent_decode(v)), None => (pair, String::new()), }; match k { "level" => out.level = Some(v), "rare" => out.rare = Some(v), "position" => out.position = Some(v), "nation" => out.nation = v.parse().ok(), "league" => out.league = v.parse().ok(), "team" => out.team = v.parse().ok(), "sort" => out.sort = Some(v), "start" => out.start = v.parse().ok(), "count" => out.count = v.parse().ok(), _ => {} } } out } /// Resolves FIFA 17 numeric entity ids to their semantic names. A real /// implementation reads the game's `leagues`/`teams`/`nations` tables; tests use /// [`StaticResolver`]. Returning `None` means "unknown id" and is fatal, by /// design — the raw id must not flow onward. pub trait EntityResolver { fn league_name(&self, id: u32) -> Option; fn nation_name(&self, id: u32) -> Option; fn team_name(&self, id: u32) -> Option; } /// A map-backed [`EntityResolver`] for tests and small deployments. #[derive(Debug, Default, Clone)] pub struct StaticResolver { pub leagues: HashMap, pub nations: HashMap, pub teams: HashMap, } impl EntityResolver for StaticResolver { fn league_name(&self, id: u32) -> Option { self.leagues.get(&id).cloned() } fn nation_name(&self, id: u32) -> Option { self.nations.get(&id).cloned() } fn team_name(&self, id: u32) -> Option { self.teams.get(&id).cloned() } } /// A FIFA id that no resolver could map. Fatal on purpose: never fall back to /// the raw id. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MapError { UnknownLeague(u32), UnknownNation(u32), UnknownTeam(u32), } impl std::fmt::Display for MapError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { MapError::UnknownLeague(id) => write!(f, "unknown FIFA league id {id}"), MapError::UnknownNation(id) => write!(f, "unknown FIFA nation id {id}"), MapError::UnknownTeam(id) => write!(f, "unknown FIFA team id {id}"), } } } impl std::error::Error for MapError {} /// The semantic query handed to OpenFUT Core. Contains only game-independent /// values: a quality tier string, entity **names**, and semantic offset/limit. /// No FIFA ids. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CoreOwnedQuery { pub quality: Option, pub position: Option, pub nation: Option, pub league: Option, pub club: Option, pub offset: Option, pub limit: Option, /// 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>, } impl CoreOwnedQuery { /// Core `/collection` query parameters. Param **names mirror** /// `openfut_core::services::inventory::OwnedItemQuery` and are the wire /// contract between this adapter and Core (pinned by test). `unsupported` /// filters are intentionally absent. pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> { let mut p = Vec::new(); if let Some(q) = &self.quality { p.push(("quality", q.clone())); } if let Some(x) = &self.position { p.push(("position", x.clone())); } if let Some(x) = &self.nation { p.push(("nation", x.clone())); } if let Some(x) = &self.league { p.push(("league", x.clone())); } if let Some(x) = &self.club { p.push(("club", x.clone())); } if let Some(x) = self.offset { p.push(("offset", x.to_string())); } if let Some(x) = self.limit { p.push(("limit", x.to_string())); } p } } /// Map a parsed FIFA 17 query to the semantic Core query, resolving every /// numeric id to a name. Any unknown id is a hard error — the raw id never flows /// through. pub fn map_to_core( q: &Fifa17ClubQuery, resolver: &impl EntityResolver, ) -> Result { // level: only the proven quality tiers map; "any"/absent → no constraint. let quality = match q.level.as_deref() { Some("gold") => Some("gold".to_string()), Some("silver") => Some("silver".to_string()), Some("bronze") => Some("bronze".to_string()), _ => None, }; // rare=SP: semantics UNKNOWN. Recorded, never turned into a filter. let mut unsupported = Vec::new(); if q.rare.is_some() { unsupported.push("rare"); } let position = q.position.as_ref().map(|p| p.to_uppercase()); let nation = match q.nation { Some(id) => Some( resolver .nation_name(id) .ok_or(MapError::UnknownNation(id))?, ), None => None, }; let league = match q.league { Some(id) => Some( resolver .league_name(id) .ok_or(MapError::UnknownLeague(id))?, ), None => None, }; // FIFA "team" is Core "club". let club = match q.team { Some(id) => Some(resolver.team_name(id).ok_or(MapError::UnknownTeam(id))?), None => None, }; Ok(CoreOwnedQuery { quality, position, nation, league, club, offset: q.start.map(|s| s as i64), limit: q.count.map(|c| c as i64), unsupported, }) } #[cfg(test)] mod tests { use super::*; fn resolver() -> StaticResolver { // Confirmed against fifa17-recon/data/tables/{leagues,teams,nations}.json. StaticResolver { leagues: HashMap::from([(13, "Premier League".to_string())]), nations: HashMap::from([(52, "Argentina".to_string())]), teams: HashMap::from([(5, "Chelsea".to_string())]), } } #[test] fn parse_full_query() { let q = parse_club_query( "year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10", ); assert_eq!( q, Fifa17ClubQuery { level: Some("gold".into()), rare: None, position: Some("ST".into()), nation: Some(52), league: Some(13), team: Some(5), sort: Some("desc".into()), start: Some(10), count: Some(11), } ); } #[test] fn parse_is_parameter_order_independent() { let a = parse_club_query("level=gold&league=13&position=ST&start=10&count=11"); let b = parse_club_query("count=11&start=10&position=ST&league=13&level=gold"); assert_eq!(a, b); } #[test] fn parse_ignores_unknown_keys_and_omitted_optionals() { let q = parse_club_query("year=2017&type=player&level=any&sort=desc"); assert_eq!(q.level.as_deref(), Some("any")); assert!(q.rare.is_none() && q.position.is_none() && q.nation.is_none()); assert!(q.league.is_none() && q.team.is_none() && q.start.is_none()); } #[test] fn parse_percent_encoded_value() { let q = parse_club_query("position=ST&rare=SP"); assert_eq!(q.position.as_deref(), Some("ST")); assert_eq!(q.rare.as_deref(), Some("SP")); } #[test] fn parse_malformed_numeric_is_absent() { let q = parse_club_query("league=notanumber"); assert!( q.league.is_none(), "malformed id treated as absent, not applied" ); } #[test] fn map_level_gold_to_quality() { let core = map_to_core(&parse_club_query("level=gold"), &resolver()).unwrap(); assert_eq!(core.quality.as_deref(), Some("gold")); } #[test] fn map_level_any_has_no_quality_filter() { let core = map_to_core(&parse_club_query("level=any"), &resolver()).unwrap(); assert_eq!(core.quality, None, "'any' must NOT become a quality filter"); } #[test] fn map_rare_sp_is_unsupported_not_a_filter() { 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 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_resolves_ids_to_semantic_names() { let core = map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap(); assert_eq!(core.nation.as_deref(), Some("Argentina")); assert_eq!(core.league.as_deref(), Some("Premier League")); assert_eq!( core.club.as_deref(), Some("Chelsea"), "FIFA team -> Core club" ); } #[test] fn map_unknown_id_is_a_hard_error_not_passthrough() { assert_eq!( map_to_core(&parse_club_query("league=9999"), &resolver()), Err(MapError::UnknownLeague(9999)) ); assert_eq!( map_to_core(&parse_club_query("nation=9999"), &resolver()), Err(MapError::UnknownNation(9999)) ); assert_eq!( map_to_core(&parse_club_query("team=9999"), &resolver()), Err(MapError::UnknownTeam(9999)) ); } #[test] fn no_raw_fifa_id_ever_reaches_core() { // Every resolvable id becomes a name; a numeric string must never appear // as a nation/league/club value in the Core-bound pairs. let core = map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap(); for (k, v) in core.to_query_pairs() { if matches!(k, "nation" | "league" | "club") { assert!( v.parse::().is_err(), "{k}={v} looks like a raw FIFA id leaking into Core" ); } } } #[test] fn map_start_count_to_offset_limit() { let core = map_to_core(&parse_club_query("start=20&count=11"), &resolver()).unwrap(); assert_eq!(core.offset, Some(20)); assert_eq!(core.limit, Some(11)); } #[test] fn map_position_uppercased() { let core = map_to_core(&parse_club_query("position=st"), &resolver()).unwrap(); assert_eq!(core.position.as_deref(), Some("ST")); } #[test] fn sort_is_dropped_no_core_param() { let core = map_to_core(&parse_club_query("sort=desc"), &resolver()).unwrap(); let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); assert!( !keys.contains(&"sort"), "no proven FIFA sort key; must not emit one" ); } #[test] fn core_query_param_names_mirror_core_contract() { // Pins the wire contract with openfut-core's OwnedItemQuery field names. let core = CoreOwnedQuery { quality: Some("gold".into()), position: Some("ST".into()), nation: Some("Argentina".into()), league: Some("Premier League".into()), club: Some("Chelsea".into()), offset: Some(10), limit: Some(11), unsupported: vec![], }; let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); assert_eq!( keys, ["quality", "position", "nation", "league", "club", "offset", "limit"] ); } #[test] fn end_to_end_capture_shaped_query() { // Mirrors the retail PAGINATION capture: PL + Chelsea, page 2. let core = map_to_core( &parse_club_query( "year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10", ), &resolver(), ) .unwrap(); assert_eq!( core, CoreOwnedQuery { quality: Some("gold".into()), position: None, nation: Some("Argentina".into()), league: Some("Premier League".into()), club: Some("Chelsea".into()), offset: Some(10), limit: Some(11), unsupported: vec![], } ); } }