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,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"
|
||||
@@ -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.
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user