Files
OpenFUT/openfut-utas-host/tests/economy_differential.rs
T
funman300 3442eac6f0 fix(fifa17): complete kit stats, restore red squad tests, unrot prod gate
Four defects found by running the suites and the staging lifecycle end to end
after the kit milestone.

1. club-stats kits were half-implemented. The global `kits` counter was real
   but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded
   0, so the same screen reported two owned kits and zero home/away kits.
   `kits` is a total with a family split, exactly like players/playersGold and
   staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home
   family and 15 the away family, verified across all 1482 rows of the kit
   table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15
   exactly the 64xxxxx ones, 654 rows; no exceptions either way).
   ClubStatInput now carries `asset_id`, and a kit buckets onto the team that
   wears it -- including a team the club owns no player from, the normal case
   for a kit won from a pack. The host reads both from the catalog through new
   NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a
   read-only stats query must never do as a side effect.

2. host_test.rs had 10 tests red since the squad-manager work (25f4ad1 /
   d37a9d5); 56bd9dd updated the squad_projection integration test and stopped
   there. `put_body` hardcoded the captured manager ref 100000427 into EVERY
   save, including tests with no manager fixture, so each one was refused with
   `unresolved_wire_ids` -- the tests were reporting a real invariant against a
   fixture that could not satisfy it. The manager is now an explicit
   `Option<i64>` per test, and FakeCore models Core's manager persistence
   instead of inheriting the "not implemented" default that 502'd every save.
   Added the coverage whose absence let this rot: a manager assignment
   round-trips as a Core owned id, a later save without one CLEARS it, and an
   unowned manager ref refuses the whole save with nothing committed.

3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit`
   forwarding to Core, which the kit commit deliberately replaced with
   host-side pagination. It only ever passed because FakeCore ignored the
   window -- against a real Core, `start=10` over a one-item club was always an
   empty page. Retargeted to the real contract (Core gets semantic filters and
   NO window) plus a new test that the window is applied locally after
   filtering, which the old fake made vacuous.

4. The staging lifecycle scripts identified production by hardcoded pids, so a
   correct teardown FATAL'd: production moved into containers and pids
   3631953/3374264 died with a container restart days ago. A pinned pid rots
   into the worst of both worlds -- a kill-refusal gate that no longer names
   any real production process, and a liveness gate that fails a healthy
   teardown. New shared `scripts/openfut_production.py` resolves production
   pids AND published ports from the container runtime at the moment they are
   needed, refuses to signal anything it cannot see, and proves production is
   the same processes serving the same ports before and after. Both lifecycle
   scripts use it, which also closed a real gap: port 8085 is published by
   openfut-fut-backend but was missing from the up script's forbidden list, so
   staging could have bound a production port.

Also fixes the economy differential, red because `complete_match` unlocks
achievements in the same transaction that pays the match reward -- a deliberate
Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted
that progression did not exist; it now asserts the delta is the 400 match reward
plus exactly the achievements the match unlocked, read from Core's own report.
2026-08-21 04:10:34 +00:00

1603 lines
63 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(),
),
}
}
/// Coins Core has granted through ACHIEVEMENT unlocks so far, summed from Core's
/// own report.
///
/// Core's progression system (objectives + achievements) has no counterpart in
/// the Python oracle, and `complete_match` unlocks achievements in the SAME
/// transaction that pays the match reward. So a raw balance delta around a match
/// is `match reward + newly unlocked achievements`, and the differential has to
/// account for the second term instead of pretending it does not exist.
///
/// NOTE: `GET /achievements` is unlock-on-read (it calls `check_and_unlock`), so
/// this snapshot must be taken BEFORE the pre-op balance is captured; that flushes
/// any already-satisfied unlock and leaves the measured window attributable to the
/// op under test.
fn achievement_coins_granted(http: &reqwest::blocking::Client, core_base: &str) -> i64 {
let body: Value = http
.get(format!("{core_base}/achievements"))
.header("X-OpenFUT-Game", "fifa17")
.send()
.expect("GET /achievements")
.json()
.expect("achievements json");
body["achievements"]
.as_array()
.expect("achievements array")
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.map(|a| a["reward_coins"].as_i64().unwrap_or(0))
.sum()
}
/// 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) ───────────────────────────────────
// The oracle pays the match reward and nothing else. Core pays the same match
// reward and, in the same transaction, any achievement the match unlocks — a
// deliberate Rust-authority feature the oracle never had. Flush pending
// unlocks first so the measured window belongs to this match.
let r_ach_before = achievement_coins_granted(&http, core_base);
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");
// Same match reward on both sides; Core additionally credits exactly the
// achievements this match unlocked, and nothing unexplained.
let r_ach_awarded = achievement_coins_granted(&http, core_base) - r_ach_before;
assert_eq!(
client.balance().unwrap() - r_mbal,
400 + r_ach_awarded,
"rust WIN credits +400 plus exactly the achievements it unlocked \
(achievement coins = {r_ach_awarded})"
);
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");
}