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
+239
View File
@@ -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"));
}
}