feat(utas): FIFA17 UTAS migration host + /club adapter mappings
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).
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
|
||||
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
||||
//!
|
||||
//! The per-item field set replicates `fifa17-recon/tools/fut_store.py::_item`
|
||||
//! (the proven-safe player item), reversed onto Core's semantic values.
|
||||
//!
|
||||
//! ## The asset-id boundary (load-bearing, evidence-grounded)
|
||||
//!
|
||||
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
|
||||
//! OWN local players table (`fut_cards.py:11-21`, proven live): a real id renders
|
||||
//! a real footballer, an **invented id renders a blank generic card**. OpenFUT
|
||||
//! Core's catalogue is synthetic string ids (`card_pl_001`) with no FIFA asset
|
||||
//! id, and no committed card→asset mapping exists. So an [`ItemIdentityResolver`]
|
||||
//! is injected; when it cannot supply a **real** FIFA asset id for an item, that
|
||||
//! item is **dropped and counted** — never emitted with a fabricated id. This
|
||||
//! keeps the response freeze-safe and honest until the Core-card→asset identity
|
||||
//! decision is made (the current blocker for retail rendering of Core inventory).
|
||||
//!
|
||||
//! Entity ids (`leagueId`/`teamid`/`nation`) come from the reverse resolver; an
|
||||
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
|
||||
//! "no badge/flag"), because those are not the identity the renderer keys on.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's `/collection`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreOwnedItem {
|
||||
/// Core owned-instance id (string).
|
||||
pub owned_card_id: String,
|
||||
/// Core card-definition id (string), used for asset resolution.
|
||||
pub card_id: String,
|
||||
/// Effective overall rating.
|
||||
pub rating: u8,
|
||||
/// Effective position, e.g. "ST".
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
|
||||
/// instance id used for later item operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17Identity {
|
||||
pub item_id: u32,
|
||||
pub asset_id: u32,
|
||||
}
|
||||
|
||||
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
|
||||
/// "no real FIFA asset id known" → the item is dropped (never faked).
|
||||
pub trait ItemIdentityResolver {
|
||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||
}
|
||||
|
||||
/// Diagnostics from shaping (safe to log — counts only).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
pub dropped_no_asset: usize,
|
||||
}
|
||||
|
||||
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
||||
/// non-fatal display field).
|
||||
fn discard_value(rating: u8) -> i64 {
|
||||
match rating {
|
||||
r if r >= 85 => 1500,
|
||||
r if r >= 80 => 900,
|
||||
r if r >= 75 => 600,
|
||||
r if r >= 65 => 300,
|
||||
_ => 150,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one FIFA `_item` object (version byte 0x00 → `resourceId == assetId`).
|
||||
fn shape_item(item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver) -> Value {
|
||||
let asset = id.asset_id;
|
||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||
let team_id = ent.team_id(&item.club).unwrap_or(0);
|
||||
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
|
||||
let attribute_list: Vec<Value> = item
|
||||
.attributes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| json!({ "index": i, "value": v }))
|
||||
.collect();
|
||||
json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": asset,
|
||||
"assetId": asset,
|
||||
"cardassetid": asset,
|
||||
"definitionId": asset,
|
||||
"cardsubtypeid": 0,
|
||||
"itemType": "player",
|
||||
"rareflag": 1,
|
||||
"rating": item.rating,
|
||||
"preferredPosition": item.position,
|
||||
"nation": nation_id,
|
||||
"teamid": team_id,
|
||||
"leagueId": league_id,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": true,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value(item.rating),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shape the whole `/club` response. Items without a resolvable real asset id
|
||||
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
|
||||
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
ident: &I,
|
||||
) -> (Value, ShapeStats) {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut stats = ShapeStats::default();
|
||||
for item in items {
|
||||
match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": out }), stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
)
|
||||
}
|
||||
|
||||
fn item(
|
||||
owned: &str,
|
||||
card: &str,
|
||||
rating: u8,
|
||||
pos: &str,
|
||||
nation: &str,
|
||||
league: &str,
|
||||
club: &str,
|
||||
) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned.into(),
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: pos.into(),
|
||||
nation: nation.into(),
|
||||
league: league.into(),
|
||||
club: club.into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
}
|
||||
}
|
||||
|
||||
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
|
||||
/// for the (unresolved-in-production) Core-card→asset mapping.
|
||||
struct MapIdentity(HashMap<String, Fifa17Identity>);
|
||||
impl ItemIdentityResolver for MapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_item_with_full_field_set_and_reverse_ids() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::from([(
|
||||
"card_ch_1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
},
|
||||
)]));
|
||||
let items = vec![item(
|
||||
"oc1",
|
||||
"card_ch_1",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
)];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let it = &body["itemData"][0];
|
||||
assert_eq!(it["id"], 100000001);
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
assert_eq!(it["assetId"], 20801);
|
||||
assert_eq!(
|
||||
it["definitionId"], 20801,
|
||||
"version byte 0 => resourceId==assetId==definitionId"
|
||||
);
|
||||
assert_eq!(it["rating"], 86);
|
||||
assert_eq!(it["preferredPosition"], "CDM");
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["teamid"], 5);
|
||||
assert_eq!(it["nation"], 52);
|
||||
assert_eq!(it["itemType"], "player");
|
||||
assert_eq!(it["rareflag"], 1);
|
||||
assert_eq!(it["contract"], 7);
|
||||
assert_eq!(it["fitness"], 99);
|
||||
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
||||
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_items_without_a_real_asset_id_never_faking() {
|
||||
let ent = entities();
|
||||
// Empty identity map == the current synthetic-catalogue reality.
|
||||
let ident = MapIdentity(HashMap::new());
|
||||
let items = vec![item(
|
||||
"oc1",
|
||||
"card_pl_001",
|
||||
84,
|
||||
"ST",
|
||||
"England",
|
||||
"Premier League",
|
||||
"Northgate United",
|
||||
)];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert_eq!(stats.dropped_no_asset, 1);
|
||||
assert_eq!(
|
||||
body["itemData"].as_array().unwrap().len(),
|
||||
0,
|
||||
"no fabricated ids emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::from([(
|
||||
"card_x".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000002,
|
||||
asset_id: 158023,
|
||||
},
|
||||
)]));
|
||||
// Synthetic club "Northgate United" has no FIFA team id.
|
||||
let items = vec![item(
|
||||
"oc2",
|
||||
"card_x",
|
||||
84,
|
||||
"ST",
|
||||
"England",
|
||||
"Premier League",
|
||||
"Northgate United",
|
||||
)];
|
||||
let (body, _) = shape_club_response(&items, &ent, &ident);
|
||||
let it = &body["itemData"][0];
|
||||
assert_eq!(
|
||||
it["teamid"], 0,
|
||||
"unknown club -> neutral 0, item still emitted"
|
||||
);
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_is_itemdata_object() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::new());
|
||||
let (body, _) = shape_club_response(&[], &ent, &ident);
|
||||
assert!(body.get("itemData").unwrap().is_array());
|
||||
assert_eq!(
|
||||
body.as_object().unwrap().len(),
|
||||
1,
|
||||
"only itemData at top level"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! FIFA 17 entity id ⇄ name resolution, loaded from the committed game-DB
|
||||
//! tables (`leagues.json`, `nations.json`, `teams.json`, dumped from
|
||||
//! `FIFA17.exe`'s resident DB).
|
||||
//!
|
||||
//! Two directions, both FIFA-17-specific and therefore adapter-owned:
|
||||
//! * **forward** id → name — resolves a wire filter (`league=13`) to the
|
||||
//! semantic name Core filters on ("Premier League"). See [`EntityResolver`].
|
||||
//! * **reverse** name → id — shapes a Core item's names back into the numeric
|
||||
//! ids the FIFA `/club` response carries (`leagueId`/`teamid`/`nation`).
|
||||
//!
|
||||
//! Grounding (worker-verified against the tables): forward is 1:1 for all three
|
||||
//! (unique ids, no gaps). Reverse is clean for leagues (50 distinct names) and
|
||||
//! nations (221 distinct); **team names collide** (e.g. `Arsenal` ×3, plus the
|
||||
//! FUT "CHAMPIONS *" placeholder teams), so reverse team lookup is first-id-wins
|
||||
//! and a collision count is exposed for diagnostics. `teamid == assetid` on
|
||||
//! every row.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::fut::owned_query::EntityResolver;
|
||||
|
||||
/// Reverse (name → FIFA id) resolution, used when shaping a Core item back onto
|
||||
/// the FIFA wire. Unknown names return `None`; the shaper substitutes a neutral
|
||||
/// `0` (a valid, non-desyncing int) rather than dropping the item.
|
||||
pub trait ReverseEntityResolver {
|
||||
fn league_id(&self, name: &str) -> Option<u32>;
|
||||
fn nation_id(&self, name: &str) -> Option<u32>;
|
||||
fn team_id(&self, name: &str) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Forward + reverse FIFA 17 entity maps.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Fifa17Entities {
|
||||
league_by_id: HashMap<u32, String>,
|
||||
league_by_name: HashMap<String, u32>,
|
||||
nation_by_id: HashMap<u32, String>,
|
||||
nation_by_name: HashMap<String, u32>,
|
||||
team_by_id: HashMap<u32, String>,
|
||||
team_by_name: HashMap<String, u32>,
|
||||
/// name-collisions dropped from the reverse maps (diagnostics only).
|
||||
pub reverse_collisions: usize,
|
||||
}
|
||||
|
||||
/// Errors loading the entity tables.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadError {
|
||||
Io(std::io::Error),
|
||||
Parse { file: String, detail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LoadError::Io(e) => write!(f, "reading entity tables: {e}"),
|
||||
LoadError::Parse { file, detail } => write!(f, "parsing {file}: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoadError {}
|
||||
|
||||
impl Fifa17Entities {
|
||||
/// Load from a directory holding `leagues.json`, `nations.json`,
|
||||
/// `teams.json` in the FIFA17 `{schema, rows:[...]}` dump format.
|
||||
pub fn from_tables_dir(dir: &std::path::Path) -> Result<Self, LoadError> {
|
||||
let mut e = Fifa17Entities::default();
|
||||
e.load_table(
|
||||
&dir.join("leagues.json"),
|
||||
"leagueid",
|
||||
"leaguename",
|
||||
Entity::League,
|
||||
)?;
|
||||
e.load_table(
|
||||
&dir.join("nations.json"),
|
||||
"nationid",
|
||||
"nationname",
|
||||
Entity::Nation,
|
||||
)?;
|
||||
e.load_table(&dir.join("teams.json"), "teamid", "teamname", Entity::Team)?;
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn load_table(
|
||||
&mut self,
|
||||
path: &std::path::Path,
|
||||
id_key: &str,
|
||||
name_key: &str,
|
||||
which: Entity,
|
||||
) -> Result<(), LoadError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(LoadError::Io)?;
|
||||
let file = path.display().to_string();
|
||||
let doc: serde_json::Value = serde_json::from_str(&raw).map_err(|e| LoadError::Parse {
|
||||
file: file.clone(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let rows = doc
|
||||
.get("rows")
|
||||
.and_then(|r| r.as_array())
|
||||
.ok_or_else(|| LoadError::Parse {
|
||||
file: file.clone(),
|
||||
detail: "missing `rows` array".into(),
|
||||
})?;
|
||||
for row in rows {
|
||||
let (Some(id), Some(name)) = (
|
||||
row.get(id_key).and_then(|v| v.as_u64()),
|
||||
row.get(name_key).and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let id = id as u32;
|
||||
let (by_id, by_name) = match which {
|
||||
Entity::League => (&mut self.league_by_id, &mut self.league_by_name),
|
||||
Entity::Nation => (&mut self.nation_by_id, &mut self.nation_by_name),
|
||||
Entity::Team => (&mut self.team_by_id, &mut self.team_by_name),
|
||||
};
|
||||
by_id.insert(id, name.to_string());
|
||||
// Reverse: first id wins on a name collision; count the rest.
|
||||
if by_name.contains_key(name) {
|
||||
self.reverse_collisions += 1;
|
||||
} else {
|
||||
by_name.insert(name.to_string(), id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build directly from maps (tests / small deployments).
|
||||
pub fn from_maps(
|
||||
leagues: HashMap<u32, String>,
|
||||
nations: HashMap<u32, String>,
|
||||
teams: HashMap<u32, String>,
|
||||
) -> Self {
|
||||
let invert = |m: &HashMap<u32, String>| {
|
||||
let mut out = HashMap::new();
|
||||
for (&id, name) in m {
|
||||
out.entry(name.clone()).or_insert(id);
|
||||
}
|
||||
out
|
||||
};
|
||||
Fifa17Entities {
|
||||
league_by_name: invert(&leagues),
|
||||
nation_by_name: invert(&nations),
|
||||
team_by_name: invert(&teams),
|
||||
league_by_id: leagues,
|
||||
nation_by_id: nations,
|
||||
team_by_id: teams,
|
||||
reverse_collisions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn league_count(&self) -> usize {
|
||||
self.league_by_id.len()
|
||||
}
|
||||
pub fn nation_count(&self) -> usize {
|
||||
self.nation_by_id.len()
|
||||
}
|
||||
pub fn team_count(&self) -> usize {
|
||||
self.team_by_id.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Entity {
|
||||
League,
|
||||
Nation,
|
||||
Team,
|
||||
}
|
||||
|
||||
impl EntityResolver for Fifa17Entities {
|
||||
fn league_name(&self, id: u32) -> Option<String> {
|
||||
self.league_by_id.get(&id).cloned()
|
||||
}
|
||||
fn nation_name(&self, id: u32) -> Option<String> {
|
||||
self.nation_by_id.get(&id).cloned()
|
||||
}
|
||||
fn team_name(&self, id: u32) -> Option<String> {
|
||||
self.team_by_id.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReverseEntityResolver for Fifa17Entities {
|
||||
fn league_id(&self, name: &str) -> Option<u32> {
|
||||
self.league_by_name.get(name).copied()
|
||||
}
|
||||
fn nation_id(&self, name: &str) -> Option<u32> {
|
||||
self.nation_by_name.get(name).copied()
|
||||
}
|
||||
fn team_id(&self, name: &str) -> Option<u32> {
|
||||
self.team_by_name.get(name).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Path to the committed game-DB tables, relative to this crate.
|
||||
fn tables_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fifa17-recon/data/tables")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_committed_tables_and_resolves_both_directions() {
|
||||
let dir = tables_dir();
|
||||
if !dir.join("leagues.json").exists() {
|
||||
eprintln!(
|
||||
"skipping: committed tables not present at {}",
|
||||
dir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let e = Fifa17Entities::from_tables_dir(&dir).expect("load tables");
|
||||
assert_eq!(e.league_count(), 50);
|
||||
assert_eq!(e.nation_count(), 221);
|
||||
assert_eq!(e.team_count(), 750);
|
||||
// forward id -> name (grounding pinned in owned_query too)
|
||||
assert_eq!(e.league_name(13).as_deref(), Some("Premier League"));
|
||||
assert_eq!(e.nation_name(52).as_deref(), Some("Argentina"));
|
||||
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
|
||||
// reverse name -> id (clean for leagues/nations)
|
||||
assert_eq!(e.league_id("Premier League"), Some(13));
|
||||
assert_eq!(e.nation_id("Argentina"), Some(52));
|
||||
assert_eq!(e.team_id("Chelsea"), Some(5));
|
||||
// unknown -> None (never a raw-id fallback)
|
||||
assert_eq!(e.league_name(999_999), None);
|
||||
assert_eq!(e.team_id("Northgate United"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_maps_inverts() {
|
||||
let e = Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
);
|
||||
assert_eq!(e.league_id("Premier League"), Some(13));
|
||||
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! FIFA 17 FUT (UTAS/RS4) wire → OpenFUT Core semantic mappings.
|
||||
//!
|
||||
//! Unlike [`crate::blaze`] (binary Blaze RPC), this is the JSON/HTTP FUT surface.
|
||||
//! It currently holds the owned-player ("My Squad") search mapping; more UTAS
|
||||
//! routes join it as the UTAS→Core migration proceeds. Nothing here opens a
|
||||
//! socket — a Rust UTAS host wires it to Core later.
|
||||
pub mod club_response;
|
||||
pub mod entities;
|
||||
pub mod owned_query;
|
||||
@@ -0,0 +1,492 @@
|
||||
//! 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![],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user