Files
OpenFUT/openfut-adapter-fifa17/src/fut/entities.rs
T
funman300 c0a3f68ded 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).
2026-08-11 21:40:15 +00:00

240 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}
}