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:
funman300
2026-08-11 21:40:15 +00:00
parent 04c5043aba
commit c0a3f68ded
14 changed files with 2456 additions and 1 deletions
Generated
+11
View File
@@ -3252,6 +3252,17 @@ dependencies = [
"openssl",
]
[[package]]
name = "openfut-utas-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-http",
"parking_lot",
"reqwest",
"serde_json",
]
[[package]]
name = "openssl"
version = "0.10.81"
+1
View File
@@ -10,6 +10,7 @@ members = [
"openfut-tls",
"openfut-redirector-host",
"openfut-roster-host",
"openfut-utas-host",
"openfut-bridge",
"openfut-launcher",
"openfut-launcher/openfut-hook",
@@ -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"
);
}
}
+239
View File
@@ -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"));
}
}
+9
View File
@@ -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![],
}
);
}
}
+4
View File
@@ -25,6 +25,9 @@
//! Implemented; runtime validated against the retail client.
//! * [`roster`] — the FUT roster-update response, the last gate before the hub.
//! Implemented; not yet runtime validated.
//! * [`fut`] — FUT/RS4 (UTAS) wire → Core semantic mappings; currently the
//! owned-player ("My Squad") search: query parse + FIFA-id→name resolution +
//! semantic filter/pagination. Pure mapping; no Rust UTAS host yet.
//!
//! Still served only by the Python backend: LSX/Origin (`:4216`), UTAS/RS4
//! (`:8099`) and POW/EASFC (`:8094`). Roster XML (`:8081`) has an adapter here
@@ -68,6 +71,7 @@
//! ```
pub mod blaze;
pub mod fut;
pub mod redirector;
pub mod roster;
pub mod tls;
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "openfut-utas-host"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "FIFA 17 UTAS migration host: serves implemented routes (/club) from OpenFUT Core, transparently proxies everything else to the Python UTAS oracle"
publish = false
[dependencies]
openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" }
openfut-http = { path = "../openfut-http" }
serde_json = "1"
# Plain-HTTP client for Core queries and Python passthrough. UTAS is plaintext
# HTTP (worker D: no wrap_socket, no cert), so no TLS backend is linked.
reqwest = { version = "0.11", default-features = false, features = ["blocking", "json"] }
[dev-dependencies]
parking_lot = "0.12"
+89
View File
@@ -0,0 +1,89 @@
# openfut-utas-host
The first live FIFA 17 **UTAS migration host**. It fronts the client-visible UTAS
port and migrates one route at a time to OpenFUT Core, proxying everything else to
the Python UTAS oracle so the rest of FUT keeps working unchanged.
```
FIFA 17 ──HTTP──▶ openfut-utas-host
├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core (/collection)
└── everything else ──▶ Python UTAS oracle (verbatim reverse proxy)
```
## What it owns / does not own
Owns: socket + HTTP/1.1 keep-alive transport, route classification, the Core
access client, the Python passthrough, and diagnostics. It owns **no** game
domain state — filtering/pagination is Core's; wire parsing/shaping is the
adapter's. The adapter never learns how Core is reached (the architecture rule):
the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` today).
## Safety model
- **Classification happens once, before execution.** Exact `GET /ut/game/<title>/club`
→ Rust; everything else → Python. No shared path, no "try Rust then Python".
- A Core failure on `/club` degrades to a valid empty `{"itemData":[]}` and logs
an error — it never falls back to Python (which could double-apply a mutation on
other routes). `/club` is read-only, but the rule is absolute.
- Mutating routes (PUT/POST, `/squad`, `/purchased`, quick-sell, market, auth, SBC,
`/club/stats/*`, `/clubUser`) all classify to passthrough and are untouched.
## Configuration (env)
| Var | Required | Default | Meaning |
|---|---|---|---|
| `OPENFUT_UTAS_HOST_ADDR` | yes | — | where this host listens (client-visible UTAS addr) |
| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python UTAS oracle base URL for fallback (must differ from this host) |
| `OPENFUT_CORE_URL` | no | `http://127.0.0.1:8080` | OpenFUT Core base |
| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | `leagues/nations/teams.json` for id⇄name |
| `OPENFUT_FIFA17_ASSET_MAP` | no | — | JSON `{ "<core_card_id>": <fifa_asset_id> }` (see the blocker) |
## KNOWN BLOCKER — retail rendering of Core inventory
FIFA renders an owned card by resolving `resourceId & 0xffffff` against the
client's **own local players table**; an invented id renders a **blank generic
card** (proven live — `fut_cards.py:11-21`). OpenFUT Core's catalogue is synthetic
string-id cards (`card_pl_001`) with **no FIFA asset id**, and no committed
card→asset mapping exists. So:
- Without `OPENFUT_FIFA17_ASSET_MAP`, `/club` returns `{"itemData":[]}` — honest,
never faked. The shaper drops any item lacking a **real** asset id.
- Making Core inventory actually render in retail requires a Core-card→FIFA-asset
identity decision (seed Core from FIFA assets, or a real mapping table). This is
the "who owns FUT state" question and is the **prerequisite** for a rendering
retail `/club`. Filtering, pagination, entity mapping, transport and fallback are
all done and tested independently of it.
`rare=SP` ("Special") stays UNSUPPORTED (semantics unproven; parsed, reported, never
guessed).
## Retail A/B runbook (first `/club` gate)
Change **only** the UTAS routing layer; keep the validated Rust Redirector/Roster
and the current Blaze path. Python remains the rollback oracle — do not modify it.
Preconditions (mirror the proven blaze/roster switch discipline):
1. `cargo test -p openfut-utas-host -p openfut-adapter-fifa17` green; `clippy -D warnings` clean; `fmt --check` clean.
2. Built binary identity == HEAD (`scripts/verify-build-identity.sh`); no dirty tree.
3. Python UTAS directly reachable; the Rust host directly probeable; no stale NAT/switch rules; FIFA fully closed.
Bring-up:
1. Move Python UTAS to an alternate port (`FUT_PORT=8199` in the container/`openfut-fut.sh`); it keeps serving there.
2. Start this host on the client-visible UTAS addr:
`OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_ASSET_MAP=<map.json> openfut-utas-host`
3. Launch FIFA → FUT → **My Squad** player picker and exercise: no-filter, position, nation, league, league+team, Gold+position, then scroll beyond page one.
Evidence to capture (all six):
- **Switch**: client traffic hits the Rust host.
- **Rust positive**: host log `owner=RUST route=club …` for the client IP.
- **Python negative for /club**: Python logs no `/club` request in the window.
- **Python positive for other UTAS**: unimplemented routes still reach Python.
- **Core positive**: Core logs the `/collection` query and returns the expected set.
- **Application + pagination**: the UI shows filtered results; later pages differ
from page one (no repeated-first-page amplification).
Rollback: point the UTAS addr back at Python directly; confirm FUT still usable;
then re-enable the host and confirm `/club` again (proves reversibility).
Logs are safe by construction: no auth/session/device/token material — only owner,
route, filter summary, counts, status.
+54
View File
@@ -0,0 +1,54 @@
//! Environment → [`HostConfig`]. Client-visible bind and the Python upstream are
//! REQUIRED with no default (host-family discipline: a defaulted port could
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
use std::env;
#[derive(Debug, Clone)]
pub struct HostConfig {
/// Where this host listens (the address FIFA reaches for UTAS). Required.
pub listen_addr: String,
/// Base URL of the Python UTAS oracle for fallback, e.g.
/// `http://127.0.0.1:8199`. Required — must NOT be this host's own address.
pub python_upstream: String,
/// OpenFUT Core base URL. Default `http://127.0.0.1:8080` (Bridge convention).
pub core_url: String,
/// Directory holding `leagues.json`/`nations.json`/`teams.json`.
pub tables_dir: String,
/// Optional JSON file mapping Core card id → FIFA asset id. Absent = the
/// current reality (no mapping) → Core items cannot render and are dropped.
pub asset_map_path: Option<String>,
}
#[derive(Debug)]
pub struct ConfigError(pub String);
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ConfigError {}
fn required(key: &str) -> Result<String, ConfigError> {
match env::var(key) {
Ok(v) if !v.is_empty() => Ok(v),
_ => Err(ConfigError(format!("{key} is required (no default)"))),
}
}
impl HostConfig {
pub fn from_env() -> Result<Self, ConfigError> {
Ok(HostConfig {
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
core_url: env::var("OPENFUT_CORE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
asset_map_path: env::var("OPENFUT_FIFA17_ASSET_MAP")
.ok()
.filter(|s| !s.is_empty()),
})
}
}
+751
View File
@@ -0,0 +1,751 @@
//! # openfut-utas-host
//!
//! The first live FIFA 17 **UTAS migration host**. It fronts the client-visible
//! UTAS port and does route-level migration:
//!
//! ```text
//! FIFA 17 ──HTTP──▶ openfut-utas-host
//! ├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core
//! └── everything else ──▶ Python UTAS oracle (verbatim)
//! ```
//!
//! ## Safety rules (see the mission brief)
//!
//! * **Classification happens once, before any execution** ([`classify`]). A
//! request is either handled in Rust or proxied to Python — never both, and
//! there is NO "try Rust then retry on Python", which could double-apply a
//! mutation. `/club` is read-only, but the rule holds regardless.
//! * The Rust `/club` path NEVER contacts Python; the passthrough path NEVER
//! runs Core logic.
//! * A Core failure on `/club` returns an empty (but valid) `{"itemData":[]}`
//! and logs an error — it does NOT fall back to Python.
//!
//! ## Transport (worker D)
//!
//! UTAS is plaintext HTTP/1.1 keep-alive, no TLS. Body is read by `Content-Length`
//! before responding; responses carry `Content-Length` and `Content-Type:
//! application/json` only when a body is present.
//!
//! ## The asset-id boundary
//!
//! FIFA renders an owned card from a real FIFA asset id (`resourceId & 0xffffff`
//! resolved against the client's local DB). Core's synthetic catalogue has none,
//! so [`ItemIdentityResolver`] is injected and unresolved items are dropped, not
//! faked (see `club_response`). With today's empty mapping, `/club` returns
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
pub mod config;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
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 serde_json::{json, Value};
use config::HostConfig;
// ───────────────────────────── Route classification ─────────────────────────
/// The route decision, taken once, before execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
/// The owned-player search, served from Core.
Club,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
/// Classify a request. ONLY `GET /ut/game/<title>/club` (exact) is owned by Rust.
/// `/club/stats/*`, `/clubUser`, mutations, auth, packs, market, squad, etc. all
/// fall through to Python.
pub fn classify(method: &str, path: &str) -> Route {
if method.eq_ignore_ascii_case("GET") && is_exact_club_path(path) {
Route::Club
} else {
Route::Passthrough
}
}
fn is_exact_club_path(path: &str) -> bool {
// /ut/game/<seg>/club with nothing after and a non-empty title segment.
match path.strip_prefix("/ut/game/") {
Some(rest) => match rest.split_once('/') {
Some((title, tail)) => !title.is_empty() && tail == "club",
None => false,
},
None => false,
}
}
// ───────────────────────────── Core access boundary ─────────────────────────
/// Failure reaching or reading OpenFUT Core.
#[derive(Debug)]
pub enum CoreError {
Http(String),
Status(u16),
Parse(String),
}
impl std::fmt::Display for CoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CoreError::Http(e) => write!(f, "core http error: {e}"),
CoreError::Status(s) => write!(f, "core returned status {s}"),
CoreError::Parse(e) => write!(f, "core response parse error: {e}"),
}
}
}
/// One page of owned items plus the filtered total, as returned by Core.
pub struct CorePage {
pub items: Vec<CoreOwnedItem>,
pub total: i64,
}
/// How the host reaches Core. The adapter never sees this — the host owns the
/// transport, mirroring the architecture rule. Tests inject a fake.
pub trait CoreAccess: Send + Sync {
/// Query the owned inventory with semantic `/collection` query params.
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError>;
}
/// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON,
/// the same boundary Bridge uses to reach Core).
pub struct HttpCoreClient {
base_url: String,
client: reqwest::blocking::Client,
}
impl HttpCoreClient {
pub fn new(base_url: impl Into<String>) -> Self {
HttpCoreClient {
base_url: base_url.into().trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(),
}
}
}
impl CoreAccess for HttpCoreClient {
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
let url = format!("{}/collection", self.base_url);
let resp = self
.client
.get(&url)
.query(params)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_page(&v)
}
}
/// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into
/// semantic owned items.
pub fn parse_core_page(v: &Value) -> Result<CorePage, CoreError> {
let arr = v
.get("collection")
.and_then(|c| c.as_array())
.ok_or_else(|| CoreError::Parse("missing `collection` array".into()))?;
let total = v
.get("total")
.and_then(|t| t.as_i64())
.unwrap_or(arr.len() as i64);
let items = arr.iter().filter_map(core_item_from_json).collect();
Ok(CorePage { items, total })
}
fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
let card = e.get("card")?;
let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8;
let position = e
.get("effective_position")
.and_then(|v| v.as_str())
.or_else(|| card.get("position").and_then(|v| v.as_str()))?
.to_string();
let rating = e
.get("effective_overall")
.and_then(|v| v.as_i64())
.or_else(|| card.get("overall").and_then(|v| v.as_i64()))
.unwrap_or(0) as u8;
Some(CoreOwnedItem {
owned_card_id: e.get("owned_card_id")?.as_str()?.to_string(),
card_id: card.get("id")?.as_str()?.to_string(),
rating,
position,
nation: card.get("nation")?.as_str()?.to_string(),
league: card.get("league")?.as_str()?.to_string(),
club: card.get("club")?.as_str()?.to_string(),
attributes: [
attr("pace"),
attr("shooting"),
attr("passing"),
attr("dribbling"),
attr("defending"),
attr("physical"),
],
})
}
// ───────────────────────────── Asset resolvers ──────────────────────────────
/// The current production reality: no Core-card→FIFA-asset mapping exists, so
/// every item is dropped (rendered response is `{"itemData":[]}`). Honest, not
/// faked.
pub struct EmptyAssetResolver;
impl ItemIdentityResolver for EmptyAssetResolver {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
}
/// Map-backed resolver (from a config file or tests): Core card id → FIFA asset
/// id. The wire item id is derived stably from the owned-card id (adequate for a
/// read-only search; item-operation identity is a later slice).
pub struct MapAssetResolver {
map: HashMap<String, u32>,
}
impl MapAssetResolver {
pub fn from_map(map: HashMap<String, u32>) -> Self {
MapAssetResolver { map }
}
/// Load `{ "card_id": assetId, … }` from a JSON file.
pub fn from_json_file(path: &str) -> std::io::Result<Self> {
let raw = std::fs::read_to_string(path)?;
let v: Value = serde_json::from_str(&raw)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut map = HashMap::new();
if let Some(obj) = v.as_object() {
for (k, val) in obj {
if let Some(id) = val.as_u64() {
map.insert(k.clone(), id as u32);
}
}
}
Ok(MapAssetResolver { map })
}
}
/// Stable wire item id in the 100_000_000+ space (FNV-1a of the owned id).
fn stable_item_id(owned_card_id: &str) -> u32 {
let mut h: u32 = 2_166_136_261;
for b in owned_card_id.bytes() {
h ^= b as u32;
h = h.wrapping_mul(16_777_619);
}
100_000_000 + (h % 900_000_000)
}
impl ItemIdentityResolver for MapAssetResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> {
let asset = *self.map.get(&item.card_id)?;
Some(Fifa17Identity {
item_id: stable_item_id(&item.owned_card_id),
asset_id: asset,
})
}
}
// ───────────────────────────── /club handler ────────────────────────────────
/// Safe, structured summary of a handled `/club` request (no auth/session/device
/// material — the club query carries none; auth is a header we never log).
#[derive(Debug, Clone)]
pub struct ClubLog {
pub outcome: &'static str,
pub filter: String,
pub total: i64,
pub emitted: usize,
pub dropped_no_asset: usize,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
/// Dependencies for the Rust `/club` path.
pub struct ClubDeps<'a> {
pub core: &'a dyn CoreAccess,
pub entities: &'a Fifa17Entities,
pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
}
/// 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).
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
let raw = parse_club_query(query);
let core_q = match map_to_core(&raw, deps.entities) {
Ok(c) => c,
Err(e) => {
// Unknown FIFA id — never a raw-id passthrough, never a guess.
return (
json_response(&json!({ "itemData": [] })),
ClubLog {
outcome: "unknown_id",
filter: describe_map_error(&e),
total: 0,
emitted: 0,
dropped_no_asset: 0,
offset: raw.start.map(|s| s as i64),
limit: raw.count.map(|c| c as i64),
},
);
}
};
let pairs = core_q.to_query_pairs();
let filter = summarize(&pairs);
let (offset, limit) = (core_q.offset, core_q.limit);
match deps.core.query_owned(&pairs) {
Ok(page) => {
let (body, stats): (Value, ShapeStats) =
shape_club_response(&page.items, deps.entities, deps.assets);
(
json_response(&body),
ClubLog {
outcome: "ok",
filter,
total: page.total,
emitted: stats.emitted,
dropped_no_asset: stats.dropped_no_asset,
offset,
limit,
},
)
}
Err(e) => {
// Degrade to a valid empty page; DO NOT fall back to Python.
eprintln!("utas-host ERROR /club core query failed: {e}");
(
json_response(&json!({ "itemData": [] })),
ClubLog {
outcome: "core_error",
filter,
total: 0,
emitted: 0,
dropped_no_asset: 0,
offset,
limit,
},
)
}
}
}
fn describe_map_error(e: &MapError) -> String {
match e {
MapError::UnknownLeague(id) => format!("unknown_league={id}"),
MapError::UnknownNation(id) => format!("unknown_nation={id}"),
MapError::UnknownTeam(id) => format!("unknown_team={id}"),
}
}
fn summarize(pairs: &[(&str, String)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",")
}
// ───────────────────────────── HTTP wire types ──────────────────────────────
/// A response ready to write: status, headers, body.
#[derive(Debug, Clone)]
pub struct WireResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
fn json_response(body: &Value) -> WireResponse {
let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
WireResponse {
status: 200,
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: bytes,
}
}
fn is_hop_by_hop(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"connection"
| "keep-alive"
| "transfer-encoding"
| "content-length"
| "host"
| "proxy-connection"
| "te"
| "trailer"
| "upgrade"
)
}
// ───────────────────────────── Python passthrough ───────────────────────────
/// Verbatim reverse proxy to the Python UTAS oracle. Preserves method, full
/// target (path + query), end-to-end headers, and body; returns the upstream's
/// status/headers/body faithfully.
pub struct PassClient {
client: reqwest::blocking::Client,
upstream: String,
}
impl PassClient {
pub fn new(upstream: impl Into<String>) -> Self {
PassClient {
client: reqwest::blocking::Client::new(),
upstream: upstream.into().trim_end_matches('/').to_string(),
}
}
pub fn forward(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
) -> Result<WireResponse, CoreError> {
let url = format!("{}{}", self.upstream, target);
let m = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|e| CoreError::Http(format!("bad method: {e}")))?;
let mut req = self.client.request(m, &url);
for (k, v) in headers {
if !is_hop_by_hop(k) {
req = req.header(k, v);
}
}
if !body.is_empty() {
req = req.body(body.to_vec());
}
let resp = req.send().map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let mut out = Vec::new();
for (k, v) in resp.headers() {
if !is_hop_by_hop(k.as_str()) {
if let Ok(s) = v.to_str() {
out.push((k.to_string(), s.to_string()));
}
}
}
let bytes = resp
.bytes()
.map_err(|e| CoreError::Http(e.to_string()))?
.to_vec();
Ok(WireResponse {
status,
headers: out,
body: bytes,
})
}
}
// ───────────────────────────── Server ───────────────────────────────────────
/// The migration host. Cheap to clone (all shared state is `Arc`).
#[derive(Clone)]
pub struct Server {
core: Arc<dyn CoreAccess>,
entities: Arc<Fifa17Entities>,
assets: Arc<dyn ItemIdentityResolver + Send + Sync>,
pass: Arc<PassClient>,
}
impl Server {
/// Assemble from injected parts (used by `from_config` and tests).
pub fn new(
core: Arc<dyn CoreAccess>,
entities: Arc<Fifa17Entities>,
assets: Arc<dyn ItemIdentityResolver + Send + Sync>,
pass: Arc<PassClient>,
) -> Self {
Server {
core,
entities,
assets,
pass,
}
}
/// Build from config: load entity tables, pick the asset resolver, wire the
/// Core client and Python passthrough.
pub fn from_config(cfg: &HostConfig) -> Result<Self, String> {
let entities = Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir))
.map_err(|e| format!("loading entity tables from {}: {e}", cfg.tables_dir))?;
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> = match &cfg.asset_map_path {
Some(p) => Arc::new(
MapAssetResolver::from_json_file(p)
.map_err(|e| format!("loading asset map {p}: {e}"))?,
),
None => Arc::new(EmptyAssetResolver),
};
Ok(Server {
core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())),
entities: Arc::new(entities),
assets,
pass: Arc::new(PassClient::new(cfg.python_upstream.clone())),
})
}
/// Route one request to a response. Classification happens here, once,
/// before either branch runs.
pub fn handle(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
) -> WireResponse {
let path = target.split('?').next().unwrap_or(target);
match classify(method, path) {
Route::Club => {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let deps = ClubDeps {
core: self.core.as_ref(),
entities: self.entities.as_ref(),
assets: self.assets.as_ref(),
};
let (resp, log) = handle_club(query, &deps);
eprintln!(
"utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}",
resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.limit
);
resp
}
Route::Passthrough => {
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!("utas-host ERROR passthrough to Python failed: {e}");
WireResponse {
status: 502,
headers: vec![(
"Content-Type".to_string(),
"application/json".to_string(),
)],
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
}
}
};
eprintln!(
"utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
method, path, resp.status
);
resp
}
}
}
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
pub fn serve(&self, addr: &str) -> std::io::Result<()> {
let listener = TcpListener::bind(addr)?;
eprintln!("utas-host listening on {addr}");
self.serve_listener(listener);
Ok(())
}
/// Accept loop on an already-bound listener (lets tests bind an ephemeral
/// port and learn it before serving).
pub fn serve_listener(&self, listener: TcpListener) {
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let server = self.clone();
std::thread::spawn(move || server.handle_conn(stream));
}
}
fn handle_conn(&self, stream: TcpStream) {
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
});
let mut writer = stream;
loop {
match read_request(&mut reader) {
Ok(Some(req)) => {
let resp = self.handle(&req.method, &req.target, &req.headers, &req.body);
if write_response(&mut writer, &resp).is_err() {
return;
}
if req.close {
return;
}
}
Ok(None) => return, // clean EOF
Err(_) => return,
}
}
}
}
// ───────────────────────────── HTTP/1.1 request reader ──────────────────────
/// A parsed request. `target` is the raw request target (path + optional query).
pub struct ParsedRequest {
pub method: String,
pub target: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub close: bool,
}
/// Read one HTTP/1.1 request. `Ok(None)` = clean connection close before a
/// request line. Body is read exactly per `Content-Length` (chunked is not used
/// by this client population — worker D).
pub fn read_request<R: BufRead>(reader: &mut R) -> std::io::Result<Option<ParsedRequest>> {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
let request_line = line.trim_end();
if request_line.is_empty() {
// Tolerate a stray blank line before the request line.
return read_request(reader);
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let target = parts.next().unwrap_or("").to_string();
let mut headers = Vec::new();
let mut content_length = 0usize;
let mut close = false;
loop {
let mut h = String::new();
if reader.read_line(&mut h)? == 0 {
break;
}
let h = h.trim_end();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
let k = k.trim().to_string();
let v = v.trim().to_string();
if k.eq_ignore_ascii_case("content-length") {
content_length = v.parse().unwrap_or(0);
} else if k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close") {
close = true;
}
headers.push((k, v));
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
reader.read_exact(&mut body)?;
}
Ok(Some(ParsedRequest {
method,
target,
headers,
body,
close,
}))
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
502 => "Bad Gateway",
_ => "OK",
}
}
fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<()> {
let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status));
for (k, v) in &resp.headers {
if is_hop_by_hop(k) {
continue;
}
head.push_str(&format!("{k}: {v}\r\n"));
}
head.push_str(&format!("Content-Length: {}\r\n", resp.body.len()));
head.push_str("\r\n");
w.write_all(head.as_bytes())?;
w.write_all(&resp.body)?;
w.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_club_only_on_exact_get() {
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
// method must be GET
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
// near-misses stay on Python
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats/staff"),
Route::Passthrough
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clubUser"),
Route::Passthrough
);
assert_eq!(
classify("GET", "/ut/game/fifa17/tradePile"),
Route::Passthrough
);
assert_eq!(
classify("POST", "/ut/game/fifa17/purchased/items"),
Route::Passthrough
);
assert_eq!(classify("GET", "/ut/game//club"), Route::Passthrough);
assert_eq!(classify("GET", "/club"), Route::Passthrough);
}
#[test]
fn parse_core_page_reads_collection_and_total() {
let v = json!({
"collection": [{
"owned_card_id": "oc1",
"effective_overall": 86,
"effective_position": "CDM",
"card": {"id":"card_ch_1","overall":85,"position":"CDM","nation":"Argentina","league":"Premier League","club":"Chelsea","pace":80,"shooting":70,"passing":75,"dribbling":78,"defending":84,"physical":82}
}],
"total": 42
});
let page = parse_core_page(&v).unwrap();
assert_eq!(page.total, 42);
assert_eq!(page.items.len(), 1);
let it = &page.items[0];
assert_eq!(it.owned_card_id, "oc1");
assert_eq!(it.card_id, "card_ch_1");
assert_eq!(it.rating, 86, "effective_overall wins over base");
assert_eq!(it.position, "CDM");
assert_eq!(it.attributes, [80, 70, 75, 78, 84, 82]);
}
#[test]
fn stable_item_id_is_deterministic_and_in_range() {
let a = stable_item_id("oc1");
let b = stable_item_id("oc1");
assert_eq!(a, b);
assert!((100_000_000..1_000_000_000).contains(&a));
assert_ne!(stable_item_id("oc1"), stable_item_id("oc2"));
}
}
+32
View File
@@ -0,0 +1,32 @@
//! FIFA 17 UTAS migration host entrypoint.
//!
//! Serves `GET …/club` from OpenFUT Core and proxies every other UTAS route to
//! the Python oracle. Config is env-only (see [`openfut_utas_host::config`]);
//! bind and Python upstream are required with no default.
use openfut_utas_host::{config::HostConfig, Server};
fn main() {
let cfg = match HostConfig::from_env() {
Ok(c) => c,
Err(e) => {
eprintln!("utas-host config error: {e}");
std::process::exit(2);
}
};
eprintln!(
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} asset_map={:?}",
cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.asset_map_path
);
let server = match Server::from_config(&cfg) {
Ok(s) => s,
Err(e) => {
eprintln!("utas-host startup error: {e}");
std::process::exit(1);
}
};
if let Err(e) = server.serve(&cfg.listen_addr) {
eprintln!("utas-host serve error: {e}");
std::process::exit(1);
}
}
+465
View File
@@ -0,0 +1,465 @@
//! Integration tests for the FIFA 17 UTAS migration host: `/club` served from a
//! fake Core through the real adapter, faithful Python passthrough against a mock
//! upstream, route-classification safety, negatives, and an end-to-end socket run.
use std::collections::HashMap;
use std::io::{BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use openfut_adapter_fifa17::fut::club_response::CoreOwnedItem;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_utas_host::{
classify, read_request, CoreAccess, CoreError, CorePage, EmptyAssetResolver, MapAssetResolver,
PassClient, Route, Server,
};
use parking_lot::Mutex;
use serde_json::Value;
// ── Fakes ────────────────────────────────────────────────────────────────────
/// (method, target, body) recorded by the mock upstream.
type Recorded = Arc<Mutex<Vec<(String, String, Vec<u8>)>>>;
struct FakeCore {
items: Vec<CoreOwnedItem>,
total: i64,
calls: AtomicUsize,
last_params: Mutex<Vec<(String, String)>>,
panic_if_called: bool,
return_err: bool,
}
impl FakeCore {
fn new(items: Vec<CoreOwnedItem>, total: i64) -> Self {
FakeCore {
items,
total,
calls: AtomicUsize::new(0),
last_params: Mutex::new(vec![]),
panic_if_called: false,
return_err: false,
}
}
fn forbidden() -> Self {
FakeCore {
items: vec![],
total: 0,
calls: AtomicUsize::new(0),
last_params: Mutex::new(vec![]),
panic_if_called: true,
return_err: false,
}
}
fn erroring() -> Self {
FakeCore {
items: vec![],
total: 0,
calls: AtomicUsize::new(0),
last_params: Mutex::new(vec![]),
panic_if_called: false,
return_err: true,
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
fn last(&self) -> Vec<(String, String)> {
self.last_params.lock().clone()
}
}
impl CoreAccess for FakeCore {
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
assert!(
!self.panic_if_called,
"Core must NOT be called on this path"
);
self.calls.fetch_add(1, Ordering::SeqCst);
if self.return_err {
return Err(CoreError::Status(500));
}
*self.last_params.lock() = params
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect();
Ok(CorePage {
items: self.items.clone(),
total: self.total,
})
}
}
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],
}
}
/// A mock Python upstream: records each request, replies 200 + `X-From-Python`.
fn spawn_mock_python() -> (String, Recorded) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let rec = Arc::new(Mutex::new(Vec::new()));
let rec2 = rec.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut s = match stream {
Ok(s) => s,
Err(_) => continue,
};
let mut r = BufReader::new(s.try_clone().unwrap());
if let Ok(Some(req)) = read_request(&mut r) {
rec2.lock()
.push((req.method.clone(), req.target.clone(), req.body.clone()));
let body = br#"{"python":true}"#;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-From-Python: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = s.write_all(head.as_bytes());
let _ = s.write_all(body);
}
}
});
(format!("http://{addr}"), rec)
}
fn build_server(
core: Arc<FakeCore>,
upstream: &str,
assets_map: Option<HashMap<String, u32>>,
) -> Server {
let assets: Arc<
dyn openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver + Send + Sync,
> = match assets_map {
Some(m) => Arc::new(MapAssetResolver::from_map(m)),
None => Arc::new(EmptyAssetResolver),
};
Server::new(
core,
Arc::new(entities()),
assets,
Arc::new(PassClient::new(upstream)),
)
}
// ── /club served from Core ─────────────────────────────────────────────────
#[test]
fn club_route_maps_query_and_shapes_core_items() {
let core = Arc::new(FakeCore::new(
vec![item(
"oc1",
"card_ch_1",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
)],
1,
));
let server = build_server(
core.clone(),
"http://127.0.0.1:1", // passthrough must not be used
Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])),
);
let resp = server.handle(
"GET",
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
&[],
b"",
);
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let it = &body["itemData"][0];
assert_eq!(it["resourceId"], 20801, "real asset id from the resolver");
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);
// Core was queried once with SEMANTIC params (ids resolved to names), and the
// FIFA UI window (start/count) became semantic offset/limit.
assert_eq!(core.calls(), 1);
let p = core.last();
assert!(
p.contains(&("quality".into(), "gold".into())),
"level=gold -> quality {p:?}"
);
assert!(
p.contains(&("league".into(), "Premier League".into())),
"league id 13 -> name {p:?}"
);
assert!(
p.contains(&("club".into(), "Chelsea".into())),
"team id 5 -> club name {p:?}"
);
assert!(p.contains(&("offset".into(), "10".into())));
assert!(p.contains(&("limit".into(), "11".into())));
// No raw FIFA id reached Core.
for (_, v) in &p {
if v == "13" || v == "5" || v == "52" {
panic!("raw FIFA id leaked into Core params: {p:?}");
}
}
}
#[test]
fn club_route_with_empty_asset_map_drops_items_not_fakes_them() {
// The current production reality: no card→asset mapping → empty itemData.
let core = Arc::new(FakeCore::new(
vec![item(
"oc1",
"card_pl_001",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)],
1,
));
let server = build_server(core.clone(), "http://127.0.0.1:1", None);
let resp = server.handle("GET", "/ut/game/fifa17/club?level=any", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
body["itemData"].as_array().unwrap().len(),
0,
"no fabricated ids"
);
assert_eq!(
core.calls(),
1,
"Core still queried; drop happens at shaping"
);
}
#[test]
fn club_route_unknown_id_returns_empty_and_never_calls_core() {
let core = Arc::new(FakeCore::forbidden());
let server = build_server(core.clone(), "http://127.0.0.1:1", None);
// league 9999 is not in the entity map → hard MapError → empty, no Core call.
let resp = server.handle("GET", "/ut/game/fifa17/club?league=9999", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
assert_eq!(core.calls(), 0, "unknown id must short-circuit before Core");
}
// ── Passthrough to Python ────────────────────────────────────────────────────
#[test]
fn passthrough_forwards_verbatim_and_never_calls_core() {
let (upstream, rec) = spawn_mock_python();
let core = Arc::new(FakeCore::forbidden()); // proves /club-only for Core
let server = build_server(core, &upstream, None);
let resp = server.handle(
"POST",
"/ut/game/fifa17/purchased/items",
&[("X-UT-SID".into(), "sess".into())],
br#"{"itemData":[{"id":1}]}"#,
);
// upstream response returned faithfully
assert_eq!(resp.status, 200);
assert!(
resp.headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("x-from-python") && v == "1"),
"upstream headers preserved: {:?}",
resp.headers
);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["python"], true);
// upstream received the exact method, target and body
std::thread::sleep(std::time::Duration::from_millis(50));
let got = rec.lock().clone();
assert_eq!(got.len(), 1);
assert_eq!(got[0].0, "POST");
assert_eq!(got[0].1, "/ut/game/fifa17/purchased/items");
assert_eq!(got[0].2, br#"{"itemData":[{"id":1}]}"#);
}
#[test]
fn mutating_route_classifies_to_passthrough_not_rust() {
// A PUT to the club PATH is NOT the read route; it must go to Python, never
// execute Rust/Core (guards against double-applying a mutation).
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
assert_eq!(
classify("POST", "/ut/game/fifa17/squad/0"),
Route::Passthrough
);
let (upstream, _rec) = spawn_mock_python();
let core = Arc::new(FakeCore::forbidden());
let server = build_server(core.clone(), &upstream, None);
let resp = server.handle("PUT", "/ut/game/fifa17/club", &[], br#"{"x":1}"#);
assert_eq!(resp.status, 200);
assert_eq!(core.calls(), 0);
}
// ── End-to-end over a socket (read_request + write_response + keep-alive) ─────
#[test]
fn end_to_end_socket_serves_club_and_passthrough() {
let (upstream, rec) = spawn_mock_python();
let core = Arc::new(FakeCore::new(
vec![item(
"oc1",
"card_ch_1",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
)],
1,
));
let server = build_server(
core,
&upstream,
Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])),
);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || server.serve_listener(listener));
// /club → Rust/Core
let club = raw_get(
&addr.to_string(),
"/ut/game/fifa17/club?level=gold&league=13",
);
assert!(club.contains("200 OK"), "club status: {club}");
assert!(
club.contains("\"resourceId\":20801") || club.contains("\"resourceId\": 20801"),
"club body: {club}"
);
// passthrough → Python
let pt = raw_get(&addr.to_string(), "/ut/game/fifa17/tradePile?x=1");
assert!(pt.contains("200 OK"));
assert!(
pt.to_lowercase().contains("x-from-python"),
"upstream header relayed (case-insensitive): {pt}"
);
std::thread::sleep(std::time::Duration::from_millis(50));
let got = rec.lock().clone();
assert!(
got.iter()
.any(|(m, t, _)| m == "GET" && t == "/ut/game/fifa17/tradePile?x=1"),
"python saw the passthrough with query intact: {got:?}"
);
assert!(
!got.iter().any(|(_, t, _)| t.contains("/club")),
"python must NOT have seen the /club request: {got:?}"
);
}
/// Minimal raw HTTP/1.1 GET (Connection: close) returning the whole response text.
fn raw_get(addr: &str, target: &str) -> String {
let mut s = TcpStream::connect(addr).unwrap();
let req = format!("GET {target} HTTP/1.1\r\nHost: fifa\r\nConnection: close\r\n\r\n");
s.write_all(req.as_bytes()).unwrap();
let mut buf = String::new();
s.read_to_string(&mut buf).unwrap();
buf
}
#[test]
fn club_core_error_returns_empty_and_never_falls_back_to_python() {
// A Core failure on /club must degrade to an empty page, NOT retry on Python
// (which could double-apply a mutation on other routes; the rule is absolute).
let (upstream, rec) = spawn_mock_python();
let core = Arc::new(FakeCore::erroring());
let server = build_server(
core.clone(),
&upstream,
Some(HashMap::from([("c".into(), 1u32)])),
);
let resp = server.handle("GET", "/ut/game/fifa17/club?level=any", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
assert_eq!(core.calls(), 1, "Core was attempted once");
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
rec.lock().is_empty(),
"Python must NOT be contacted on a /club core error"
);
}
#[test]
fn host_does_not_refilter_core_results() {
// Filtering is Core's job. The host must emit whatever Core returns; if it
// re-applied the filter it would drop items Core already vetted.
let core = Arc::new(FakeCore::new(
vec![
item(
"oc1",
"card_a",
90,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
item(
"oc2",
"card_b",
60,
"GK",
"England",
"Premier League",
"Chelsea",
),
],
2,
));
let server = build_server(
core,
"http://127.0.0.1:1",
Some(HashMap::from([
("card_a".into(), 20801u32),
("card_b".into(), 158023u32),
])),
);
// Query says gold; Core (faked) returns both regardless. Host must emit BOTH.
let resp = server.handle("GET", "/ut/game/fifa17/club?level=gold", &[], b"");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
body["itemData"].as_array().unwrap().len(),
2,
"host must not second-guess Core's filtering"
);
}