c0a3f68ded
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).
240 lines
8.2 KiB
Rust
240 lines
8.2 KiB
Rust
//! 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"));
|
||
}
|
||
}
|