c68c10cf04
- store_catalog: replace the invented catalogue with the real always-available FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at real prices + tier composition; PackDef now carries per-tier quantities. - pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s" FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4). - pack_content: tier-aware generator draws each pack bronze/silver/gold composition with special_chance bias + empty-tier fallback. - host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the FULL card universe via non-minting catalog lookup, owned-inventory fallback. - economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the authoritative store; Python oracle stays the untouched rollback baseline). - fixtures/tests updated to the real catalogue. Odds are DESIGNED placeholders (FUT17 pack probabilities were never published); club items remain excluded (cardtype-9 mapping unknown). Full regression green; real prices + tier-correct draws verified server-side on staging.
1560 lines
61 KiB
Rust
1560 lines
61 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 | 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":<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::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<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,
|
|
/// 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<Fifa17IdentityResolver>, 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, 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<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, _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<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, 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<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"
|
|
);
|
|
|
|
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::<Vec<_>>()
|
|
});
|
|
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");
|
|
}
|