c0a3f68ded
openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS); route classification before execution; a Core error on /club degrades to an empty page and never falls back to Python. CoreAccess is a host-owned boundary (the adapter stays transport-agnostic). openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping, unknown id = hard error), entities (id<->name from committed tables), and club_response (FIFA _item shaping; drops items lacking a real FIFA asset id, never fabricates one). openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116 multi-game + eab522a replace_squad/SquadRules + the /club semantic query). 11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN. Retail rendering of Core inventory still blocked on the Core-card->asset-id identity decision (next phase).
493 lines
18 KiB
Rust
493 lines
18 KiB
Rust
//! 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<String>,
|
|
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
|
|
pub rare: Option<String>,
|
|
/// Playing position, e.g. `ST`.
|
|
pub position: Option<String>,
|
|
/// FIFA nation id (e.g. 52 = Argentina).
|
|
pub nation: Option<u32>,
|
|
/// FIFA league id (e.g. 13 = Premier League).
|
|
pub league: Option<u32>,
|
|
/// FIFA team id (e.g. 5 = Chelsea). Core calls this "club".
|
|
pub team: Option<u32>,
|
|
/// Client sort token (`desc`). No proven key; dropped.
|
|
pub sort: Option<String>,
|
|
/// Pagination offset.
|
|
pub start: Option<u32>,
|
|
/// Pagination page size.
|
|
pub count: Option<u32>,
|
|
}
|
|
|
|
/// 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<String>;
|
|
fn nation_name(&self, id: u32) -> Option<String>;
|
|
fn team_name(&self, id: u32) -> Option<String>;
|
|
}
|
|
|
|
/// A map-backed [`EntityResolver`] for tests and small deployments.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct StaticResolver {
|
|
pub leagues: HashMap<u32, String>,
|
|
pub nations: HashMap<u32, String>,
|
|
pub teams: HashMap<u32, String>,
|
|
}
|
|
|
|
impl EntityResolver for StaticResolver {
|
|
fn league_name(&self, id: u32) -> Option<String> {
|
|
self.leagues.get(&id).cloned()
|
|
}
|
|
fn nation_name(&self, id: u32) -> Option<String> {
|
|
self.nations.get(&id).cloned()
|
|
}
|
|
fn team_name(&self, id: u32) -> Option<String> {
|
|
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<String>,
|
|
pub position: Option<String>,
|
|
pub nation: Option<String>,
|
|
pub league: Option<String>,
|
|
pub club: Option<String>,
|
|
pub offset: Option<i64>,
|
|
pub limit: Option<i64>,
|
|
/// 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<CoreOwnedQuery, MapError> {
|
|
// 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::<u32>().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![],
|
|
}
|
|
);
|
|
}
|
|
}
|