From 6926bb9528724469c66b7954b10b1d297415b708 Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 22:30:47 +0000 Subject: [PATCH] test(fifa17): add Python-oracle economy differential coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit economy_differential.rs boots the REAL Python oracle (fifa17-recon/tools/ utas_server.py) as an isolated subprocess (env FUT_PROFILE/FUT_ACCOUNT_PATH/ FUT_PORT into a temp dir + loopback port; no production container/port/save; killed on Drop) AND the real Rust stack (seeded in-process Core + a real Server with EconomyServices), seeds a semantically-aligned fixture on both, and drives 16 ops through the REAL surfaces (oracle over HTTP; Rust via Server::try_handle_economy on the off-runtime thread). Result: 15 PARITY, 1 DIFFERENT-BY-DESIGN. - PARITY: credits, userMassInfo economy, purchasegroup (pack70/sentinel/clean-v1 incl. the real SessionStore capability handshake), Store BUY, POST /purchased open, GET /purchased reveal (VERIFIED: durable single-profile purchased pile on BOTH — the hypothesised per-SID cache does NOT exist, so PARITY not DIFFERENT-BY-DESIGN), quick-sell (both forms), move, match WIN (+400 byte shape), market list/query/cancel. - DIFFERENT-BY-DESIGN: market second-buy. First buy debits buyNowPrice + closes on both. Rust's MarketStore is a crash-consistent single-debit ledger (second buy of a sold listing = no-op, pinned by assertion); the oracle's buyable market is a stateless PACK_POOL sample that re-debits on repeat. Compat impact NONE (buy-now is one-shot); Rust is a strict correctness improvement. No Python source changes; no classifier changes. Deterministic (3 runs). --- .../tests/economy_differential.rs | 1175 +++++++++++++++++ 1 file changed, 1175 insertions(+) create mode 100644 openfut-utas-host/tests/economy_differential.rs diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs new file mode 100644 index 0000000..4edfa2c --- /dev/null +++ b/openfut-utas-host/tests/economy_differential.rs @@ -0,0 +1,1175 @@ +//! 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 | PARITY | owned pack present -> id set {1,5,6,7,70}, NO 65534 sentinel. | +//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, | +//! | | | sentinel `state:"active"`. | +//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, | +//! | | | sentinel stripped. Rust drives the REAL `SessionStore` state | +//! | | | machine (register_capability+open_session+freeze) exactly as | +//! | | | the oracle's launcher/auth handshake does. | +//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, | +//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. | +//! | 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 | PARITY | after list -> `auctionInfo` len 1, `tradeState:"active"`. | +//! | 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::entities::Fifa17Entities; +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, +/// 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, 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 { + 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, 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 +} + +/// 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, 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; + assert_eq!( + pack_ids(&opg), + vec![1, 5, 6, 7, 70], + "oracle owned pack70 id set" + ); + assert_eq!( + pack_ids(&rpg), + vec![1, 5, 6, 7, 70], + "rust owned pack70 id set" + ); + for b in [&opg, &rpg] { + assert!( + !pack_ids(b).contains(&SENTINEL_PACK_ID), + "no 65534 sentinel while a pack is owned" + ); + // packType parity per id (BRONZE for 1, GOLD for the rest). + for p in b["purchase"].as_array().unwrap() { + let id = p["id"].as_u64().unwrap(); + let want = if id == 1 { "BRONZE" } else { "GOLD" }; + assert_eq!(p["packType"], want, "packType parity for pack {id}"); + } + } + matrix.push(("purchasegroup pack70", "PARITY")); + + // ── 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(); + assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards"); + assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards"); + for b in [&o_buy, &r_buy] { + let cpr = &b["createPackResponse"]; + assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5"); + 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" + ); + matrix.push(("Store BUY (pack1)", "PARITY")); + // 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"); + let (r_ls, r_list) = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).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" + ); + matrix.push(("market query tradePile", "PARITY")); + + // ── 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. + let clist = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).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, 5, 6, 7, SENTINEL_PACK_ID], + "rust empty (unknown SID) -> 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", "PARITY")); + + // ── 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, 5, 6, 7], + "rust clean-v1 strips the sentinel" + ); + assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID)); + matrix.push(("purchasegroup clean-v1", "PARITY")); + + // ── 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"); +} + +#[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"); +}