//! 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; fn nation_id(&self, name: &str) -> Option; fn team_id(&self, name: &str) -> Option; } /// Forward + reverse FIFA 17 entity maps. #[derive(Debug, Default, Clone)] pub struct Fifa17Entities { league_by_id: HashMap, league_by_name: HashMap, nation_by_id: HashMap, nation_by_name: HashMap, team_by_id: HashMap, team_by_name: HashMap, /// 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 { 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, nations: HashMap, teams: HashMap, ) -> Self { let invert = |m: &HashMap| { 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 { self.league_by_id.get(&id).cloned() } fn nation_name(&self, id: u32) -> Option { self.nation_by_id.get(&id).cloned() } fn team_name(&self, id: u32) -> Option { self.team_by_id.get(&id).cloned() } } impl ReverseEntityResolver for Fifa17Entities { fn league_id(&self, name: &str) -> Option { self.league_by_name.get(name).copied() } fn nation_id(&self, name: &str) -> Option { self.nation_by_name.get(name).copied() } fn team_id(&self, name: &str) -> Option { 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")); } }