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
+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);
}
}