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 = [ dependencies = [
"openfut-adapter-fifa17", "openfut-adapter-fifa17",
"openfut-http", "openfut-http",
"openfut-identity",
"parking_lot", "parking_lot",
"reqwest", "reqwest",
"serde_json", "serde_json",
+1
View File
@@ -9,6 +9,7 @@ publish = false
[dependencies] [dependencies]
openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" }
openfut-http = { path = "../openfut-http" } openfut-http = { path = "../openfut-http" }
openfut-identity = { path = "../openfut-identity" }
serde_json = "1" serde_json = "1"
# Plain-HTTP client for Core queries and Python passthrough. UTAS is plaintext # 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. # 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_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_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_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_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 FIFA renders an owned card by resolving `resourceId & 0xffffff` against the
client's **own local players table**; an invented id renders a **blank generic 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 card** (proven live — `fut_cards.py:11-21`). Two distinct identities, never
string-id cards (`card_pl_001`) with **no FIFA asset id**, and no committed conflated, are resolved by [`Fifa17IdentityResolver`] (the single production path):
card→asset mapping exists. So:
- Without `OPENFUT_FIFA17_ASSET_MAP`, `/club` returns `{"itemData":[]}` — honest, - **Definition identity** (`resourceId`/`assetId`) — the card's real FIFA asset
never faked. The shaper drops any item lacking a **real** asset id. id, from the versioned `OPENFUT_FIFA17_CATALOG`. An unmapped definition is
- Making Core inventory actually render in retail requires a Core-card→FIFA-asset **dropped and counted**, never faked.
identity decision (seed Core from FIFA assets, or a real mapping table). This is - **Instance identity** (`id`) — a stable, persistent, reversible wire integer
the "who owns FUT state" question and is the **prerequisite** for a rendering from the generic `openfut-identity` store under the FIFA 17 wire-id policy
retail `/club`. Filtering, pagination, entity mapping, transport and fallback are (monotonic from `100_000_001`). The same owned instance keeps its id across
all done and tested independently of it. 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 **Remaining prerequisite for a *rendering* retail `/club`:** Core inventory must
guessed). 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) ## Retail A/B runbook (first `/club` gate)
@@ -70,7 +79,7 @@ Preconditions (mirror the proven blaze/roster switch discipline):
Bring-up: Bring-up:
1. Move Python UTAS to an alternate port (`FUT_PORT=8199` in the container/`openfut-fut.sh`); it keeps serving there. 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: 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. 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): Evidence to capture (all six):
+10 -6
View File
@@ -15,9 +15,14 @@ pub struct HostConfig {
pub core_url: String, pub core_url: String,
/// Directory holding `leagues.json`/`nations.json`/`teams.json`. /// Directory holding `leagues.json`/`nations.json`/`teams.json`.
pub tables_dir: String, pub tables_dir: String,
/// Optional JSON file mapping Core card id → FIFA asset id. Absent = the /// FIFA 17 card-definition **identity catalog** (card id → FIFA asset id).
/// current reality (no mapping) → Core items cannot render and are dropped. /// Required production identity source: a `/club` item's `resourceId` comes
pub asset_map_path: Option<String>, /// 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)] #[derive(Debug)]
@@ -46,9 +51,8 @@ impl HostConfig {
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()), .unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR") tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()), .unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
asset_map_path: env::var("OPENFUT_FIFA17_ASSET_MAP") catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
.ok() identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
.filter(|s| !s.is_empty()),
}) })
} }
} }
+186 -66
View File
@@ -36,16 +36,17 @@
pub mod config; pub mod config;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::sync::Arc; use std::sync::Arc;
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
use openfut_adapter_fifa17::fut::club_response::{ use openfut_adapter_fifa17::fut::club_response::{
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
}; };
use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::owned_query::{map_to_core, parse_club_query, MapError}; use openfut_adapter_fifa17::fut::owned_query::{map_to_core, parse_club_query, MapError};
use openfut_identity::ExternalIdentityStore;
use serde_json::{json, Value}; use serde_json::{json, Value};
use config::HostConfig; 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 /// The single production [`ItemIdentityResolver`]: it composes the two distinct
/// every item is dropped (rendered response is `{"itemData":[]}`). Honest, not /// 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. /// faked.
pub struct EmptyAssetResolver; /// * **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 { impl Fifa17IdentityResolver {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> { pub fn new(catalog: Fifa17CardCatalog, store: Arc<dyn ExternalIdentityStore>) -> Self {
None 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 impl ItemIdentityResolver for Fifa17IdentityResolver {
/// 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 {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> { 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 { Some(Fifa17Identity {
item_id: stable_item_id(&item.owned_card_id), // Wire ids live in 1e8..9e8 (policy) — well within u32.
asset_id: asset, 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 /// Build from config: load entity tables + the FIFA 17 identity catalog, open
/// Core client and Python passthrough. /// 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> { pub fn from_config(cfg: &HostConfig) -> Result<Self, String> {
let entities = Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)) 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))?; .map_err(|e| format!("loading entity tables from {}: {e}", cfg.tables_dir))?;
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> = match &cfg.asset_map_path { let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
Some(p) => Arc::new( .map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?;
MapAssetResolver::from_json_file(p) let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
.map_err(|e| format!("loading asset map {p}: {e}"))?, .map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?;
), let assets: Arc<dyn ItemIdentityResolver + Send + Sync> =
None => Arc::new(EmptyAssetResolver), Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
};
Ok(Server { Ok(Server {
core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())), core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())),
entities: Arc::new(entities), entities: Arc::new(entities),
@@ -740,12 +755,117 @@ mod tests {
assert_eq!(it.attributes, [80, 70, 75, 78, 84, 82]); 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] #[test]
fn stable_item_id_is_deterministic_and_in_range() { fn resolver_maps_definition_and_allocates_wire_id() {
let a = stable_item_id("oc1"); let p = temp_store_path("map");
let b = stable_item_id("oc1"); let r = resolver(&p, &[("card_gold_001", 20801)]);
assert_eq!(a, b); let id = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
assert!((100_000_000..1_000_000_000).contains(&a)); assert_eq!(id.asset_id, 20801, "real asset from the catalog");
assert_ne!(stable_item_id("oc1"), stable_item_id("oc2")); 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!( eprintln!(
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} asset_map={:?}", "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.asset_map_path 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) { let server = match Server::from_config(&cfg) {
Ok(s) => s, 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::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; 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_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_identity::JsonIdentityStore;
use openfut_utas_host::{ use openfut_utas_host::{
classify, read_request, CoreAccess, CoreError, CorePage, EmptyAssetResolver, MapAssetResolver, classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver, PassClient,
PassClient, Route, Server, Route, Server,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_json::Value; use serde_json::Value;
@@ -149,17 +151,34 @@ fn spawn_mock_python() -> (String, Recorded) {
(format!("http://{addr}"), rec) (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( fn build_server(
core: Arc<FakeCore>, core: Arc<FakeCore>,
upstream: &str, upstream: &str,
assets_map: Option<HashMap<String, u32>>, assets_map: Option<HashMap<String, u32>>,
) -> Server { ) -> Server {
let assets: Arc< // The map (card id → asset id) becomes a real identity catalog; an absent
dyn openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver + Send + Sync, // map is an empty catalog (every item dropped — the honest no-mapping case).
> = match assets_map { let cards = assets_map.unwrap_or_default();
Some(m) => Arc::new(MapAssetResolver::from_map(m)), let entries: Vec<String> = cards
None => Arc::new(EmptyAssetResolver), .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( Server::new(
core, core,
Arc::new(entities()), Arc::new(entities()),