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