Files
OpenFUT/openfut-utas-host/tests/economy_differential.rs
T
2026-08-18 18:26:35 +00:00

1458 lines
58 KiB
Rust

//! 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":<tradeId>}` (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::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<Value>,
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<dyn CoreAccess> = 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<dyn CoreEconomy> = 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, 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<u64> {
let mut v: Vec<u64> = 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, _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<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
let r_wire: Vec<i64> = 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/<wireId>) ───────────────────
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<String> = 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/<id>) — 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/<id>) ────────────
// 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, 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");
}
/// 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, _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!({}))),
("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<i64> = rust_result.1["categories"][0]["sets"]
.as_array()
.unwrap()
.iter()
.map(|set| set["setId"].as_i64().unwrap())
.collect();
let oracle_set_ids: Vec<i64> = 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"
);
}
#[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");
}