//! Python-oracle differential economy harness. //! //! Boots the REAL Python UTAS oracle (`fifa17-recon/tools/utas_server.py`) as an //! isolated subprocess (temp `fut_profile.json`, temp account file, chosen //! loopback port, disposable log — never the production container, port, or //! save) AND the REAL Rust economy stack (in-process Core seeded via //! `start_core_seeded` + a real `Server` with `EconomyServices` from //! `build_econ_server`). It then drives the SAME semantic op sequence against //! BOTH and compares HTTP status, JSON structure, coin/entitlement/inventory //! deltas, wire-significant ids, and pile/listing/reveal state — classifying //! every op PARITY or DIFFERENT-BY-DESIGN. //! //! Starting fixtures are semantically aligned (both own one pack-70 entitlement; //! Python's fresh profile seeds pack 70, Core is granted the `"70"` entitlement //! via its economy API). The seed BALANCES differ by design — the oracle's //! `_new_profile` grants 15 000 coins, `start_core_seeded` grants 100 000 — so //! the differential compares coin DELTAS and structure, never absolute balances. //! No Python source is modified; the classifier is untouched. //! //! ────────────────────────── PARITY / DIFFERENT-BY-DESIGN matrix ───────────── //! //! | op | class | notes | //! |------------------------|--------------------|--------------------------------------------------------------| //! | credits | PARITY | identical body shape: `credits`+`currencies[coins/points]` | //! | | | (`funds==finalFunds==balance`) + `unopenedPacks.recovered=1`.| //! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both | //! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent | //! | | | across the credits & massinfo surfaces on each side. | //! | purchasegroup pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue | //! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. | //! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle | //! | | | {1,5,6,7,65534}; sentinel `state:"active"` on both. | //! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};| //! | | | sentinel stripped. Rust drives the REAL `SessionStore` state | //! | | | machine (register_capability+open_session+freeze). | //! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; | //! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. | //! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", | //! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. | //! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal | //! | | | = `PileStore.list_by_pile("purchased")` + Core inventory, NO | //! | | | per-SID cache). Non-empty after open; idempotent on repeat. | //! | | | The hypothesised per-SID cache does NOT exist — VERIFIED. | //! | quick-sell DELETE/item | PARITY | 200; `{items:[{id}],totalCredits}`; exactly one sold; coin | //! | | | delta > 0; `totalCredits`==absolute post-sale balance. | //! | quick-sell POST delete | PARITY | batch form `{itemData:[{id}]}` -> same body shape/mechanism. | //! | move-items PUT /item | PARITY | 200; `{itemData:[{id,pile,success:true}]}` verdict ack. | //! | match reward (WIN) | PARITY | 200; `{allCoins,matchCoins:400,gameModeAward{coins:400}, | //! | | | seasonCoins,tournamentCoins,boostConis,participationAward, | //! | | | teamOfTournamentWinner}`; coin delta +400. Byte-shape match. | //! | market list POST /ah | PARITY | 200; `{"id":}` (a positive listing id). Id SPACES | //! | | | differ (Rust MarketStore vs oracle 900500000+seq) — a | //! | | | wire-insignificant server-private handle. | //! | market query tradePile | DIFFERENT-BY-DESIGN| after list -> `auctionInfo` len 1, `tradeState:"active"`. | //! | | | ONE field diverges deliberately: `itemData.itemState`. The | //! | | | oracle emits `listFS`, which does not exist in FIFA 17 (0 in | //! | | | CardsDLL, 0 in 4.26 GiB of client memory) and decodes to -1; | //! | | | Rust emits `forSale` (5), the client's own token. Parity here | //! | | | passed while BOTH were wrong, which is why it survived. | //! | market buy POST /trade | DIFFERENT-BY-DESIGN| first buy debits exactly `buyNowPrice` & closes on BOTH, but | //! | | | Rust's MarketStore is STATEFUL single-debit (a second buy of | //! | | | a sold listing is a no-op: 0 delta, empty `auctionInfo`) | //! | | | whereas the oracle's buyable market is a STATELESS sample | //! | | | reconstruction (`_auction_by_tradeid`, id base 900000000) | //! | | | that RE-DEBITS on every repeat buy and is a DISTINCT id | //! | | | space from the user's own sale pile (900500000+). See below. | //! | market cancel DELETE | PARITY | 200; a cancelled listing is no longer active/buyable on both | //! | | | (Rust: buying it is a 0-delta empty `auctionInfo`; oracle: | //! | | | it drops out of `tradePile`). Response body differs (`{}` | //! | | | oracle vs closed-record Rust) — wire-insignificant. | //! //! DIFFERENT-BY-DESIGN — market buy idempotency & id-space, in full: //! * Python behaviour: `trade_route` reconstructs a buyable auction from the //! SAMPLE pool every call (`_auction_by_tradeid(tid)` = PACK_POOL[tid-900000000]); //! it holds no per-listing sold state, so POSTing the same sample tradeId twice //! debits `buyNowPrice` twice and grants twice. The user's OWN listings live in //! a separate id space (`list_for_sale` -> 900500000+seq) surfaced only by //! `/tradePile`, and are NOT buyable via `/trade/{id}` (that returns an empty //! auction). Evidence: probed live — buying sample tradeId 900000000 twice each //! returned `tradeState:"closed"` with a -buyNow coin delta both times. //! * Rust behaviour: `handle_market_buy` reserves+sells against a durable //! `MarketStore` keyed by the listing's own tradeId (unified with the sale //! pile); a second buy of a `sold` listing is a no-op (0 coin delta, empty //! `auctionInfo`). Evidence: asserted below (second buy delta 0). //! * Reason: the oracle is a single-file, single-profile emulator whose sample //! market exists only to make the client's Transfer Market browsable; it never //! needed durable auction state. The Rust cutover makes the market a real, //! crash-consistent, single-debit ledger backed by SQLite. Compatibility //! impact: NONE for the client — a buy-now is a one-shot UI action, so the //! re-debit path was never reachable in normal play; the Rust behaviour is a //! strict correctness improvement and is pinned by assertion so it cannot //! regress toward the stateless oracle shape. //! //! Safety: temp dir + `127.0.0.1` ephemeral ports only. The oracle subprocess is //! killed on guard drop. Never touches the production Core DB/ports or `.105`. use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME; use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID}; use openfut_identity::JsonIdentityStore; use openfut_utas_host::async_bridge::AsyncBridge; use openfut_utas_host::market_store::MarketStore; use openfut_utas_host::pile_store::PileStore; use openfut_utas_host::{ build_content_pool, handle_purchasegroup, overlay_massinfo_economy, CoreAccess, CoreEconomy, EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, }; use serde_json::{json, Value}; use std::collections::HashMap; use std::process::{Child, Command, Stdio}; use std::sync::Arc; /// The persona the oracle's `ACCOUNT` defaults to (tier-2 built-in). The Rust /// `SessionStore` clean-v1 handshake keys on the same value so both sides bind a /// verified capability to the same `(ip, persona)`. const PERSONA_ID: i64 = 33068179; // ─────────────────────────── Python oracle subprocess ─────────────────────── /// A running, isolated Python UTAS oracle; killed on drop. struct Oracle { child: Child, base: String, http: reqwest::blocking::Client, } impl Drop for Oracle { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } impl Oracle { /// Spawn the oracle bound to a fresh loopback port with a disposable profile, /// account file, and log — fully isolated from any production instance. fn spawn(dir: &std::path::Path) -> Oracle { // A free loopback port: bind :0, read the assignment, release it, hand the // number to Python via FUT_PORT. A brief TOCTOU window is acceptable on a // loopback test host. let port = { let l = std::net::TcpListener::bind("127.0.0.1:0").expect("free port"); l.local_addr().unwrap().port() }; let tools = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("fifa17-recon/tools"); assert!( tools.join("utas_server.py").exists(), "oracle source not found at {}", tools.display() ); let child = Command::new("python3") .arg("utas_server.py") .current_dir(&tools) .env("FUT_PROFILE", dir.join("fut_profile.json")) .env("FUT_ACCOUNT_PATH", dir.join("fut_account.json")) .env("FUT_LOG", dir.join("oracle.log")) .env("FUT_PORT", port.to_string()) .env("OPENFUT_BIND", "127.0.0.1") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn python oracle (is python3 on PATH?)"); Oracle { child, base: format!("http://127.0.0.1:{port}"), http: reqwest::blocking::Client::new(), } } /// Poll `/user/credits` until the oracle answers 200 (or panic after ~15s). fn wait_ready(&mut self) { for _ in 0..750 { if let Some(status) = self.child.try_wait().expect("try_wait") { panic!("oracle exited before ready: {status}"); } if let Ok(r) = self .http .get(format!("{}/ut/game/fifa17/user/credits", self.base)) .header("X-OpenFUT-Game", "fifa17") .send() { if r.status().is_success() { return; } } std::thread::sleep(std::time::Duration::from_millis(20)); } panic!("oracle never became ready at {}", self.base); } /// One request against the oracle. Returns `(status, json_body)`; a missing or /// empty body decodes to `Value::Null`. fn req( &self, method: &str, path: &str, body: Option, sid: Option<&str>, ) -> (u16, Value) { let m = reqwest::Method::from_bytes(method.as_bytes()).expect("method"); let mut rb = self .http .request(m, format!("{}{path}", self.base)) .header("X-OpenFUT-Game", "fifa17"); if let Some(s) = sid { rb = rb.header("X-UT-SID", s); } if let Some(b) = body { rb = rb.json(&b); } let resp = rb .send() .unwrap_or_else(|e| panic!("oracle {method} {path}: {e}")); let status = resp.status().as_u16(); let text = resp.text().unwrap_or_default(); let v: Value = if text.is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap_or(Value::Null) }; (status, v) } fn coins(&self) -> i64 { self.req("GET", "/ut/game/fifa17/user/credits", None, None) .1["currencies"][0]["funds"] .as_i64() .expect("oracle coins") } } // ─────────────────── Core boot + econ server (reference harness) ───────────── // Replicated from `tests/economy_integration.rs` (separate test binary, no shared // module) so this file is self-contained per the one-file-per-agent contract. /// Boot a Core with FIFA17 dev CONTENT loaded and, on first boot, the dev /// inventory SEEDED (a fifa17 profile + club with 100k coins + one owned /// instance per definition). Ephemeral loopback port. async fn start_core_seeded(db_url: &str, seed: bool) -> (tokio::task::JoinHandle<()>, String) { use openfut_core::config::Config; use openfut_core::seed::{seed_fifa17_dev, FIFA17_GAME}; use openfut_core::services::card_db::CardDb; let data_dir = "../openfut-core/data"; let pool = openfut_core::db::init_pool(db_url, 5) .await .expect("core pool"); openfut_core::db::run_migrations(&pool) .await .expect("core migrations"); if seed { let mut card_db = CardDb::load(data_dir).expect("card_db load"); card_db .load_game_dev(data_dir, FIFA17_GAME) .expect("load fifa17 dev content"); seed_fifa17_dev(&pool, &card_db) .await .expect("seed fifa17 dev inventory"); } let cfg = Config { listen_addr: "127.0.0.1:0".into(), database_url: "sqlite::memory:".into(), data_dir: data_dir.into(), max_connections: 5, dev_content_games: vec![FIFA17_GAME.to_string()], content_packs: Vec::new(), }; let app = openfut_core::app::build(pool, cfg) .await .expect("core app::build"); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind ephemeral"); let addr = listener.local_addr().unwrap(); let handle = tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); (handle, format!("http://{addr}")) } fn wait_ready(base: &str) { let http = reqwest::blocking::Client::new(); for _ in 0..1500 { if let Ok(r) = http.get(format!("{base}/health")).send() { if r.status().is_success() { return; } } std::thread::sleep(std::time::Duration::from_millis(20)); } panic!("core did not become ready at {base}"); } fn core_post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Value) -> Value { let resp = http .post(format!("{base}{path}")) .header("X-OpenFUT-Game", "fifa17") .json(&body) .send() .unwrap_or_else(|e| panic!("POST {path}: {e}")); let status = resp.status(); let v: Value = resp.json().unwrap_or(Value::Null); assert!(status.is_success(), "POST {path} -> {status}: {v}"); v } /// Build a real `Server` with economy authority wired against the seeded Core. /// Returns the server, a direct Core client for balance/entitlement assertions, /// the resolver used to obtain stable wire instance ids, and a valid wire /// `resourceId` (20000) that reverse-maps to a real Core card. fn build_econ_server( base: &str, dir: &std::path::Path, ) -> (Server, HttpCoreClient, Arc, i64) { let probe = HttpCoreClient::new(base, "fifa17"); let owned = probe.all_owned().expect("core collection"); assert!(!owned.is_empty(), "seed must grant a starter 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; } let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{entries}}}}}"); let catalog = Fifa17CardCatalog::from_json_str(&doc).expect("catalog"); let store = JsonIdentityStore::open(dir.join("identity.json").to_str().unwrap()).unwrap(); let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); let entities = Arc::new(Fifa17Entities::from_maps( HashMap::new(), HashMap::new(), HashMap::new(), )); let core: Arc = Arc::new(HttpCoreClient::new(base, "fifa17")); let bridge = Arc::new(AsyncBridge::new().unwrap()); let market_path = dir.join("market.db").to_string_lossy().into_owned(); let market = Arc::new( bridge .block_on(async move { MarketStore::open(&market_path).await }) .expect("market store"), ); let pile_path = dir.join("pile.db").to_string_lossy().into_owned(); let piles = Arc::new( bridge .block_on(async move { PileStore::open(&pile_path).await }) .expect("pile store"), ); let econ: Arc = Arc::new(HttpCoreClient::new(base, "fifa17")); let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref())); assert!( !pool.is_empty(), "content pool derived from real Core content" ); let services = Arc::new(EconomyServices { // Production default: the sold experiment is OFF. sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF, econ, market, piles, bridge, pool, }); let server = Server::new( core, entities, resolver.clone(), Arc::new(PassClient::new("http://127.0.0.1:9")), PERSONA_ID, ) .with_economy(services); (server, probe, resolver, 20000) } // ─────────────────────────── differential comparison ──────────────────────── /// A single `try_handle_economy` call against the Rust dispatch. Panics if the /// route is unhandled (all economy ops here are wired). fn rust(server: &Server, method: &str, path: &str, body: &[u8], sid: Option<&str>) -> (u16, Value) { let headers: Vec<(String, String)> = match sid { Some(s) => vec![("X-UT-SID".into(), s.into())], None => vec![], }; let resp = server .try_handle_economy(method, path, &headers, body, None) .unwrap_or_else(|| panic!("rust dispatch did not route {method} {path}")); let v: Value = if resp.body.is_empty() { Value::Null } else { serde_json::from_slice(&resp.body).unwrap_or(Value::Null) }; (resp.status, v) } /// Sorted `purchase[].id` set from a purchasegroup body. fn pack_ids(pg: &Value) -> Vec { let mut v: Vec = pg["purchase"] .as_array() .expect("purchase array") .iter() .map(|p| p["id"].as_u64().expect("pack id")) .collect(); v.sort_unstable(); v } /// Preserve every object key, array position, and JSON scalar kind while discarding /// wire-insignificant values such as translated labels and live completion counts. fn json_shape(value: &Value) -> Value { match value { Value::Null => json!("null"), Value::Bool(_) => json!("bool"), Value::Number(_) => json!("number"), Value::String(_) => json!("string"), Value::Array(values) => Value::Array(values.iter().map(json_shape).collect()), Value::Object(values) => Value::Object( values .iter() .map(|(key, value)| (key.clone(), json_shape(value))) .collect(), ), } } /// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking /// Core client + `reqwest::blocking` require this), exactly like the /// thread-per-connection server — the bridge takes its direct `block_on` path. fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { wait_ready(core_base); let http = reqwest::blocking::Client::new(); let (server, client, _resolver, _sample_resource) = build_econ_server(core_base, dir); // ── Fixture alignment: both sides own exactly one pack-70 entitlement. ── // Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement // through its real economy API (cost 0, no debit). core_post( &http, core_base, "/economy/purchase-entitlement", json!({ "cost": 0, "definition_id": "70" }), ); assert_eq!( oracle .req("GET", "/ut/game/fifa17/user/credits", None, None) .0, 200 ); let mut matrix: Vec<(&str, &str)> = Vec::new(); // ── OP 1: credits ────────────────────────────────────────────────────── let (os, ob) = oracle.req("GET", "/ut/game/fifa17/user/credits", None, None); let (rs, rb) = rust(&server, "GET", "/ut/game/fifa17/user/credits", b"", None); assert_eq!(os, 200); assert_eq!(rs, 200, "credits status parity"); for (tag, b) in [("oracle", &ob), ("rust", &rb)] { assert_eq!( b["currencies"][0]["name"], "coins", "{tag} currencies[0]=coins" ); assert_eq!( b["currencies"][1]["name"], "points", "{tag} currencies[1]=points" ); let coins = b["currencies"][0]["funds"].as_i64().unwrap(); assert_eq!( b["currencies"][0]["finalFunds"].as_i64().unwrap(), coins, "{tag} funds==finalFunds" ); assert_eq!( b["credits"].as_i64().unwrap(), coins, "{tag} credits scalar == coins" ); assert_eq!( b["unopenedPacks"]["recoveredPacks"].as_i64().unwrap(), 1, "{tag} recoveredPacks==1" ); } matrix.push(("credits", "PARITY")); // ── OP 2: userMassInfo economy ───────────────────────────────────────── let om = oracle .req("GET", "/ut/game/fifa17/userMassInfo", None, None) .1; let o_mass_coins = om["userInfo"]["currencies"][0]["funds"].as_i64().unwrap(); assert_eq!(om["userInfo"]["currencies"][0]["name"], "coins"); assert_eq!( o_mass_coins, ob["currencies"][0]["funds"].as_i64().unwrap(), "oracle massinfo coins == credits coins" ); assert_eq!( om["userInfo"]["unopenedPacks"]["recoveredPacks"] .as_i64() .unwrap(), 1, "oracle massinfo recoveredPacks==1" ); // Rust: overlay the authoritative Core economy onto a massinfo body in place. let mut mass = json!({ "userInfo": { "currencies": [ {"name":"coins","funds":0,"finalFunds":0} ] } }); let r_coins = client.balance().unwrap(); let r_ents = client.entitlements().unwrap().len(); assert!(overlay_massinfo_economy(&mut mass, r_coins, r_ents)); assert_eq!( mass["userInfo"]["currencies"][0]["funds"].as_i64().unwrap(), r_coins, "rust overlay coins == Core balance" ); assert_eq!( mass["userInfo"]["currencies"][0]["funds"], mass["userInfo"]["currencies"][0]["finalFunds"] ); assert_eq!( mass["userInfo"]["unopenedPacks"]["recoveredPacks"] .as_i64() .unwrap(), r_ents as i64, "rust overlay recoveredPacks == entitlements" ); assert_eq!( rb["currencies"][0]["funds"].as_i64().unwrap(), r_coins, "rust massinfo coins == credits coins" ); matrix.push(("userMassInfo economy", "PARITY")); // ── OP 3a: purchasegroup — pack70 owned (no sentinel) ────────────────── let opg = oracle .req("GET", "/ut/game/fifa17/store/purchasegroup", None, None) .1; let rpg = rust( &server, "GET", "/ut/game/fifa17/store/purchasegroup", b"", None, ) .1; // STORE DIVERGED: Rust is the authoritative store owner and serves the real // 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback // baseline, never modified) still serves the old placeholder set {1,5,6,7,70}, // so this is Rust-authoritative, NOT oracle parity. assert_eq!( pack_ids(&opg), vec![1, 5, 6, 7, 70], "oracle (rollback) placeholder id set" ); assert_eq!( pack_ids(&rpg), vec![1, 2, 3, 4, 5, 6, 70], "rust authoritative real-catalogue id set" ); for b in [&opg, &rpg] { assert!( !pack_ids(b).contains(&SENTINEL_PACK_ID), "no 65534 sentinel while a pack is owned" ); } // packType by side: oracle BRONZE for 1 else GOLD; rust by real category // (1,2 bronze / 3,4 silver / 5,6,70 gold). for p in opg["purchase"].as_array().unwrap() { let id = p["id"].as_u64().unwrap(); let want = if id == 1 { "BRONZE" } else { "GOLD" }; assert_eq!(p["packType"], want, "oracle packType for pack {id}"); } for p in rpg["purchase"].as_array().unwrap() { let id = p["id"].as_u64().unwrap(); let want = match id { 1 | 2 => "BRONZE", 3 | 4 => "SILVER", _ => "GOLD", }; assert_eq!(p["packType"], want, "rust packType for pack {id}"); } matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN")); // ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ──────────────── let o_bal0 = oracle.coins(); let (o_bs, o_buy) = oracle.req( "PUT", "/ut/game/fifa17/store/transaction", Some(json!({"packId":1})), None, ); let r_bal0 = client.balance().unwrap(); let (r_bs, r_buy) = rust( &server, "PUT", "/ut/game/fifa17/store/transaction", br#"{"packId":1}"#, None, ); assert_eq!(o_bs, 200); assert_eq!(r_bs, 200, "BUY status parity"); let o_items = o_buy["createPackResponse"]["itemList"] .as_array() .expect("oracle itemList") .clone(); let r_items = r_buy["createPackResponse"]["itemList"] .as_array() .expect("rust itemList") .clone(); // STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the // oracle placeholder awards 5. Both debit the same 400-coin price. assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards"); assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards"); assert_eq!( o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(), 5, "oracle numberItems==5" ); assert_eq!( r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(), 12, "rust numberItems==12" ); for b in [&o_buy, &r_buy] { let cpr = &b["createPackResponse"]; assert_eq!( cpr["purchasedPackId"].as_i64().unwrap(), 1, "purchasedPackId==1" ); assert!( cpr["duplicateItemIdList"].is_array(), "duplicateItemIdList is an array" ); } assert_eq!(oracle.coins() - o_bal0, -400, "oracle BUY debits 400"); assert_eq!( client.balance().unwrap() - r_bal0, -400, "rust BUY debits 400 (price parity)" ); matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN")); // Minted wire ids for the item ops that follow (both sides put them in the // pending purchased pile). let o_wire: Vec = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect(); let r_wire: Vec = r_items.iter().map(|i| i["id"].as_i64().unwrap()).collect(); assert!( r_wire[0] >= 100_000_000, "rust mints above the FIFA wire floor" ); // ── OP 5: POST /purchased — owned pack-70 open ───────────────────────── let o_bal_p = oracle.coins(); let (o_ps, o_open) = oracle.req( "POST", "/ut/game/fifa17/purchased", Some(json!({"packId":70})), None, ); let r_ent_before = client.entitlements().unwrap().len(); let r_bal_p = client.balance().unwrap(); let (r_ps, r_open) = rust( &server, "POST", "/ut/game/fifa17/purchased", br#"{"packId":70}"#, None, ); assert_eq!(o_ps, 200); assert_eq!(r_ps, 200, "pack-70 open status parity"); for b in [&o_open, &r_open] { assert_eq!(b["packId"].as_i64().unwrap(), 70, "open echoes pack id 70"); assert_eq!(b["productId"], "70", "productId echo"); assert_eq!(b["purchasePackType"], "GOLD", "reward pack is GOLD"); } assert_eq!(oracle.coins() - o_bal_p, 0, "oracle pack-70 open is free"); assert_eq!( client.balance().unwrap() - r_bal_p, 0, "rust pack-70 open is free" ); assert_eq!( client.entitlements().unwrap().len(), r_ent_before - 1, "rust consumes the 70 entitlement" ); matrix.push(("POST /purchased open70", "PARITY")); // ── OP 6: GET /purchased reveal (durable single-profile pile; idempotent) ─ let o_rev1 = oracle.req("GET", "/ut/game/fifa17/purchased", None, None).1["itemData"] .as_array() .unwrap() .len(); let r_rev1 = rust(&server, "GET", "/ut/game/fifa17/purchased", b"", None).1["itemData"] .as_array() .unwrap() .len(); assert!(o_rev1 > 0, "oracle reveal shows opened items ({o_rev1})"); assert!(r_rev1 > 0, "rust reveal shows opened items ({r_rev1})"); // Idempotent + durable: a repeat GET (with a DIFFERENT SID) returns the same // reveal — there is no per-SID cache; the pile is single-profile durable. let o_rev2 = oracle .req( "GET", "/ut/game/fifa17/purchased", None, Some("OPENFUT-SID-OTHER1"), ) .1["itemData"] .as_array() .unwrap() .len(); let r_rev2 = rust( &server, "GET", "/ut/game/fifa17/purchased", b"", Some("OPENFUT-SID-OTHER1"), ) .1["itemData"] .as_array() .unwrap() .len(); assert_eq!( o_rev2, o_rev1, "oracle reveal idempotent & SID-independent (durable single-profile)" ); assert_eq!( r_rev2, r_rev1, "rust reveal idempotent & SID-independent (durable single-profile, NO per-SID cache)" ); matrix.push(("GET /purchased reveal", "PARITY")); // ── OP 7: quick-sell (single, DELETE /item/) ─────────────────── let o_qbal = oracle.coins(); let (o_qs, o_q) = oracle.req( "DELETE", &format!("/ut/game/fifa17/item/{}", o_wire[0]), None, None, ); let r_qbal = client.balance().unwrap(); let (r_qs, r_q) = rust( &server, "DELETE", &format!("/ut/game/fifa17/item/{}", r_wire[0]), b"", None, ); assert_eq!(o_qs, 200); assert_eq!(r_qs, 200, "quick-sell status parity"); let o_qafter = oracle.coins(); let r_qafter = client.balance().unwrap(); for (tag, b, bal) in [("oracle", &o_q, o_qafter), ("rust", &r_q, r_qafter)] { assert_eq!( b["items"].as_array().unwrap().len(), 1, "{tag} sold exactly one" ); assert_eq!( b["totalCredits"].as_i64().unwrap(), bal, "{tag} totalCredits == absolute balance" ); } assert!(o_qafter - o_qbal > 0, "oracle quick-sell credits coins"); assert!(r_qafter - r_qbal > 0, "rust quick-sell credits coins"); matrix.push(("quick-sell DELETE/item", "PARITY")); // ── OP 8: quick-sell (batch, POST /ut/delete/.../item) ───────────────── let (o_qbs, o_qb) = oracle.req( "POST", "/ut/delete/game/fifa17/item", Some(json!({"itemData":[{"id":o_wire[3]}]})), None, ); let (r_qbs, r_qb) = rust( &server, "POST", "/ut/delete/game/fifa17/item", format!(r#"{{"itemData":[{{"id":{}}}]}}"#, r_wire[3]).as_bytes(), None, ); assert_eq!(o_qbs, 200); assert_eq!(r_qbs, 200, "batch quick-sell status parity"); for (tag, b, bal) in [ ("oracle", &o_qb, oracle.coins()), ("rust", &r_qb, client.balance().unwrap()), ] { assert_eq!( b["items"].as_array().unwrap().len(), 1, "{tag} batch sold one" ); assert_eq!( b["totalCredits"].as_i64().unwrap(), bal, "{tag} batch totalCredits == balance" ); } matrix.push(("quick-sell POST delete", "PARITY")); // ── OP 9: move-items (PUT /item, still-owned card -> trade pile) ──────── let (o_ms, o_mv) = oracle.req( "PUT", "/ut/game/fifa17/item", Some(json!({"itemData":[{"id":o_wire[1],"pile":"trade"}]})), None, ); let (r_ms, r_mv) = rust( &server, "PUT", "/ut/game/fifa17/item", format!(r#"{{"itemData":[{{"id":{},"pile":"trade"}}]}}"#, r_wire[1]).as_bytes(), None, ); assert_eq!(o_ms, 200); assert_eq!(r_ms, 200, "move status parity"); for (tag, b, id) in [("oracle", &o_mv, o_wire[1]), ("rust", &r_mv, r_wire[1])] { let v = &b["itemData"][0]; assert_eq!(v["success"], true, "{tag} move recorded success"); assert_eq!(v["pile"], "trade", "{tag} move pile echo"); assert_eq!(v["id"].as_i64().unwrap(), id, "{tag} move id echo"); } matrix.push(("move-items PUT /item", "PARITY")); // ── OP 10: match reward (WIN = +400) ─────────────────────────────────── let o_mbal = oracle.coins(); let (o_mms, o_mm) = oracle.req( "POST", "/ut/delete/game/fifa17/match", Some(json!({"endReason":"WIN"})), None, ); let r_mbal = client.balance().unwrap(); let (r_mms, r_mm) = rust( &server, "POST", "/ut/delete/game/fifa17/match", br#"{"endReason":"WIN"}"#, None, ); assert_eq!(o_mms, 200); assert_eq!(r_mms, 200, "match status parity"); for (tag, b, bal) in [ ("oracle", &o_mm, oracle.coins()), ("rust", &r_mm, client.balance().unwrap()), ] { assert_eq!( b["matchCoins"].as_i64().unwrap(), 400, "{tag} WIN matchCoins==400" ); assert_eq!( b["gameModeAward"]["coins"].as_i64().unwrap(), 400, "{tag} gameModeAward.coins==400" ); assert_eq!( b["allCoins"].as_i64().unwrap(), bal, "{tag} allCoins == post-credit balance" ); assert_eq!(b["seasonCoins"].as_i64().unwrap(), 0, "{tag} seasonCoins"); assert_eq!( b["tournamentCoins"].as_i64().unwrap(), 0, "{tag} tournamentCoins" ); assert_eq!( b["boostConis"].as_i64().unwrap(), 0, "{tag} boostConis (EA typo key)" ); assert_eq!( b["teamOfTournamentWinner"], false, "{tag} teamOfTournamentWinner" ); assert!( b["participationAward"].is_i64(), "{tag} participationAward present" ); } assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400"); assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400"); matrix.push(("match reward (WIN)", "PARITY")); // ── OP 11: market list (POST /auctionhouse) ──────────────────────────── // Oracle lists an OWNED CLUB item: the moved card (OP 9) is now in the club, // so it is a valid, tradePile-visible listing. Rust lists a valid wire id // against its MarketStore. let (o_ls, o_list) = oracle.req( "POST", "/ut/game/fifa17/auctionhouse", Some(json!({"itemData":{"id":o_wire[1]},"buyNowPrice":1000,"startingBid":500})), None, ); let o_trade_id = o_list["id"].as_i64().expect("oracle trade id"); // Same body shape as the oracle: the wire id ALONE (the server resolves the // owned card's card_id + resourceId from inventory). `r_wire[1]` is the card // moved to the trade pile in OP 9 — the Rust parallel of the oracle's o_wire[1]. let (r_ls, r_list) = rust( &server, "POST", "/ut/game/fifa17/auctionhouse", format!( r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#, r_wire[1] ) .as_bytes(), None, ); let r_trade_id = r_list["id"].as_i64().expect("rust trade id"); assert_eq!(o_ls, 200); assert_eq!(r_ls, 200, "market list status parity"); assert!( o_trade_id > 0 && r_trade_id > 0, "both return a positive listing id" ); matrix.push(("market list POST /ah", "PARITY")); // ── OP 12: market query (GET /tradePile) ─────────────────────────────── let o_tp = oracle.req("GET", "/ut/game/fifa17/tradePile", None, None).1; let r_tp = rust(&server, "GET", "/ut/game/fifa17/tradePile", b"", None).1; assert_eq!( o_tp["auctionInfo"].as_array().unwrap().len(), 1, "oracle tradePile shows the listing" ); assert_eq!( r_tp["auctionInfo"].as_array().unwrap().len(), 1, "rust tradePile shows the listing" ); assert_eq!( o_tp["auctionInfo"][0]["tradeState"], "active", "oracle listing active" ); assert_eq!( r_tp["auctionInfo"][0]["tradeState"], "active", "rust listing active" ); // Compare the record FIELD-FOR-FIELD, not merely its length and trade state. // The client reads the seller identity to decide whether a transfer-pile row is // the player's OWN — and therefore whether Remove / Re-list exist at all — and // a len+tradeState check is blind to that. A real client silently offered NO // action on the player's own listing (pressing it opened no dialog) because we // stamped EA's house name as the seller while the oracle stamps the persona. let o_rec = &o_tp["auctionInfo"][0]; let r_rec = &r_tp["auctionInfo"][0]; let keys = |v: &Value| { let mut k: Vec = v .as_object() .expect("auction record is an object") .keys() .cloned() .collect(); k.sort(); k }; // Both sides emit exactly FIFA 17's twelve auctionInfo atoms, so this is a // strict key-set equality. NOTE the limit of that: parity here proves we match // the oracle, NOT that either side is complete -- a field absent from BOTH is // invisible to this check. That is exactly how the Transfer List Actions-panel // bug hid, and the client binary's atom table is the authority that settled it // (see docs/FIFA17_TRANSFER_MARKET_WIRE.md). assert_eq!( keys(o_rec), keys(r_rec), "tradePile auction-record key set parity" ); for f in [ "sellerName", "bidState", "currentBid", "sellerEstablished", "watched", "coinsProcessed", ] { assert_eq!(o_rec[f], r_rec[f], "tradePile record field `{f}` parity"); } assert_eq!( r_rec["sellerName"], PERSONA_DISPLAY_NAME, "the player's own listing is sold BY the player, never by EA" ); // `expires` is seconds remaining on a live clock, so it need not equal the // oracle's constant; it must be a positive 64-bit count for an active auction. assert!( r_rec["expires"].as_i64().is_some_and(|e| e > 0), "an active auction has positive seconds remaining" ); // itemData must be the full shaped card on both sides; a stub cannot render. // // DELIBERATE DIVERGENCE — the one field on this route where the oracle is // WRONG. It stamps `listFS`, which is not a FIFA 17 token at all: zero // occurrences in `CardsDLL_Win64_retail.dll` (md5 // 4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory, // and it decodes to -1 through the itemState table walk, so the client is // handed an unrecognised `CARD_OFFERSTATE`. FIFA 17's value for an item // offered for sale is `forSale` (5), from the 12-row table at 0x180229cc0. // // This assertion used to demand parity, and passed while BOTH sides were // wrong — the reason the defect survived every differential run. Oracle parity // is necessary but not sufficient; where the binary contradicts the oracle, // the binary wins. assert_eq!( o_rec["itemData"]["itemState"], "listFS", "pins what the oracle actually emits, so this divergence stays visible" ); assert_eq!( r_rec["itemData"]["itemState"], "forSale", "Rust emits FIFA 17's own token, not the oracle's non-existent one" ); assert!( r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(), "rust tradePile itemData is the full card, not a stub" ); matrix.push(("market query tradePile", "DIFFERENT-BY-DESIGN")); // ── OP 13: market buy (POST /trade/) — DIFFERENT-BY-DESIGN ───────── // Shared invariant: the first buy debits exactly buyNowPrice and closes the // auction on BOTH. Rust buys its own (unified) listing; the oracle's buyable // market is the STATELESS sample pool, so we buy a sample auction there. let o_sample = oracle .req("GET", "/ut/game/fifa17/auctionhouse", None, None) .1; let o_sample_tid = o_sample["auctionInfo"][0]["tradeId"] .as_i64() .expect("sample tradeId"); let o_sample_bn = o_sample["auctionInfo"][0]["buyNowPrice"] .as_i64() .expect("sample buyNow"); let o_bbal = oracle.coins(); let (o_bys, o_buy1) = oracle.req( "POST", &format!("/ut/game/fifa17/trade/{o_sample_tid}"), Some(json!({})), None, ); assert_eq!(o_bys, 200); assert_eq!( o_buy1["auctionInfo"][0]["tradeState"], "closed", "oracle buy closes the auction" ); assert_eq!( oracle.coins() - o_bbal, -o_sample_bn, "oracle first buy debits exactly buyNowPrice" ); let r_bbal = client.balance().unwrap(); let (r_bys, r_buy1) = rust( &server, "POST", &format!("/ut/game/fifa17/trade/{r_trade_id}"), b"{}", None, ); assert_eq!(r_bys, 200); assert_eq!( r_buy1["auctionInfo"][0]["tradeState"], "closed", "rust buy closes the auction" ); assert_eq!( client.balance().unwrap() - r_bbal, -1000, "rust first buy debits exactly buyNowPrice" ); // The divergence, PINNED. Rust: a second buy of a sold listing is a no-op // (0 delta, empty auctionInfo). Oracle: a repeat sample buy RE-DEBITS. let r_before2 = client.balance().unwrap(); let (_, r_buy2) = rust( &server, "POST", &format!("/ut/game/fifa17/trade/{r_trade_id}"), b"{}", None, ); assert_eq!( client.balance().unwrap(), r_before2, "RUST single-debit: second buy of a sold listing does NOT re-debit" ); assert_eq!( r_buy2["auctionInfo"].as_array().unwrap().len(), 0, "rust second buy sees a closed/absent auction" ); // rust tradePile no longer active after sale. let r_tp2 = rust(&server, "GET", "/ut/game/fifa17/tradePile", b"", None).1; assert_eq!( r_tp2["auctionInfo"].as_array().unwrap().len(), 0, "rust listing sold: no longer active" ); let o_before2 = oracle.coins(); let (_, o_buy2) = oracle.req( "POST", &format!("/ut/game/fifa17/trade/{o_sample_tid}"), Some(json!({})), None, ); assert_eq!( o_buy2["auctionInfo"][0]["tradeState"], "closed", "oracle repeat buy re-closes a fresh reconstruction" ); assert_eq!( oracle.coins() - o_before2, -o_sample_bn, "ORACLE stateless: sample-market buy RE-DEBITS on repeat (by design)" ); matrix.push(("market buy POST /trade", "DIFFERENT-BY-DESIGN")); // ── OP 14: market cancel (DELETE /ut/delete/.../trade/) ──────────── // Oracle: cancel the OWN listing (OP 11) -> it drops out of tradePile. let (o_cs, _) = oracle.req( "DELETE", &format!("/ut/delete/game/fifa17/trade/{o_trade_id}"), None, None, ); assert_eq!(o_cs, 200, "oracle cancel 200"); let o_tp_after = oracle.req("GET", "/ut/game/fifa17/tradePile", None, None).1; assert_eq!( o_tp_after["auctionInfo"].as_array().unwrap().len(), 0, "oracle cancelled listing gone from tradePile" ); // Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction. // A still-owned card (r_wire[0]/[3] were quick-sold, [1] is listed above). let clist = rust( &server, "POST", "/ut/game/fifa17/auctionhouse", format!( r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#, r_wire[2] ) .as_bytes(), None, ) .1; let r_cancel_id = clist["id"].as_i64().unwrap(); let (r_cs, _) = rust( &server, "DELETE", &format!("/ut/delete/game/fifa17/trade/{r_cancel_id}"), b"", None, ); assert_eq!(r_cs, 200, "rust cancel 200"); let r_bc = client.balance().unwrap(); let cbuy = rust( &server, "POST", &format!("/ut/game/fifa17/trade/{r_cancel_id}"), b"{}", None, ) .1; assert_eq!( cbuy["auctionInfo"].as_array().unwrap().len(), 0, "rust cancelled listing not buyable" ); assert_eq!( client.balance().unwrap(), r_bc, "rust buying a cancelled listing does not debit" ); matrix.push(("market cancel DELETE", "PARITY")); // ── OP 3b: purchasegroup — sentinel (empty My Packs, unverified session) ─ // Both sides now own zero packs (pack 70 was consumed in OP 5). Oracle: clear // the opened-pack grace via /hub, then an unverified session gets the sentinel. oracle.req("GET", "/ut/game/fifa17/hub", None, None); let o_pg_s = oracle .req("GET", "/ut/game/fifa17/store/purchasegroup", None, None) .1; let r_pg_s = rust( &server, "GET", "/ut/game/fifa17/store/purchasegroup", b"", None, ) .1; assert_eq!( pack_ids(&o_pg_s), vec![1, 5, 6, 7, SENTINEL_PACK_ID], "oracle empty -> sentinel" ); assert_eq!( pack_ids(&r_pg_s), vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID], "rust empty (unknown SID) -> real catalogue + sentinel" ); for b in [&o_pg_s, &r_pg_s] { let s = b["purchase"] .as_array() .unwrap() .iter() .find(|p| p["id"].as_u64() == Some(SENTINEL_PACK_ID)) .unwrap(); assert_eq!(s["state"], "active", "sentinel state active"); } matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN")); // ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─ // Oracle: register the launcher capability, open a session (auth), present the // SID -> clean topology (no sentinel). oracle.req( "POST", "/openfut/fifa17/capability", Some(json!({"capability":"empty_mypacks_resolver","version":1,"personaId":PERSONA_ID})), None, ); let auth = oracle .req( "POST", "/ut/auth", Some(json!({"nucleusPersonaId":PERSONA_ID,"nucleusPersonaDisplayName":"CAGE"})), None, ) .1; let sid = auth["sid"].as_str().expect("oracle sid").to_string(); let o_pg_c = oracle .req( "GET", "/ut/game/fifa17/store/purchasegroup", None, Some(&sid), ) .1; assert_eq!( pack_ids(&o_pg_c), vec![1, 5, 6, 7], "oracle clean-v1 strips the sentinel" ); // Rust: drive the REAL SessionStore state machine identically (capability // before login -> pending, open the session, freeze the mode), then the real // Core-backed builder. Core owns zero entitlements now, so this is the empty // clean case. let mut sessions = SessionStore::new(); let now = 10.0; sessions.register_capability(Some("127.0.0.1".into()), PERSONA_ID, 1, now); let rsid = "OPENFUT-SID-DIFFERENTIAL01"; sessions.open_session(rsid, Some("127.0.0.1".into()), PERSONA_ID, now); let mode = sessions.empty_mypacks_mode(rsid, Some("127.0.0.1"), now); assert_eq!( mode, StoreMode::CleanV1, "verified capability -> clean-v1 mode" ); let r_pg_c_resp = handle_purchasegroup(&client, mode); let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap(); assert_eq!( pack_ids(&r_pg_c), vec![1, 2, 3, 4, 5, 6], "rust clean-v1 real catalogue, sentinel stripped" ); assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID)); matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN")); // ── Emit the matrix for the run log. ──────────────────────────────────── eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix ====="); for (op, class) in &matrix { eprintln!(" {op:<26} {class}"); } let dbd = matrix .iter() .filter(|(_, c)| *c == "DIFFERENT-BY-DESIGN") .count(); eprintln!( " ({} ops compared: {} PARITY, {dbd} DIFFERENT-BY-DESIGN)\n", matrix.len(), matrix.len() - dbd ); assert_eq!(matrix.len(), 16, "all economy ops classified"); } /// Compare the complete reversed `/sbs/*` response family against the Python oracle. /// Listing labels and counters deliberately come from Core, so parity is defined as the /// exact key/container/scalar-kind graph. Submission is the one semantic deviation: /// Python acknowledges any body without consuming cards; Rust rejects an empty squad. fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { wait_ready(core_base); let (server, client, resolver, _sample_resource) = build_econ_server(core_base, dir); let cases = [ ("GET", "/ut/game/fifa17/sbs/sets", None), ("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None), ("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None), ("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None), ("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))), ("PUT", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))), ("POST", "/ut/game/fifa17/sbs/challenge/101", None), ("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None), ( "PUT", "/ut/game/fifa17/sbs/challenge/101/squad", Some(json!({ "squad": [] })), ), ]; for (method, path, body) in cases { let oracle_result = oracle.req(method, path, body.clone(), None); let bytes = body .as_ref() .map(|value| serde_json::to_vec(value).unwrap()) .unwrap_or_default(); let rust_result = rust(&server, method, path, &bytes, None); assert_eq!( rust_result.0, oracle_result.0, "{method} {path} status parity" ); assert_eq!( json_shape(&rust_result.1), json_shape(&oracle_result.1), "{method} {path} wire shape parity\nRust: {}\nOracle: {}", rust_result.1, oracle_result.1 ); match (method, path) { ("GET", "/ut/game/fifa17/sbs/sets") => { assert_eq!(rust_result.1["categories"][0]["categoryId"], 1); assert_eq!(oracle_result.1["categories"][0]["categoryId"], 1); let rust_set_ids: Vec = rust_result.1["categories"][0]["sets"] .as_array() .unwrap() .iter() .map(|set| set["setId"].as_i64().unwrap()) .collect(); let oracle_set_ids: Vec = oracle_result.1["categories"][0]["sets"] .as_array() .unwrap() .iter() .map(|set| set["setId"].as_i64().unwrap()) .collect(); assert_eq!(rust_set_ids, [1, 2]); assert_eq!(oracle_set_ids, [1, 2]); } ("GET", "/ut/game/fifa17/sbs/setId/1/challenges") => { for body in [&rust_result.1, &oracle_result.1] { assert_eq!(body["challenges"][0]["challengeId"], 101); assert_eq!(body["challenges"][0]["setId"], 1); assert_eq!(body["challenges"][0]["categoryId"], 1); } } ("GET", "/ut/game/fifa17/sbs/setId/2/challenges") => { for body in [&rust_result.1, &oracle_result.1] { assert_eq!(body["challenges"][0]["challengeId"], 201); assert_eq!(body["challenges"][0]["setId"], 2); assert_eq!(body["challenges"][0]["categoryId"], 1); } } ("GET", "/ut/game/fifa17/sbs/setId/999/challenges") => { assert_eq!(rust_result.1["challenges"], json!([])); assert_eq!(oracle_result.1["challenges"], json!([])); } ("POST", "/ut/game/fifa17/sbs/challenge/101") => { assert_eq!(rust_result.1["challengeId"], 101); assert_eq!(oracle_result.1["challengeId"], 101); } (method, "/ut/game/fifa17/sbs/challenge/101/squad") => { assert!(method == "GET" || method == "PUT"); assert_eq!(rust_result.1["id"], 101); assert_eq!(oracle_result.1["id"], 101); } _ => {} } } let submit = json!({ "squad": [] }); let oracle_submit = oracle.req( "PUT", "/ut/game/fifa17/sbs/challenge/101", Some(submit.clone()), None, ); let rust_submit = rust( &server, "PUT", "/ut/game/fifa17/sbs/challenge/101", &serde_json::to_vec(&submit).unwrap(), None, ); assert_eq!(oracle_submit.0, 200, "oracle preserves its no-op submit"); assert_eq!( json_shape(&oracle_submit.1), json_shape(&json!({ "challengeId": 0, "setId": 0, "credits": 0, "preOrderPacks": 0, "recoveredPacks": 0, "grantedChallengeAwards": [], "grantedSetAwards": [] })), "oracle submit response remains freeze-safe" ); assert_eq!( rust_submit.0, 400, "Rust must validate instead of copying the oracle's no-op acceptance" ); let owned = client.all_owned().expect("SBC differential inventory"); let mut selected = Vec::new(); for nation in ["Argentina", "Brazil"] { selected.push( owned .iter() .find(|item| item.rating >= 70 && item.nation == nation) .unwrap_or_else(|| panic!("missing {nation} SBC fixture")), ); } for item in &owned { if selected.len() == 11 { break; } if item.rating >= 70 && !selected .iter() .any(|existing| existing.owned_card_id == item.owned_card_id) { selected.push(item); } } assert_eq!(selected.len(), 11); let successful = json!({ "squad": selected .iter() .enumerate() .map(|(index, item)| json!({ "index": index, "itemData": { "id": i64::from( resolver.resolve(item).expect("SBC wire identity").item_id ) } })) .collect::>() }); let before_balance = client.balance().unwrap(); let before_packs = client.entitlements().unwrap().len(); let oracle_success = oracle.req( "POST", "/ut/game/fifa17/sbs/challenge/201", Some(successful.clone()), None, ); let rust_success = rust( &server, "POST", "/ut/game/fifa17/sbs/challenge/201", &serde_json::to_vec(&successful).unwrap(), None, ); assert_eq!(oracle_success.0, 200); assert_eq!(rust_success.0, 200); assert_eq!( json_shape(&rust_success.1), json_shape(&oracle_success.1), "successful Rust submit preserves the oracle response class" ); assert_eq!(rust_success.1["challengeId"], 201); assert_eq!(rust_success.1["setId"], 2); assert!( client.balance().unwrap() > before_balance, "Core applies challenge and achievement coin rewards" ); assert_eq!( client.entitlements().unwrap().len(), before_packs + 1, "Core grants one Hybrid Nations pack" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn economy_differential_python_oracle() { let dir = std::env::temp_dir().join(format!( "openfut-econ-diff-{}-{}", 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()); // Boot the seeded in-process Core. let (core_handle, core_base) = start_core_seeded(&db_url, true).await; // Everything below runs off-runtime on a plain OS thread. This is mandatory: // the blocking Core client, `reqwest::blocking` (including the oracle client, // whose construction spins up + tears down a temporary runtime), and the // AsyncBridge's direct `block_on` all panic if a Tokio runtime is entered on // the thread. spawn_blocking joins a std::thread — a truly runtime-free // context, exactly like the thread-per-connection server. The oracle guard is // created AND owned inside that thread, so the subprocess is killed when the // thread finishes or unwinds on panic. let core_base_run = core_base.clone(); let dir_run = dir.clone(); let outcome = tokio::task::spawn_blocking(move || { std::thread::spawn(move || { let mut oracle = Oracle::spawn(&dir_run); oracle.wait_ready(); run_differential(&core_base_run, &oracle, &dir_run); }) .join() }) .await .expect("join spawn_blocking"); core_handle.abort(); std::fs::remove_dir_all(&dir).ok(); outcome.expect("differential thread panicked"); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn sbc_differential_python_oracle() { let dir = std::env::temp_dir().join(format!( "openfut-sbc-diff-{}-{}", 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://{}/sbc.db", dir.display()); let (core_handle, core_base) = start_core_seeded(&db_url, true).await; let core_base_run = core_base.clone(); let dir_run = dir.clone(); let outcome = tokio::task::spawn_blocking(move || { std::thread::spawn(move || { let mut oracle = Oracle::spawn(&dir_run); oracle.wait_ready(); run_sbc_differential(&core_base_run, &oracle, &dir_run); }) .join() }) .await .expect("join spawn_blocking"); core_handle.abort(); std::fs::remove_dir_all(&dir).ok(); outcome.expect("SBC differential thread panicked"); }