feat(utas-host): real Fifa17IdentityResolver (catalog + store + policy), drop placeholders

This commit is contained in:
funman300
2026-08-11 22:33:40 +00:00
parent 36fe1caa3f
commit 3ef3bc32ec
7 changed files with 253 additions and 99 deletions
Generated
+1
View File
@@ -3269,6 +3269,7 @@ version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-http",
"openfut-identity",
"parking_lot",
"reqwest",
"serde_json",
+1
View File
@@ -9,6 +9,7 @@ publish = false
[dependencies]
openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" }
openfut-http = { path = "../openfut-http" }
openfut-identity = { path = "../openfut-identity" }
serde_json = "1"
# Plain-HTTP client for Core queries and Python passthrough. UTAS is plaintext
# HTTP (worker D: no wrap_socket, no cert), so no TLS backend is linked.
+24 -15
View File
@@ -34,28 +34,37 @@ the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` toda
|---|---|---|---|
| `OPENFUT_UTAS_HOST_ADDR` | yes | — | where this host listens (client-visible UTAS addr) |
| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python UTAS oracle base URL for fallback (must differ from this host) |
| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 card-definition identity catalog (`Fifa17CardCatalog` JSON: card id → asset id) |
| `OPENFUT_IDENTITY_STORE` | yes | — | persistent external-identity store file (owned instance → stable wire id) |
| `OPENFUT_CORE_URL` | no | `http://127.0.0.1:8080` | OpenFUT Core base |
| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | `leagues/nations/teams.json` for id⇄name |
| `OPENFUT_FIFA17_ASSET_MAP` | no | — | JSON `{ "<core_card_id>": <fifa_asset_id> }` (see the blocker) |
## KNOWN BLOCKER — retail rendering of Core inventory
Startup **fails clearly** if the catalog or identity store cannot be loaded —
there is no placeholder fallback (exactly one production identity path).
## Identity model (resolved)
FIFA renders an owned card by resolving `resourceId & 0xffffff` against the
client's **own local players table**; an invented id renders a **blank generic
card** (proven live — `fut_cards.py:11-21`). OpenFUT Core's catalogue is synthetic
string-id cards (`card_pl_001`) with **no FIFA asset id**, and no committed
card→asset mapping exists. So:
card** (proven live — `fut_cards.py:11-21`). Two distinct identities, never
conflated, are resolved by [`Fifa17IdentityResolver`] (the single production path):
- Without `OPENFUT_FIFA17_ASSET_MAP`, `/club` returns `{"itemData":[]}` — honest,
never faked. The shaper drops any item lacking a **real** asset id.
- Making Core inventory actually render in retail requires a Core-card→FIFA-asset
identity decision (seed Core from FIFA assets, or a real mapping table). This is
the "who owns FUT state" question and is the **prerequisite** for a rendering
retail `/club`. Filtering, pagination, entity mapping, transport and fallback are
all done and tested independently of it.
- **Definition identity** (`resourceId`/`assetId`) — the card's real FIFA asset
id, from the versioned `OPENFUT_FIFA17_CATALOG`. An unmapped definition is
**dropped and counted**, never faked.
- **Instance identity** (`id`) — a stable, persistent, reversible wire integer
from the generic `openfut-identity` store under the FIFA 17 wire-id policy
(monotonic from `100_000_001`). The same owned instance keeps its id across
restart and reverses exactly; two copies of one definition share a
`resourceId` but get distinct `id`s. The namespace is globally monotonic
within `(fifa17, owned-item)` — no per-account column is needed because Core
owned-instance ids are globally-unique UUIDs.
`rare=SP` ("Special") stays UNSUPPORTED (semantics unproven; parsed, reported, never
guessed).
**Remaining prerequisite for a *rendering* retail `/club`:** Core inventory must
reference cards that exist in the catalog. The catalog + store + resolver are
built and tested; wiring a controlled real FIFA 17 dev-content inventory (the
curated per-game dev pack) is the next slice. `rare=SP` ("Special") stays
UNSUPPORTED (semantics unproven; parsed, reported, never guessed).
## Retail A/B runbook (first `/club` gate)
@@ -70,7 +79,7 @@ Preconditions (mirror the proven blaze/roster switch discipline):
Bring-up:
1. Move Python UTAS to an alternate port (`FUT_PORT=8199` in the container/`openfut-fut.sh`); it keeps serving there.
2. Start this host on the client-visible UTAS addr:
`OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_ASSET_MAP=<map.json> openfut-utas-host`
`OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG=<catalog.json> OPENFUT_IDENTITY_STORE=<store.json> openfut-utas-host`
3. Launch FIFA → FUT → **My Squad** player picker and exercise: no-filter, position, nation, league, league+team, Gold+position, then scroll beyond page one.
Evidence to capture (all six):
+10 -6
View File
@@ -15,9 +15,14 @@ pub struct HostConfig {
pub core_url: String,
/// Directory holding `leagues.json`/`nations.json`/`teams.json`.
pub tables_dir: String,
/// Optional JSON file mapping Core card id → FIFA asset id. Absent = the
/// current reality (no mapping) → Core items cannot render and are dropped.
pub asset_map_path: Option<String>,
/// FIFA 17 card-definition **identity catalog** (card id → FIFA asset id).
/// Required production identity source: a `/club` item's `resourceId` comes
/// from here. Startup fails if it cannot be loaded — never a placeholder.
pub catalog_path: String,
/// Persistent external-identity **store** file (owned-instance → stable wire
/// id). Required: the wire `id` of every owned item is allocated/resolved
/// here so it survives restart and reverses exactly.
pub identity_store_path: String,
}
#[derive(Debug)]
@@ -46,9 +51,8 @@ impl HostConfig {
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
asset_map_path: env::var("OPENFUT_FIFA17_ASSET_MAP")
.ok()
.filter(|s| !s.is_empty()),
catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
})
}
}
+187 -67
View File
@@ -36,16 +36,17 @@
pub mod config;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
use openfut_adapter_fifa17::fut::club_response::{
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
};
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::owned_query::{map_to_core, parse_club_query, MapError};
use openfut_identity::ExternalIdentityStore;
use serde_json::{json, Value};
use config::HostConfig;
@@ -197,64 +198,77 @@ fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
})
}
// ───────────────────────────── Asset resolvers ──────────────────────────────
// ───────────────────────────── Item identity resolver ───────────────────────
/// The current production reality: no Core-card→FIFA-asset mapping exists, so
/// every item is dropped (rendered response is `{"itemData":[]}`). Honest, not
/// faked.
pub struct EmptyAssetResolver;
/// The single production [`ItemIdentityResolver`]: it composes the two distinct
/// FIFA 17 identities from real, persistent sources — no placeholder, no hash,
/// no fabricated id.
///
/// * **Definition identity** (`resourceId`/`assetId`) comes from the
/// [`Fifa17CardCatalog`]: `card_id` → real FIFA asset id. An unmapped
/// definition resolves to `None` → the item is dropped and counted, never
/// faked.
/// * **Instance identity** (`item_id`) comes from the generic
/// [`ExternalIdentityStore`] under the FIFA 17 wire-id policy: the same owned
/// instance always resolves to the same monotonic wire id, it survives
/// restart, and it reverses exactly. Two copies of the same definition share a
/// `resourceId` but get distinct `item_id`s.
///
/// The wire-id namespace is **globally monotonic within `(game, "owned-item")`**,
/// not per-account. Python restarts numbering per save file; Core owned-instance
/// ids are globally-unique UUIDs, so a single monotonic sequence keeps every
/// wire id unique and its reverse lookup unambiguous across all accounts —
/// satisfying the client's only requirement (per-session unique/stable/
/// reversible ids). An account column is therefore unnecessary.
pub struct Fifa17IdentityResolver {
catalog: Fifa17CardCatalog,
store: Arc<dyn ExternalIdentityStore>,
}
impl ItemIdentityResolver for EmptyAssetResolver {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
impl Fifa17IdentityResolver {
pub fn new(catalog: Fifa17CardCatalog, store: Arc<dyn ExternalIdentityStore>) -> Self {
Fifa17IdentityResolver { catalog, store }
}
/// Reverse an owned-item wire id back to its Core owned-instance id (used by
/// later item-operation slices). `None` = unknown wire id, never a guess.
pub fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
self.store
.core_for(
Fifa17WireItemIdPolicy::GAME,
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
wire,
)
.unwrap_or(None)
}
}
/// Map-backed resolver (from a config file or tests): Core card id → FIFA asset
/// id. The wire item id is derived stably from the owned-card id (adequate for a
/// read-only search; item-operation identity is a later slice).
pub struct MapAssetResolver {
map: HashMap<String, u32>,
}
impl MapAssetResolver {
pub fn from_map(map: HashMap<String, u32>) -> Self {
MapAssetResolver { map }
}
/// Load `{ "card_id": assetId, … }` from a JSON file.
pub fn from_json_file(path: &str) -> std::io::Result<Self> {
let raw = std::fs::read_to_string(path)?;
let v: Value = serde_json::from_str(&raw)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut map = HashMap::new();
if let Some(obj) = v.as_object() {
for (k, val) in obj {
if let Some(id) = val.as_u64() {
map.insert(k.clone(), id as u32);
}
}
}
Ok(MapAssetResolver { map })
}
}
/// Stable wire item id in the 100_000_000+ space (FNV-1a of the owned id).
fn stable_item_id(owned_card_id: &str) -> u32 {
let mut h: u32 = 2_166_136_261;
for b in owned_card_id.bytes() {
h ^= b as u32;
h = h.wrapping_mul(16_777_619);
}
100_000_000 + (h % 900_000_000)
}
impl ItemIdentityResolver for MapAssetResolver {
impl ItemIdentityResolver for Fifa17IdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> {
let asset = *self.map.get(&item.card_id)?;
// Definition identity first: an unmapped card is dropped (never faked).
let ident = self.catalog.lookup(&item.card_id)?;
// Instance identity: stable, persistent, reversible wire id.
let wire = match self.store.resolve_or_allocate(
Fifa17WireItemIdPolicy::GAME,
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
&item.owned_card_id,
Fifa17WireItemIdPolicy::owned_item_base_floor(),
) {
Ok(w) => w,
Err(e) => {
// Infrastructure failure allocating a wire id: drop this item
// (freeze-safe) and log — never emit an unstable/fake id.
eprintln!(
"utas-host ERROR identity store alloc failed for {}: {e}",
item.owned_card_id
);
return None;
}
};
Some(Fifa17Identity {
item_id: stable_item_id(&item.owned_card_id),
asset_id: asset,
// Wire ids live in 1e8..9e8 (policy) — well within u32.
item_id: wire as u32,
asset_id: ident.asset_id,
})
}
}
@@ -479,18 +493,19 @@ impl Server {
}
}
/// Build from config: load entity tables, pick the asset resolver, wire the
/// Core client and Python passthrough.
/// Build from config: load entity tables + the FIFA 17 identity catalog, open
/// the persistent identity store, and wire the Core client + Python
/// passthrough. Fails clearly if a required production identity source cannot
/// be loaded — there is no placeholder fallback.
pub fn from_config(cfg: &HostConfig) -> Result<Self, String> {
let entities = Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir))
.map_err(|e| format!("loading entity tables from {}: {e}", cfg.tables_dir))?;
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> = match &cfg.asset_map_path {
Some(p) => Arc::new(
MapAssetResolver::from_json_file(p)
.map_err(|e| format!("loading asset map {p}: {e}"))?,
),
None => Arc::new(EmptyAssetResolver),
};
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
.map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?;
let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
.map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?;
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> =
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
Ok(Server {
core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())),
entities: Arc::new(entities),
@@ -740,12 +755,117 @@ mod tests {
assert_eq!(it.attributes, [80, 70, 75, 78, 84, 82]);
}
// ── Fifa17IdentityResolver: the single production identity path ──────────
fn test_catalog(cards: &[(&str, u32)]) -> Fifa17CardCatalog {
let entries: Vec<String> = cards
.iter()
.map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
.collect();
let doc = format!(
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
entries.join(",")
);
Fifa17CardCatalog::from_json_str(&doc).unwrap()
}
fn temp_store_path(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!(
"ofut-resolver-{tag}-{}-{n}.json",
std::process::id()
))
}
fn owned(owned_id: &str, card: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned_id.into(),
card_id: card.into(),
rating: 90,
position: "ST".into(),
nation: "Argentina".into(),
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 90, 80, 91, 33, 80],
}
}
fn resolver(path: &std::path::Path, cards: &[(&str, u32)]) -> Fifa17IdentityResolver {
let store = openfut_identity::JsonIdentityStore::open(path).unwrap();
Fifa17IdentityResolver::new(test_catalog(cards), Arc::new(store))
}
#[test]
fn stable_item_id_is_deterministic_and_in_range() {
let a = stable_item_id("oc1");
let b = stable_item_id("oc1");
assert_eq!(a, b);
assert!((100_000_000..1_000_000_000).contains(&a));
assert_ne!(stable_item_id("oc1"), stable_item_id("oc2"));
fn resolver_maps_definition_and_allocates_wire_id() {
let p = temp_store_path("map");
let r = resolver(&p, &[("card_gold_001", 20801)]);
let id = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
assert_eq!(id.asset_id, 20801, "real asset from the catalog");
assert_eq!(
id.item_id, 100_000_001,
"first wire id from the policy floor"
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn resolver_drops_unmapped_definition_never_faking() {
let p = temp_store_path("drop");
let r = resolver(&p, &[("card_gold_001", 20801)]);
assert!(
r.resolve(&owned("oc1", "card_unknown")).is_none(),
"no catalog entry => dropped, never a fabricated id"
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn two_copies_of_a_definition_share_resource_but_get_distinct_wire_ids() {
let p = temp_store_path("copies");
let r = resolver(&p, &[("card_gold_001", 20801)]);
let a = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
let b = r.resolve(&owned("oc2", "card_gold_001")).unwrap();
assert_eq!(a.asset_id, b.asset_id, "same definition => same resourceId");
assert_ne!(a.item_id, b.item_id, "distinct copies => distinct wire ids");
// Idempotent: the same owned instance re-resolves to the same wire id.
assert_eq!(
r.resolve(&owned("oc1", "card_gold_001")).unwrap().item_id,
a.item_id
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn wire_id_survives_restart_and_reverses_exactly() {
let p = temp_store_path("restart");
let first = {
let r = resolver(&p, &[("card_gold_001", 20801)]);
r.resolve(&owned("oc-stable", "card_gold_001"))
.unwrap()
.item_id
};
// Reopen the same store file (simulating a host restart).
let r2 = resolver(&p, &[("card_gold_001", 20801)]);
let again = r2
.resolve(&owned("oc-stable", "card_gold_001"))
.unwrap()
.item_id;
assert_eq!(
first, again,
"same owned instance keeps its wire id across restart"
);
assert_eq!(
r2.owned_id_for_wire(again as i64).as_deref(),
Some("oc-stable"),
"reverse lookup returns the exact owned instance"
);
assert_eq!(
r2.owned_id_for_wire(999_999_999),
None,
"unknown wire id => None"
);
let _ = std::fs::remove_file(&p);
}
}
+2 -2
View File
@@ -15,8 +15,8 @@ fn main() {
}
};
eprintln!(
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} asset_map={:?}",
cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.asset_map_path
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={}",
cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path
);
let server = match Server::from_config(&cfg) {
Ok(s) => s,
+28 -9
View File
@@ -8,11 +8,13 @@ use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use openfut_adapter_fifa17::fut::club_response::CoreOwnedItem;
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_identity::JsonIdentityStore;
use openfut_utas_host::{
classify, read_request, CoreAccess, CoreError, CorePage, EmptyAssetResolver, MapAssetResolver,
PassClient, Route, Server,
classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver, PassClient,
Route, Server,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -149,17 +151,34 @@ fn spawn_mock_python() -> (String, Recorded) {
(format!("http://{addr}"), rec)
}
/// A unique temp path for a per-server identity store (integration tests run in
/// the same process; each server gets its own store file).
fn unique_store_path() -> std::path::PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!("ofut-host-it-{}-{n}.json", std::process::id()))
}
fn build_server(
core: Arc<FakeCore>,
upstream: &str,
assets_map: Option<HashMap<String, u32>>,
) -> Server {
let assets: Arc<
dyn openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver + Send + Sync,
> = match assets_map {
Some(m) => Arc::new(MapAssetResolver::from_map(m)),
None => Arc::new(EmptyAssetResolver),
};
// The map (card id → asset id) becomes a real identity catalog; an absent
// map is an empty catalog (every item dropped — the honest no-mapping case).
let cards = assets_map.unwrap_or_default();
let entries: Vec<String> = cards
.iter()
.map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
.collect();
let doc = format!(
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
entries.join(",")
);
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> =
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
Server::new(
core,
Arc::new(entities()),