feat(fifa17): attach economy services in Server::from_config
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.
This commit is contained in:
@@ -27,6 +27,13 @@ pub struct HostConfig {
|
|||||||
/// Required, non-zero: it stamps `personaId` on the Core-backed
|
/// Required, non-zero: it stamps `personaId` on the Core-backed
|
||||||
/// `GET /squad/active`, and must match the persona LSX/Blaze/POW/UTAS use.
|
/// `GET /squad/active`, and must match the persona LSX/Blaze/POW/UTAS use.
|
||||||
pub persona_id: i64,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -71,6 +78,8 @@ impl HostConfig {
|
|||||||
catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
|
catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
|
||||||
identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
|
identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
|
||||||
persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?,
|
persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?,
|
||||||
|
market_db_path: required("OPENFUT_MARKET_DB")?,
|
||||||
|
pile_db_path: required("OPENFUT_PILE_DB")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1829,19 +1829,55 @@ impl Server {
|
|||||||
let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
|
let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
|
||||||
.map_err(|e| format!("opening identity store {}: {e}", 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)));
|
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||||
Ok(Server {
|
// One HTTP client, shared as both the read (`CoreAccess`) and the economy
|
||||||
core: Arc::new(HttpCoreClient::new(
|
// (`CoreEconomy`) transport — the same Core, one connection policy.
|
||||||
cfg.core_url.clone(),
|
let client = Arc::new(HttpCoreClient::new(
|
||||||
Fifa17WireItemIdPolicy::GAME,
|
cfg.core_url.clone(),
|
||||||
)),
|
Fifa17WireItemIdPolicy::GAME,
|
||||||
entities: Arc::new(entities),
|
));
|
||||||
|
let core: Arc<dyn CoreAccess> = client.clone();
|
||||||
|
let econ: Arc<dyn CoreEconomy> = 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,
|
resolver,
|
||||||
pass: Arc::new(PassClient::new(cfg.python_upstream.clone())),
|
Arc::new(PassClient::new(cfg.python_upstream.clone())),
|
||||||
persona_id: cfg.persona_id,
|
cfg.persona_id,
|
||||||
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
)
|
||||||
start: Instant::now(),
|
.with_economy(economy))
|
||||||
economy: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the shared squad dependencies (Core access + the one production
|
/// Assemble the shared squad dependencies (Core access + the one production
|
||||||
|
|||||||
@@ -709,3 +709,183 @@ async fn economy_full_sequence_through_dispatch_and_restart() {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user