From 43917a005198c97296536202eca077d3873a73a1 Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 21:59:29 +0000 Subject: [PATCH] feat(fifa17): attach economy services in Server::from_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the PRODUCTION constructor so the economy authority is not test-only. Server::from_config now builds one process-lifetime AsyncBridge, opens the durable MarketStore + PileStore (paths from config), shares one HttpCoreClient as both CoreAccess and CoreEconomy, builds the content pool from Core, and attaches EconomyServices via with_economy. Stores/bridge are host-lifetime, never per request. - config.rs: required OPENFUT_MARKET_DB / OPENFUT_PILE_DB (durable file paths; must survive host restart — no temp defaults). - Fail-closed startup: a bridge/store that cannot initialize returns Err from from_config (host refuses to start) — NEVER a silent omission or a Python economy fallback. Test: from_config_constructs_and_serves_economy — builds the Server via the REAL from_config (disposable config: temp market/pile/identity + a catalog file derived from seeded content + the real tables dir) against a live Core, drives credits / purchasegroup / Store BUY / market list-query-buy through it, then rebuilds from the SAME config after a Core restart and asserts the balance persisted. host 71 lib + 3 integration + 24 host_test green; clippy/fmt clean. --- openfut-utas-host/src/config.rs | 9 + openfut-utas-host/src/lib.rs | 60 ++++-- .../tests/economy_integration.rs | 180 ++++++++++++++++++ 3 files changed, 237 insertions(+), 12 deletions(-) diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs index 7a4f0f5..59b1938 100644 --- a/openfut-utas-host/src/config.rs +++ b/openfut-utas-host/src/config.rs @@ -27,6 +27,13 @@ pub struct HostConfig { /// Required, non-zero: it stamps `personaId` on the Core-backed /// `GET /squad/active`, and must match the persona LSX/Blaze/POW/UTAS use. pub persona_id: i64, + /// Durable FIFA17 transfer-market listing DB (host-owned SQLite). Required + /// for the economy cutover; must survive host restart (a real path, not a + /// temp file). Env `OPENFUT_MARKET_DB`. + pub market_db_path: String, + /// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must + /// survive host restart. Env `OPENFUT_PILE_DB`. + pub pile_db_path: String, } #[derive(Debug)] @@ -71,6 +78,8 @@ impl HostConfig { catalog_path: required("OPENFUT_FIFA17_CATALOG")?, identity_store_path: required("OPENFUT_IDENTITY_STORE")?, persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?, + market_db_path: required("OPENFUT_MARKET_DB")?, + pile_db_path: required("OPENFUT_PILE_DB")?, }) } } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 540e7d2..2dbb317 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -1829,19 +1829,55 @@ impl Server { let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path) .map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?; let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); - Ok(Server { - core: Arc::new(HttpCoreClient::new( - cfg.core_url.clone(), - Fifa17WireItemIdPolicy::GAME, - )), - entities: Arc::new(entities), + // One HTTP client, shared as both the read (`CoreAccess`) and the economy + // (`CoreEconomy`) transport — the same Core, one connection policy. + let client = Arc::new(HttpCoreClient::new( + cfg.core_url.clone(), + Fifa17WireItemIdPolicy::GAME, + )); + let core: Arc = client.clone(); + let econ: Arc = client; + let entities = Arc::new(entities); + + // Economy authority services: one runtime bridge + the two durable + // host-owned SQLite stores (opened here, at host lifetime, NEVER per + // request) + the content pool. A store that cannot open is a hard startup + // failure — NEVER a silent omission or a Python economy fallback. + let bridge = Arc::new( + crate::async_bridge::AsyncBridge::new() + .map_err(|e| format!("building economy runtime bridge: {e}"))?, + ); + let market_path = cfg.market_db_path.clone(); + let market = Arc::new( + bridge + .block_on(async move { crate::market_store::MarketStore::open(&market_path).await }) + .map_err(|e| format!("opening market store {}: {e}", cfg.market_db_path))?, + ); + let pile_path = cfg.pile_db_path.clone(); + let piles = Arc::new( + bridge + .block_on(async move { crate::pile_store::PileStore::open(&pile_path).await }) + .map_err(|e| format!("opening pile store {}: {e}", cfg.pile_db_path))?, + ); + // Content pool from Core's current inventory (empty ⇒ Store fails closed, + // never mints/debits — an honest degrade if Core is not yet seeded). + let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref())); + let economy = Arc::new(EconomyServices { + econ, + market, + piles, + bridge, + pool, + }); + + Ok(Server::new( + core, + entities, resolver, - pass: Arc::new(PassClient::new(cfg.python_upstream.clone())), - persona_id: cfg.persona_id, - sessions: Arc::new(Mutex::new(SessionStore::new())), - start: Instant::now(), - economy: None, - }) + Arc::new(PassClient::new(cfg.python_upstream.clone())), + cfg.persona_id, + ) + .with_economy(economy)) } /// Assemble the shared squad dependencies (Core access + the one production diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 7d16d49..1d06449 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -709,3 +709,183 @@ async fn economy_full_sequence_through_dispatch_and_restart() { std::fs::remove_dir_all(&dir).ok(); } + +// ─────────────── Production constructor (Server::from_config) E2E ──────────── +// +// Proves the PRODUCTION wiring path, not just manual `with_economy`: build the +// Server from a real (disposable) HostConfig — which itself opens the durable +// market/pile stores + the runtime bridge + the content pool — and drive the +// economy through it, then restart from the same config/files and prove +// persistence. + +/// Write a catalog FILE mapping every seeded Core definition to a deterministic +/// asset id (asset 20000 = the first, so wire resourceId 20000 reverse-maps). +fn write_catalog_file(base: &str, path: &std::path::Path) { + let owned = HttpCoreClient::new(base, "fifa17") + .all_owned() + .expect("core collection"); + let mut entries = String::new(); + let mut seen = std::collections::HashSet::new(); + let mut asset = 20000u32; + for it in &owned { + if !seen.insert(it.card_id.clone()) { + continue; + } + if !entries.is_empty() { + entries.push(','); + } + entries.push_str(&format!("\"{}\":{{\"asset_id\":{asset}}}", it.card_id)); + asset += 1; + } + std::fs::write( + path, + format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{entries}}}}}"), + ) + .unwrap(); +} + +fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::HostConfig { + let catalog = dir.join("catalog.json"); + write_catalog_file(base, &catalog); + openfut_utas_host::config::HostConfig { + listen_addr: "127.0.0.1:0".into(), + python_upstream: "http://127.0.0.1:9".into(), // unused by economy dispatch + core_url: base.to_string(), + tables_dir: "../fifa17-recon/data/tables".into(), + catalog_path: catalog.to_string_lossy().into_owned(), + identity_store_path: dir.join("identity.json").to_string_lossy().into_owned(), + persona_id: 33_068_179, + market_db_path: dir.join("market.db").to_string_lossy().into_owned(), + pile_db_path: dir.join("pile.db").to_string_lossy().into_owned(), + } +} + +fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 { + wait_ready(base); + let cfg = from_config(base, dir); + // PRODUCTION constructor — economy services are attached by from_config, NOT + // injected by the test. + let server = Server::from_config(&cfg).expect("from_config builds economy services"); + let client = HttpCoreClient::new(base, "fifa17"); + let start = client.balance().unwrap(); + + // credits + purchasegroup readers. + let cr = server + .try_handle_economy("GET", "/ut/game/fifa17/user/credits", &[], b"", None) + .expect("credits routed"); + assert_eq!( + serde_json::from_slice::(&cr.body).unwrap()["currencies"][0]["funds"], + start + ); + let pg = server + .try_handle_economy("GET", "/ut/game/fifa17/store/purchasegroup", &[], b"", None) + .expect("purchasegroup routed"); + assert_eq!(pg.status, 200); + + // Store BUY (writer) through the production-built server. + let buy = server + .try_handle_economy( + "PUT", + "/ut/game/fifa17/store/transaction", + &[], + br#"{"packId":1}"#, + None, + ) + .expect("buy routed"); + assert_eq!(buy.status, 200); + assert_eq!( + client.balance().unwrap(), + start - 400, + "BUY debited via from_config server" + ); + + // Market list -> query -> buy through the production-built server. + let list = server + .try_handle_economy( + "POST", + "/ut/game/fifa17/auctionhouse", + &[], + br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"#, + None, + ) + .expect("list routed"); + let trade_id = serde_json::from_slice::(&list.body).unwrap()["id"] + .as_i64() + .unwrap(); + let q = server + .try_handle_economy("GET", "/ut/game/fifa17/tradePile", &[], b"", None) + .unwrap(); + assert_eq!( + serde_json::from_slice::(&q.body).unwrap()["auctionInfo"] + .as_array() + .unwrap() + .len(), + 1 + ); + let before_buy = client.balance().unwrap(); + server + .try_handle_economy( + "POST", + &format!("/ut/game/fifa17/trade/{trade_id}"), + &[], + b"{}", + None, + ) + .expect("market buy routed"); + assert_eq!( + client.balance().unwrap(), + before_buy - 1000, + "market buy debited" + ); + client.balance().unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn from_config_constructs_and_serves_economy() { + let dir = std::env::temp_dir().join(format!( + "openfut-econ-fromcfg-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let db_url = format!("sqlite://{}/econ.db", dir.display()); + + let (h1, base1) = start_core_seeded(&db_url, true).await; + let (b1, d1) = (base1.clone(), dir.clone()); + let final_balance = tokio::task::spawn_blocking(move || { + let t = std::thread::spawn(move || exercise_from_config(&b1, &d1)); + t.join().expect("from_config thread") + }) + .await + .expect("from_config exercise"); + h1.abort(); + + // Restart Core + rebuild the Server from the SAME config/files: balance + // persists, and a fresh from_config server serves it. + let (h2, base2) = start_core_seeded(&db_url, false).await; + let (b2, d2) = (base2.clone(), dir.clone()); + tokio::task::spawn_blocking(move || { + let t = std::thread::spawn(move || { + wait_ready(&b2); + let cfg = from_config(&b2, &d2); + let server = Server::from_config(&cfg).expect("from_config rebuild"); + let cr = server + .try_handle_economy("GET", "/ut/game/fifa17/user/credits", &[], b"", None) + .unwrap(); + assert_eq!( + serde_json::from_slice::(&cr.body).unwrap()["currencies"][0]["funds"], + final_balance, + "balance persists across a from_config restart" + ); + }); + t.join().expect("restart thread") + }) + .await + .expect("from_config restart"); + h2.abort(); + + std::fs::remove_dir_all(&dir).ok(); +}