Files
OpenFUT/openfut-utas-host/tests/economy_integration.rs
T
funman300 33300f2ad1 fix(fifa17): serve the match lifecycle instead of proxying it to a dead upstream
"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:

  utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
  utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502

Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.

The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.

`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.

MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.

ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.

THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.

One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.

Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.

Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
2026-08-21 18:00:28 +00:00

1804 lines
68 KiB
Rust

//! Real host↔Core economy integration harness.
//!
//! Spawns OpenFUT Core (axum) on an ephemeral loopback port backed by a
//! disposable temp-file SQLite, seeds a `fifa17`-scoped profile via the real
//! Core HTTP API, then drives the HOST's REAL economy transport
//! (`HttpCoreClient` implementing `CoreEconomy`) + handlers against it — no
//! fakes, no in-memory doubles. It proves the credits / purchasegroup /
//! userMassInfo / match-reward cluster end-to-end and that state survives a
//! Core restart from the same on-disk database.
//!
//! Safety: uses only a temp directory + `127.0.0.1:0` ephemeral ports. Never
//! touches the production Core DB, production ports/containers, or `.105`.
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::store_session::StoreMode;
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_credits, handle_match_end, handle_purchasegroup,
overlay_massinfo_economy, CoreAccess, CoreEconomy, EconomyServices, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Server,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
/// Boot a Core instance against `db_url`, serving on an ephemeral port. Returns
/// the serve task handle and its base URL.
async fn start_core(db_url: &str) -> (tokio::task::JoinHandle<()>, String) {
// Multi-connection pool: the fresh-DB write-lock race is fixed in Core
// (WAL-establish-once + BEGIN IMMEDIATE write transactions + busy_timeout,
// core fbb54ea), proven by the concurrency reproduction (800/800 concurrent
// writes), so a real multi-connection pool is stable here.
let pool = openfut_core::db::init_pool(db_url, 5)
.await
.expect("core pool");
openfut_core::db::run_migrations(&pool)
.await
.expect("core migrations");
// `data` lives in the sibling core crate; tests run with the host crate as cwd.
let app = openfut_core::build_app(pool, "../openfut-core/data")
.await
.expect("core build_app");
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}"))
}
/// Boot a Core with the FIFA17 dev CONTENT loaded (so `/collection` renders real
/// card definitions) and, on first boot, the dev inventory SEEDED (a fifa17
/// profile + club with 100k coins + one owned instance per definition). On
/// restart pass `seed=false`: content is reloaded but the durable DB is left as
/// is, proving persistence rather than re-seeding.
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();
// Generous ceiling (~30s): returns on first success, so it only ever waits
// this long if Core genuinely never comes up. Under heavy parallel test-binary
// load Core's content load (CardDb::load) can take several seconds to be ready.
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 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
}
fn pack_ids(pg: &Value) -> Vec<u64> {
pg["purchase"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_u64().unwrap())
.collect()
}
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
/// Returns the final Core balance so the restart phase can assert persistence.
fn seed_and_exercise(base: &str) -> i64 {
wait_ready(base);
let http = reqwest::blocking::Client::new();
// Seed a fifa17 profile + club (auth grants 5000 coins + a starter pack).
post(&http, base, "/auth/local", json!({ "username": "CAGE" }));
// The host's REAL transport to Core (no fake).
let client = HttpCoreClient::new(base, "fifa17");
// credits reads the authoritative Core balance.
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
// exactly-once complete_match transaction, end to end. The reward is at least
// the match coins; Core may also grant XP-driven level-up and first-win
// achievement coins, so assert the flat match coins + a relative delta.
let before_match = client.balance().unwrap();
let m = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(m.status, 200);
let mb: Value = serde_json::from_slice(&m.body).unwrap();
assert_eq!(mb["matchCoins"], 400, "flat match coins");
let after_match = client.balance().unwrap();
assert!(
after_match >= before_match + 400,
"match credited at least +400"
);
assert_eq!(
mb["allCoins"].as_i64().unwrap(),
after_match,
"response echoes the authoritative Core balance"
);
// Idempotent replay: the SAME match session does NOT double-credit.
let replay = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(replay.status, 200);
assert_eq!(
client.balance().unwrap(),
after_match,
"replay must not re-credit"
);
// A DIFFERENT session with a BYTE-IDENTICAL body is a different match and
// must credit again. Keyed on the body alone it would not, which is the
// silent under-credit every abandoned match would have hit.
let second = handle_match_end(&client, 1, Some(200_000_002), br#"{"endReason":"WIN"}"#);
assert_eq!(second.status, 200);
let after_second = client.balance().unwrap();
assert!(
after_second >= after_match + 400,
"a second, distinct match must credit: {after_second} vs {after_match}"
);
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
post(
&http,
base,
"/economy/purchase-entitlement",
json!({ "cost": 600, "definition_id": "70" }),
);
let after_buy = after_second - 600;
assert_eq!(
client.balance().unwrap(),
after_buy,
"debit applied atomically"
);
// credits reflects the debit through the same Core state.
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits2["currencies"][0]["funds"], after_buy);
// purchasegroup full-gen shows the owned pack 70 and NO sentinel.
let pg: Value =
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
let ids = pack_ids(&pg);
assert!(
ids.contains(&70),
"owned pack 70 rendered from Core entitlement"
);
assert!(!ids.contains(&65534), "no sentinel while a pack is owned");
// userMassInfo overlay derives coins from the SAME Core state as credits.
let mut mass = json!({
"userInfo": { "currencies": [ {"name":"coins","funds":0,"finalFunds":0} ] }
});
overlay_massinfo_economy(
&mut mass,
client.balance().unwrap(),
client.entitlements().unwrap().len(),
);
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy);
// Invariant: credits coins == userMassInfo coins == Core balance.
assert_eq!(
credits2["currencies"][0]["funds"],
mass["userInfo"]["currencies"][0]["funds"]
);
after_buy
}
/// After a Core restart from the same DB file, all economy state persists.
fn verify_after_restart(base: &str, expected_balance: i64) {
wait_ready(base);
let client = HttpCoreClient::new(base, "fifa17");
assert_eq!(
client.balance().unwrap(),
expected_balance,
"coins persisted across restart"
);
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], expected_balance);
let pg: Value =
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
assert!(
pack_ids(&pg).contains(&70),
"entitlement persisted across restart"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn economy_end_to_end_and_restart_persistence() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-it-{}-{}",
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());
// --- Core instance #1: seed + exercise the full cluster ---
let (h1, base1) = start_core(&db_url).await;
let b1 = base1.clone();
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
h1.abort();
let expected_balance = r.expect("exercise phase");
// --- Core instance #2: same on-disk DB, prove persistence ---
let (h2, base2) = start_core(&db_url).await;
let b2 = base2.clone();
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await;
h2.abort();
r2.expect("restart phase");
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Full economy sequence through the real host dispatch ────────
//
// Everything below drives the ACTUAL `Server::try_handle_economy` path (the same
// dispatch + async bridge the barrier will route production through), against a
// live in-process Core over the real blocking `HttpCoreClient`. No fakes. The
// sequence runs on a plain OS thread (no ambient Tokio runtime), exactly like the
// thread-per-connection server, so the bridge takes its DIRECT `block_on` path.
/// Facts captured from the write sequence, re-checked after a full restart.
struct SeqResult {
final_balance: i64,
sold_listing: String,
moved_core_id: String,
}
/// Build a real `Server` with economy authority wired: real Core transport, a
/// catalog derived from the seeded content (so the pack pool + shaper resolve),
/// a persistent identity store, and the two durable SQLite stores opened via the
/// bridge. Returns the server, a direct Core client for balance assertions, and
/// the identity resolver (to reverse a wire id for the restart pile check).
fn build_econ_server(
base: &str,
dir: &std::path::Path,
pass_url: &str,
) -> (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");
// A catalog mapping every seeded definition to a real-looking asset id (in
// production this is the shipped FIFA catalog; here it is derived so the E2E
// exercises real shaping without a static fixture).
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(pass_url)),
33068179,
)
.with_economy(services);
// asset 20000 is assigned to the first distinct seeded definition, so wire
// resourceId 20000 reverse-maps to a real Core card_id (a valid synthetic mint).
(server, probe, resolver, 20000)
}
/// Drive the whole writer+reader cluster through the real dispatch. Panics on any
/// mismatch. Runs on a plain OS thread (no ambient runtime).
fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
wait_ready(base);
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
// owned instance per definition. No /auth/local — the profile already exists.
let (server, client, resolver, _sample_resource) =
build_econ_server(base, dir, "http://127.0.0.1:9");
let start = client.balance().unwrap();
assert!(start >= 5000, "seeded dev balance present ({start})");
// 1) Store BUY (pack 1 = Bronze, price 400, 5 cards): debit + mint + reveal.
let buy = server
.try_handle_economy(
"PUT",
"/ut/game/fifa17/store/transaction",
&[],
br#"{"packId":1}"#,
None,
)
.expect("store BUY routed");
assert_eq!(buy.status, 200, "BUY 200");
let bv: Value = serde_json::from_slice(&buy.body).unwrap();
let items = bv["createPackResponse"]["itemList"]
.as_array()
.expect("itemList")
.clone();
assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 12);
assert!(
items[0]["id"].as_i64().unwrap() >= 100_000_000,
"minted wire id above the FIFA floor"
);
assert_eq!(
client.balance().unwrap(),
start - 400,
"BUY debited exactly 400"
);
// 2) credits reads the SAME Core authority.
let cr = server
.try_handle_economy("GET", "/ut/game/fifa17/user/credits", &[], b"", None)
.unwrap();
let crv: Value = serde_json::from_slice(&cr.body).unwrap();
assert_eq!(
crv["currencies"][0]["funds"],
start - 400,
"credits == Core balance"
);
// 3) Quick-sell one minted card: reverse-resolve wire -> Core id, credit.
let sell_wire = items[0]["id"].as_i64().unwrap();
let before = client.balance().unwrap();
let qs = server
.try_handle_economy(
"DELETE",
&format!("/ut/game/fifa17/item/{sell_wire}"),
&[],
b"",
None,
)
.expect("quick-sell routed");
assert_eq!(qs.status, 200);
let qv: Value = serde_json::from_slice(&qs.body).unwrap();
assert_eq!(qv["items"].as_array().unwrap().len(), 1, "sold exactly one");
let after_sell = client.balance().unwrap();
assert!(
after_sell > before,
"quick-sell credited ({before}->{after_sell})"
);
assert_eq!(
qv["totalCredits"].as_i64().unwrap(),
after_sell,
"totalCredits == absolute Core balance"
);
// 4) Match END reward through dispatch. The WIN credits at least the flat
// match coins (Core may also add XP level-up / first-win achievement coins).
let before_match = client.balance().unwrap();
let mm = server
.try_handle_economy(
"POST",
"/ut/delete/game/fifa17/match",
&[],
br#"{"endReason":"WIN"}"#,
None,
)
.expect("match routed");
assert_eq!(mm.status, 200);
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
assert!(
client.balance().unwrap() >= before_match + 400,
"WIN credited at least +400 via Core"
);
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
// query -> second buy fails, exactly one debit + one sale.
// List a still-owned minted card (items[0] was quick-sold, items[1] is moved
// below). The body carries the wire id ALONE — the server resolves the owned
// card's Core card_id + FIFA resourceId from inventory.
let list_wire = items[2]["id"].as_i64().unwrap();
let list = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/auctionhouse",
&[],
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
.as_bytes(),
None,
)
.expect("market list routed");
let lv: Value = serde_json::from_slice(&list.body).unwrap();
let trade_id = lv["id"].as_i64().expect("trade id");
let trade_path = format!("/ut/game/fifa17/trade/{trade_id}");
let q1 = server
.try_handle_economy("GET", "/ut/game/fifa17/tradePile", &[], b"", None)
.unwrap();
let q1v: Value = serde_json::from_slice(&q1.body).unwrap();
assert_eq!(
q1v["auctionInfo"].as_array().unwrap().len(),
1,
"listing active before buy"
);
let before_buy = client.balance().unwrap();
let buy1 = server
.try_handle_economy("POST", &trade_path, &[], b"{}", None)
.expect("market buy routed");
assert_eq!(buy1.status, 200);
assert_eq!(
client.balance().unwrap(),
before_buy - 1000,
"buy debited exactly the buy-now price"
);
let q2 = server
.try_handle_economy("GET", "/ut/game/fifa17/tradePile", &[], b"", None)
.unwrap();
let q2v: Value = serde_json::from_slice(&q2.body).unwrap();
assert_eq!(
q2v["auctionInfo"].as_array().unwrap().len(),
0,
"listing sold: no longer active"
);
let after_buy = client.balance().unwrap();
let buy2 = server
.try_handle_economy("POST", &trade_path, &[], b"{}", None)
.unwrap();
let buy2v: Value = serde_json::from_slice(&buy2.body).unwrap();
assert_eq!(
buy2v["auctionInfo"].as_array().unwrap().len(),
0,
"second buy sees a closed auction"
);
assert_eq!(
client.balance().unwrap(),
after_buy,
"second buy does NOT debit again"
);
// 6) MARKET cancel: a cancelled listing cannot be bought.
let cancel_wire = items[3]["id"].as_i64().unwrap();
let clist = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/auctionhouse",
&[],
format!(
r#"{{"itemData":{{"id":{cancel_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
)
.as_bytes(),
None,
)
.unwrap();
let cancel_id = serde_json::from_slice::<Value>(&clist.body).unwrap()["id"]
.as_i64()
.unwrap();
server
.try_handle_economy(
"DELETE",
&format!("/ut/delete/game/fifa17/trade/{cancel_id}"),
&[],
b"",
None,
)
.expect("market cancel routed");
let bal_before_cancel_buy = client.balance().unwrap();
let cancel_buy = server
.try_handle_economy(
"POST",
&format!("/ut/game/fifa17/trade/{cancel_id}"),
&[],
b"{}",
None,
)
.unwrap();
assert_eq!(
serde_json::from_slice::<Value>(&cancel_buy.body).unwrap()["auctionInfo"]
.as_array()
.unwrap()
.len(),
0,
"cancelled listing is not buyable"
);
assert_eq!(
client.balance().unwrap(),
bal_before_cancel_buy,
"buying a cancelled listing does not debit"
);
// 6b) Owned-pack (70) open + GET /purchased reveal (Part 8 + 0B). Seed an
// unopened pack-70 entitlement via the Core economy API (cost 0), open it,
// and prove the reveal screen (GET /purchased) shows the freshly opened items
// and is idempotent on repeat (presentation state, not a second grant).
let http = reqwest::blocking::Client::new();
post(
&http,
base,
"/economy/purchase-entitlement",
json!({ "cost": 0, "definition_id": "70" }),
);
let reveal_before = {
let r = server
.try_handle_economy("GET", "/ut/game/fifa17/purchased", &[], b"", None)
.expect("reveal routed");
serde_json::from_slice::<Value>(&r.body).unwrap()["itemData"]
.as_array()
.map(|a| a.len())
.unwrap_or(0)
};
let open = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/purchased",
&[],
br#"{"packId":70}"#,
None,
)
.expect("pack open routed");
assert_eq!(open.status, 200, "pack-70 open 200");
let ov: Value = serde_json::from_slice(&open.body).unwrap();
assert_eq!(ov["packId"], 70, "open echoes the pack id");
let reveal = server
.try_handle_economy("GET", "/ut/game/fifa17/purchased", &[], b"", None)
.expect("reveal routed");
let reveal_items = serde_json::from_slice::<Value>(&reveal.body).unwrap()["itemData"]
.as_array()
.expect("reveal itemData")
.len();
assert!(
reveal_items > reveal_before,
"GET /purchased reveals the opened items ({reveal_before} -> {reveal_items})"
);
// Idempotent: a repeat GET does not re-grant or clear (same reveal).
let reveal2 = server
.try_handle_economy("GET", "/ut/game/fifa17/purchased", &[], b"", None)
.unwrap();
let reveal2_items = serde_json::from_slice::<Value>(&reveal2.body).unwrap()["itemData"]
.as_array()
.unwrap()
.len();
assert_eq!(
reveal2_items, reveal_items,
"repeated GET /purchased is idempotent"
);
// 7) Move a still-owned minted card to the trade pile (durable pile metadata).
let move_wire = items[1]["id"].as_i64().unwrap();
let mv = server
.try_handle_economy(
"PUT",
"/ut/game/fifa17/item",
&[],
format!(r#"{{"itemData":[{{"id":{move_wire},"pile":"trade"}}]}}"#).as_bytes(),
None,
)
.expect("move routed");
let mvv: Value = serde_json::from_slice(&mv.body).unwrap();
assert_eq!(mvv["itemData"][0]["success"], true, "move recorded");
let moved_core_id = resolver
.owned_id_for_wire(move_wire)
.expect("moved item reverses to a Core id");
// 8) THE MATCH LIFECYCLE, through the REAL dispatch rather than the handler.
// `POST …/match` and `PUT …/match/end` were not classified at all and were
// proxied to Python, which the client reported as "There was an error
// creating your game session".
let created = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE","seasonId":1,"divisionId":10}"#,
None,
)
.expect("match create must be claimed by the economy dispatch, never proxied");
assert_eq!(created.status, 200);
let created_body: Value = serde_json::from_slice(&created.body).unwrap();
let match_id = created_body["id"].as_i64().expect("a match id is minted");
assert!(match_id > 0, "the client needs a non-zero session id");
assert_eq!(created_body["reportIdEnabled"], false);
assert!(
created_body.get("squad").is_none(),
"`squad` is nested and a documented freeze risk — it must be omitted"
);
// The match id lives in its OWN identity scope: it must not reverse-map to
// an owned card, or quick-sell and move would resolve a match as an item.
assert!(
resolver.owned_id_for_wire(match_id).is_none(),
"a match id must never appear in the owned-item reverse map"
);
// FutPlayGame reuses the create path, discriminated by an integer matchId.
// It must ack without minting a second session.
let play = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
format!(r#"{{"matchId":{match_id}}}"#).as_bytes(),
None,
)
.expect("play routed");
assert_eq!(
serde_json::from_slice::<Value>(&play.body).unwrap(),
serde_json::json!({}),
"FutPlayGame parses no fields"
);
// Every abandoned match sends a BYTE-IDENTICAL body. The first credits…
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
let before_dnf = client.balance().unwrap();
let ended = server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("match end must be claimed, never proxied");
assert_eq!(ended.status, 200);
let after_dnf = client.balance().unwrap();
assert!(after_dnf > before_dnf, "the abandoned match credited");
// …a REPLAY of that same match must not. Holding (not taking) the in-flight
// id is what makes the replay reuse one identity; taking it sent the replay
// down the body-fingerprint path, a different identity, and Core paid twice.
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("replay routed");
assert_eq!(
client.balance().unwrap(),
after_dnf,
"a replayed match end must NOT credit again"
);
// …and a NEW session with the identical body is a different match, which
// must credit. Keyed on the body alone every abandoned match after the
// first would silently pay nothing.
let created2 = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE"}"#,
None,
)
.expect("second create routed");
let second_id = serde_json::from_slice::<Value>(&created2.body).unwrap()["id"]
.as_i64()
.unwrap();
assert_ne!(second_id, match_id, "each match gets its own id");
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("second end routed");
assert!(
client.balance().unwrap() > after_dnf,
"a second, distinct abandoned match must credit"
);
SeqResult {
final_balance: client.balance().unwrap(),
sold_listing: trade_id.to_string(),
moved_core_id,
}
}
/// After a FULL restart from the SAME on-disk state — Core rebooted from its
/// SQLite file, and the durable market/pile stores reopened from their files —
/// coins, the sold listing, and the pile move all persist. The synthetic market
/// buy now mints a REAL Core `card_id` (resourceId reverse-mapped), so Core's
/// content preflight passes on reboot.
fn verify_economy_restart(base: &str, dir: &std::path::Path, seq: &SeqResult) {
wait_ready(base);
let client = HttpCoreClient::new(base, "fifa17");
assert_eq!(
client.balance().unwrap(),
seq.final_balance,
"coins persisted across Core restart"
);
let bridge = AsyncBridge::new().unwrap();
let market_path = dir.join("market.db").to_string_lossy().into_owned();
let market = bridge
.block_on(async move { MarketStore::open(&market_path).await })
.unwrap();
let sold = seq.sold_listing.clone();
let m2 = market.clone();
let listing = bridge
.block_on(async move { m2.get_listing(&sold).await })
.expect("sold listing survives reopen");
assert_eq!(listing.state, "sold", "sold stays sold after reopen");
let pile_path = dir.join("pile.db").to_string_lossy().into_owned();
let piles = bridge
.block_on(async move { PileStore::open(&pile_path).await })
.unwrap();
let moved = seq.moved_core_id.clone();
let p2 = piles.clone();
let pile = bridge
.block_on(async move { p2.get(&moved).await })
.unwrap();
assert_eq!(
pile.as_deref(),
Some("trade"),
"pile move persisted after reopen"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn economy_full_sequence_through_dispatch_and_restart() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-dispatch-{}-{}",
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());
// Core #1: run the full write sequence on a plain OS thread (direct bridge
// path). The join runs in spawn_blocking so Core's runtime keeps serving.
let (h1, base1) = start_core_seeded(&db_url, true).await;
let (b1, d1) = (base1.clone(), dir.clone());
let seq = tokio::task::spawn_blocking(move || {
let t = std::thread::spawn(move || economy_sequence(&b1, &d1));
t.join().expect("sequence thread")
})
.await
.expect("write sequence");
h1.abort();
// Core #2: same on-disk Core DB + same market/pile files — prove full restart
// persistence (coins + sold listing + pile). Core content preflight passes
// because the synthetic mint used a real reverse-mapped card_id.
let (h2, base2) = start_core_seeded(&db_url, false).await;
let (b2, d2) = (base2.clone(), dir.clone());
tokio::task::spawn_blocking(move || {
let t = std::thread::spawn(move || verify_economy_restart(&b2, &d2, &seq));
t.join().expect("restart thread")
})
.await
.expect("restart phase");
h2.abort();
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Production constructor (Server::from_config) E2E ────────────
//
// Proves the PRODUCTION wiring path, not just manual `with_economy`: build the
// Server from a real (disposable) HostConfig — which itself opens the durable
// market/pile stores + the runtime bridge + the content pool — and drive the
// economy through it, then restart from the same config/files and prove
// persistence.
/// Write a catalog FILE mapping every seeded Core definition to a deterministic
/// asset id (asset 20000 = the first, so wire resourceId 20000 reverse-maps).
fn write_catalog_file(base: &str, path: &std::path::Path) {
let owned = HttpCoreClient::new(base, "fifa17")
.all_owned()
.expect("core 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;
}
std::fs::write(
path,
format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{entries}}}}}"),
)
.unwrap();
}
fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::HostConfig {
let catalog = dir.join("catalog.json");
write_catalog_file(base, &catalog);
openfut_utas_host::config::HostConfig {
listen_addr: "127.0.0.1:0".into(),
python_upstream: "http://127.0.0.1:9".into(), // unused by economy dispatch
core_url: base.to_string(),
tables_dir: "../fifa17-recon/data/tables".into(),
catalog_path: catalog.to_string_lossy().into_owned(),
identity_store_path: dir.join("identity.json").to_string_lossy().into_owned(),
persona_id: 33_068_179,
market_db_path: dir.join("market.db").to_string_lossy().into_owned(),
pile_db_path: dir.join("pile.db").to_string_lossy().into_owned(),
clientdata_path: dir.join("clientdata.json").to_string_lossy().into_owned(),
account_path: dir
.join("active_account.json")
.to_string_lossy()
.into_owned(),
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
}
}
fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
wait_ready(base);
let cfg = from_config(base, dir);
// PRODUCTION constructor — economy services are attached by from_config, NOT
// injected by the test.
let server = Server::from_config(&cfg).expect("from_config builds economy services");
let client = HttpCoreClient::new(base, "fifa17");
let start = client.balance().unwrap();
// credits + purchasegroup readers.
let cr = server
.try_handle_economy("GET", "/ut/game/fifa17/user/credits", &[], b"", None)
.expect("credits routed");
assert_eq!(
serde_json::from_slice::<Value>(&cr.body).unwrap()["currencies"][0]["funds"],
start
);
let pg = server
.try_handle_economy("GET", "/ut/game/fifa17/store/purchasegroup", &[], b"", None)
.expect("purchasegroup routed");
assert_eq!(pg.status, 200);
// Store BUY (writer) through the production-built server.
let buy = server
.try_handle_economy(
"PUT",
"/ut/game/fifa17/store/transaction",
&[],
br#"{"packId":1}"#,
None,
)
.expect("buy routed");
assert_eq!(buy.status, 200);
// A listing must name a card the club actually owns, so take one the BUY minted.
let list_wire = serde_json::from_slice::<Value>(&buy.body).unwrap()["createPackResponse"]
["itemList"][0]["id"]
.as_i64()
.expect("minted wire id");
assert_eq!(
client.balance().unwrap(),
start - 400,
"BUY debited via from_config server"
);
// Market list -> query -> buy through the production-built server.
let list = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/auctionhouse",
&[],
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
.as_bytes(),
None,
)
.expect("list routed");
let trade_id = serde_json::from_slice::<Value>(&list.body).unwrap()["id"]
.as_i64()
.unwrap();
let q = server
.try_handle_economy("GET", "/ut/game/fifa17/tradePile", &[], b"", None)
.unwrap();
assert_eq!(
serde_json::from_slice::<Value>(&q.body).unwrap()["auctionInfo"]
.as_array()
.unwrap()
.len(),
1
);
let before_buy = client.balance().unwrap();
server
.try_handle_economy(
"POST",
&format!("/ut/game/fifa17/trade/{trade_id}"),
&[],
b"{}",
None,
)
.expect("market buy routed");
assert_eq!(
client.balance().unwrap(),
before_buy - 1000,
"market buy debited"
);
client.balance().unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn from_config_constructs_and_serves_economy() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-fromcfg-{}-{}",
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());
let (h1, base1) = start_core_seeded(&db_url, true).await;
let (b1, d1) = (base1.clone(), dir.clone());
let final_balance = tokio::task::spawn_blocking(move || {
let t = std::thread::spawn(move || exercise_from_config(&b1, &d1));
t.join().expect("from_config thread")
})
.await
.expect("from_config exercise");
h1.abort();
// Restart Core + rebuild the Server from the SAME config/files: balance
// persists, and a fresh from_config server serves it.
let (h2, base2) = start_core_seeded(&db_url, false).await;
let (b2, d2) = (base2.clone(), dir.clone());
tokio::task::spawn_blocking(move || {
let t = std::thread::spawn(move || {
wait_ready(&b2);
let cfg = from_config(&b2, &d2);
let server = Server::from_config(&cfg).expect("from_config rebuild");
let cr = server
.try_handle_economy("GET", "/ut/game/fifa17/user/credits", &[], b"", None)
.unwrap();
assert_eq!(
serde_json::from_slice::<Value>(&cr.body).unwrap()["currencies"][0]["funds"],
final_balance,
"balance persists across a from_config restart"
);
});
t.join().expect("restart thread")
})
.await
.expect("from_config restart");
h2.abort();
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sbc_survives_complete_core_and_host_restart() {
let dir = std::env::temp_dir().join(format!(
"openfut-sbc-restart-{}-{}",
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 (h1, base1) = start_core_seeded(&db_url, true).await;
let first_base = base1.clone();
let first_dir = dir.clone();
let (mut cfg, selected_core_ids, selected_wire_ids) = tokio::task::spawn_blocking(move || {
let cfg = from_config(&first_base, &first_dir);
let server = Server::from_config(&cfg).expect("first complete host");
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
assert_eq!(club.status, 200);
let club: Value = serde_json::from_slice(&club.body).unwrap();
let items = club["itemData"].as_array().expect("club itemData");
let entities =
Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)).unwrap();
let argentina = i64::from(entities.nation_id("Argentina").unwrap());
let brazil = i64::from(entities.nation_id("Brazil").unwrap());
let mut selected = Vec::new();
for nation in [argentina, brazil] {
selected.push(
items
.iter()
.find(|item| {
item["rating"].as_i64().unwrap_or_default() >= 70
&& item["nation"].as_i64() == Some(nation)
})
.expect("required hybrid nation")
.clone(),
);
}
for item in items {
if selected.len() == 11 {
break;
}
if item["rating"].as_i64().unwrap_or_default() >= 70
&& !selected.iter().any(|existing| existing["id"] == item["id"])
{
selected.push(item.clone());
}
}
assert_eq!(selected.len(), 11, "deterministic Hybrid Nations squad");
let selected_wire_ids: Vec<i64> = selected
.iter()
.map(|item| item["id"].as_i64().expect("wire id"))
.collect();
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
.expect("catalog reload");
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("identity reload");
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
let selected_core_ids: Vec<String> = selected_wire_ids
.iter()
.map(|wire| {
resolver
.owned_id_for_wire(*wire)
.expect("wire mapping persisted")
})
.collect();
let squad = json!({
"squad": selected_wire_ids
.iter()
.enumerate()
.map(|(index, id)| json!({
"index": index,
"itemData": { "id": id },
"kitNumber": 0
}))
.collect::<Vec<_>>()
});
let squad_bytes = serde_json::to_vec(&squad).unwrap();
assert_eq!(
server
.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/201/squad",
&[],
&squad_bytes,
)
.status,
200
);
let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/201", &[], br#"{}"#);
assert_eq!(submitted.status, 200);
let submitted: Value = serde_json::from_slice(&submitted.body).unwrap();
assert_eq!(submitted["challengeId"], 201);
assert_eq!(submitted["credits"], 102_750);
assert_eq!(submitted["recoveredPacks"], 1);
(cfg, selected_core_ids, selected_wire_ids)
})
.await
.expect("initial complete-host phase");
h1.abort();
let _ = h1.await;
let (h2, base2) = start_core_seeded(&db_url, false).await;
cfg.core_url = base2.clone();
let selected_core_ids_check = selected_core_ids.clone();
let selected_wire_ids_check = selected_wire_ids.clone();
tokio::task::spawn_blocking(move || {
let server = Server::from_config(&cfg).expect("restarted complete host");
let client = HttpCoreClient::new(&base2, "fifa17");
assert_eq!(client.balance().unwrap(), 102_750);
assert_eq!(client.entitlements().unwrap().len(), 1);
assert_eq!(
client
.sbc_completion_counts()
.unwrap()
.get("sbc_hybrid_nations")
.copied(),
Some(1)
);
let owned = client.all_owned().unwrap();
assert!(selected_core_ids_check
.iter()
.all(|id| { owned.iter().all(|item| item.owned_card_id != *id) }));
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
let club: Value = serde_json::from_slice(&club.body).unwrap();
assert!(selected_wire_ids_check.iter().all(|id| {
club["itemData"]
.as_array()
.unwrap()
.iter()
.all(|item| item["id"].as_i64() != Some(*id))
}));
let saved = server.handle("GET", "/ut/game/fifa17/sbs/challenge/201/squad", &[], b"");
let saved: Value = serde_json::from_slice(&saved.body).unwrap();
assert!(saved["squad"].as_array().unwrap().is_empty());
let purchased = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
let purchased: Value = serde_json::from_slice(&purchased.body).unwrap();
assert!(purchased["itemData"].as_array().unwrap().is_empty());
for path in ["/ut/game/fifa17/tradePile", "/ut/game/fifa17/watchList"] {
let pile = server.handle("GET", path, &[], b"");
let pile: Value = serde_json::from_slice(&pile.body).unwrap();
assert!(pile["auctionInfo"]
.as_array()
.unwrap()
.iter()
.all(|auction| {
selected_wire_ids_check
.iter()
.all(|id| auction["itemData"]["id"].as_i64() != Some(*id))
}));
}
let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b"");
let active: Value = serde_json::from_slice(&active.body).unwrap();
assert!(selected_wire_ids_check.iter().all(|id| {
active["players"].as_array().is_none_or(|players| {
players
.iter()
.all(|slot| slot["itemData"]["id"].as_i64() != Some(*id))
})
}));
let replay_body = json!({
"squad": selected_wire_ids_check
.iter()
.map(|id| json!({ "itemData": { "id": id } }))
.collect::<Vec<_>>()
});
assert_eq!(
server
.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/201",
&[],
&serde_json::to_vec(&replay_body).unwrap(),
)
.status,
404,
"host rejects consumed wire ids before a duplicate effect"
);
assert!(matches!(
client.submit_sbc("sbc_hybrid_nations", &selected_core_ids_check),
Err(openfut_utas_host::CoreError::Status(409))
));
assert_eq!(client.balance().unwrap(), 102_750);
assert_eq!(client.entitlements().unwrap().len(), 1);
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
.expect("restart catalog");
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("restart identity");
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
for (wire, core_id) in selected_wire_ids_check.iter().zip(&selected_core_ids_check) {
assert_eq!(
resolver.owned_id_for_wire(*wire).as_deref(),
Some(core_id.as_str())
);
}
})
.await
.expect("restart verification");
h2.abort();
let _ = h2.await;
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
// stale reader), through the REAL handle_with_ip dispatch ──────
/// A mock Python UTAS upstream that COUNTS every request it receives and always
/// answers with a distinctive marker body carrying coins=111. If an economy
/// route ever reaches Python, this counter moves and/or the marker leaks.
struct MockPython {
calls: Arc<std::sync::atomic::AtomicUsize>,
url: String,
}
fn start_mock_python() -> MockPython {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let c2 = calls.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
c2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut buf = [0u8; 8192];
let _ = s.read(&mut buf);
let body = br#"{"__python__":true,"credits":111,"currencies":[{"name":"coins","funds":111,"finalFunds":111}],"userInfo":{"currencies":[{"name":"coins","funds":111,"finalFunds":111}]},"purchase":[]}"#;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = s.write_all(head.as_bytes());
let _ = s.write_all(body);
}
});
MockPython {
calls,
url: format!("http://{addr}"),
}
}
/// The pure economy routes (userMassInfo excluded — it is the documented hybrid
/// that proxies the Python envelope but Rust-overlays the economy fields).
fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
vec![
("GET", "/ut/game/fifa17/user/credits".into(), b"".to_vec()),
(
"GET",
"/ut/game/fifa17/store/purchasegroup".into(),
b"".to_vec(),
),
(
"PUT",
"/ut/game/fifa17/store/transaction".into(),
br#"{"packId":1}"#.to_vec(),
),
(
"POST",
"/ut/game/fifa17/purchased".into(),
br#"{"packId":70}"#.to_vec(),
),
("GET", "/ut/game/fifa17/purchased".into(), b"".to_vec()),
(
"DELETE",
"/ut/game/fifa17/item/100000001".into(),
b"".to_vec(),
),
(
"POST",
"/ut/delete/game/fifa17/item".into(),
br#"{"itemData":[{"id":100000001}]}"#.to_vec(),
),
(
"PUT",
"/ut/game/fifa17/item".into(),
br#"{"itemData":[{"id":100000001,"pile":"trade"}]}"#.to_vec(),
),
(
"POST",
"/ut/delete/game/fifa17/match".into(),
br#"{"endReason":"WIN"}"#.to_vec(),
),
(
"POST",
"/ut/game/fifa17/auctionhouse".into(),
br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"#
.to_vec(),
),
("GET", "/ut/game/fifa17/tradePile".into(), b"".to_vec()),
(
"POST",
"/ut/game/fifa17/trade/900000001".into(),
b"{}".to_vec(),
),
(
"DELETE",
"/ut/delete/game/fifa17/trade/900000001".into(),
b"".to_vec(),
),
// ── Retail v2 Store family (the S2 live-failure shapes). These MUST be
// Rust-owned exactly like their v1 forms. ──
(
"PUT",
"/ut/v2/game/fifa17/store/transaction/0".into(),
br#"{"packId":1}"#.to_vec(),
),
(
"GET",
"/ut/v2/game/fifa17/store/purchasegroup".into(),
b"".to_vec(),
),
(
"POST",
"/ut/v2/game/fifa17/purchased".into(),
br#"{"packId":70}"#.to_vec(),
),
("GET", "/ut/v2/game/fifa17/purchased".into(), b"".to_vec()),
// ── Round-2 retail shapes: the confirmed BUY uses POST /purchased/items,
// reveal GET /purchased/items; hub tile polls lowercase tradepile + /counts. ──
(
"POST",
"/ut/game/fifa17/purchased/items".into(),
br#"{"packId":1}"#.to_vec(),
),
(
"GET",
"/ut/game/fifa17/purchased/items".into(),
b"".to_vec(),
),
("GET", "/ut/game/fifa17/tradepile".into(), b"".to_vec()),
(
"GET",
"/ut/game/fifa17/tradePile/counts".into(),
b"".to_vec(),
),
// ── FIFA 17 SBC family. Reads, tag acknowledgement, durable squad
// saves, challenge start, and submission are all Rust-owned. ──
("GET", "/ut/game/fifa17/sbs/sets".into(), b"".to_vec()),
(
"GET",
"/ut/game/fifa17/sbs/setId/1/challenges".into(),
b"".to_vec(),
),
(
"GET",
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
b"".to_vec(),
),
("PUT", "/ut/game/fifa17/sbs/sets/tag".into(), b"{}".to_vec()),
(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
br#"{"squad":[]}"#.to_vec(),
),
(
"POST",
"/ut/game/fifa17/sbs/challenge/101".into(),
b"".to_vec(),
),
(
"PUT",
"/ut/game/fifa17/sbs/challenge/101".into(),
br#"{"squad":[]}"#.to_vec(),
),
]
}
fn barrier_checks(base: &str, dir: &std::path::Path, mock: &MockPython) {
wait_ready(base);
let (server, client, _r, _sample) = build_econ_server(base, dir, &mock.url);
let core_coins = client.balance().unwrap();
assert_ne!(
core_coins, 111,
"Core must diverge from the Python marker (111)"
);
// ── STALE READER: readers show Core values, never the Python 111 ──
let cr = server.handle_with_ip("GET", "/ut/game/fifa17/user/credits", &[], b"", None);
let crv: Value = serde_json::from_slice(&cr.body).unwrap();
assert!(
crv.get("__python__").is_none(),
"credits is Rust, not the Python body"
);
assert_eq!(
crv["currencies"][0]["funds"], core_coins,
"credits coins = Core, not 111"
);
// userMassInfo is the hybrid: Python envelope proxied, economy Rust-overlaid.
let mi = server.handle_with_ip("GET", "/ut/game/fifa17/userMassInfo", &[], b"", None);
let miv: Value = serde_json::from_slice(&mi.body).unwrap();
assert_eq!(
miv["userInfo"]["currencies"][0]["funds"], core_coins,
"userMassInfo coins overlaid to Core (stale Python 111 not visible)"
);
// ── PART 7 REPRO: the exact S2 live-failure shape (retail v2 Store BUY,
// `PUT /ut/v2/game/fifa17/store/transaction/0`) is now Rust-owned — it
// debits Core and returns a `createPackResponse`, NOT the Python
// `{"state":"TRANSACTIONCANCEL"}` no-op the rejected candidate produced. ──
let before_buy = client.balance().unwrap();
let calls_before_buy = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
let buy = server.handle_with_ip(
"PUT",
"/ut/v2/game/fifa17/store/transaction/0",
&[],
br#"{"packId":1}"#,
None,
);
assert_eq!(buy.status, 200, "v2 Store BUY handled by Rust (200)");
let buyv: Value = serde_json::from_slice(&buy.body).unwrap();
assert!(
buyv.get("createPackResponse").is_some(),
"v2 Store BUY returns a Rust createPackResponse, not the Python no-op: {buyv}"
);
assert_ne!(
buyv.get("state").and_then(|s| s.as_str()),
Some("TRANSACTIONCANCEL"),
"v2 Store BUY must NOT be the Python TRANSACTIONCANCEL fallback"
);
assert_eq!(
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
calls_before_buy,
"v2 Store BUY never reached the Python proxy"
);
let after_buy = client.balance().unwrap();
assert!(
after_buy < before_buy,
"v2 Store BUY debited Core coins ({before_buy} -> {after_buy})"
);
// ── NEVER BOTH (Core up): pure economy routes reach Rust, never Python ──
let before = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
for (m, p, b) in pure_economy_routes() {
let r = server.handle_with_ip(m, &p, &[], &b, None);
assert!(
!r.body.windows(10).any(|w| w == b"__python__"),
"{m} {p} must be Rust-owned (no Python marker in body)"
);
}
assert_eq!(
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
before,
"NEVER BOTH: no pure economy route reached the Python proxy"
);
// ── NO FALLBACK: a server pointed at a DEAD Core still fails closed and
// never proxies to Python. Built without probing Core (empty catalog +
// empty pool), so no live Core is needed to construct it. ──
let dead_dir = dir.join("dead");
std::fs::create_dir_all(&dead_dir).unwrap();
let dead = build_dead_core_server(&dead_dir, &mock.url);
let before_down = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
let credits_down = dead.handle_with_ip("GET", "/ut/game/fifa17/user/credits", &[], b"", None);
assert_eq!(
credits_down.status, 503,
"credits fails closed against a dead Core"
);
let match_down = dead.handle_with_ip(
"POST",
"/ut/delete/game/fifa17/match",
&[],
br#"{"endReason":"WIN"}"#,
None,
);
assert_eq!(
match_down.status, 503,
"match fails closed against a dead Core"
);
for (m, p, b) in pure_economy_routes() {
let _ = dead.handle_with_ip(m, &p, &[], &b, None);
}
assert_eq!(
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
before_down,
"NO FALLBACK: economy routes never proxy to Python even against a dead Core"
);
}
/// A `Server` whose Core (read + economy) points at a definitely-dead loopback
/// port, wired WITHOUT probing Core: an empty catalog + empty content pool. Used
/// to prove economy routes fail closed (503) and never fall back to Python.
fn build_dead_core_server(dir: &std::path::Path, pass_url: &str) -> Server {
// A closed loopback port: bind then drop, so connects are refused.
let dead_addr = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap()
};
let dead_url = format!("http://{dead_addr}");
let catalog =
Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa17","cards":{}}"#)
.unwrap();
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(dead_url.clone(), "fifa17"));
let bridge = Arc::new(AsyncBridge::new().unwrap());
let mp = dir.join("market.db").to_string_lossy().into_owned();
let market = Arc::new(
bridge
.block_on(async move { MarketStore::open(&mp).await })
.unwrap(),
);
let pp = dir.join("pile.db").to_string_lossy().into_owned();
let piles = Arc::new(
bridge
.block_on(async move { PileStore::open(&pp).await })
.unwrap(),
);
let econ: Arc<dyn CoreEconomy> = Arc::new(HttpCoreClient::new(dead_url, "fifa17"));
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: Arc::new(Vec::new()),
});
Server::new(
core,
entities,
resolver,
Arc::new(PassClient::new(pass_url)),
33_068_179,
)
.with_economy(services)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn barrier_never_both_no_fallback_and_stale_reader() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-barrier-{}-{}",
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());
let (h, base) = start_core_seeded(&db_url, true).await;
let mock = start_mock_python();
let (b, d) = (base.clone(), dir.clone());
tokio::task::spawn_blocking(move || {
std::thread::spawn(move || barrier_checks(&b, &d, &mock))
.join()
.expect("barrier checks thread")
})
.await
.expect("barrier phase");
h.abort();
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Retail v2 Store E2E + v1/v2 route equivalence ───────────────
//
// Proves the S2 fix end-to-end through the REAL dispatch: the Store flow driven
// over the retail `/ut/v2/game/<sku>/…` paths is Rust-owned (Python proxy count
// 0), mutates Core, and behaves IDENTICALLY to the v1 paths for the same op.
fn v2_store_flow(base: &str, dir: &std::path::Path) {
let mock = start_mock_python();
let (server, client, _r, _s) = build_econ_server(base, dir, &mock.url);
let calls0 = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
// GET purchasegroup via v2 → Rust catalogue (non-empty).
let pg = server.handle_with_ip(
"GET",
"/ut/v2/game/fifa17/store/purchasegroup",
&[],
b"",
None,
);
assert_eq!(pg.status, 200, "v2 purchasegroup handled by Rust");
let pgv: Value = serde_json::from_slice(&pg.body).unwrap();
assert!(
pgv.get("purchase")
.and_then(|p| p.as_array())
.is_some_and(|a| !a.is_empty()),
"v2 purchasegroup returns a Rust catalogue: {pgv}"
);
// Same pack (id 1) via v1 then v2 → IDENTICAL debit + item count (Part 6).
let bal0 = client.balance().unwrap();
let v1 = server.handle_with_ip(
"PUT",
"/ut/game/fifa17/store/transaction",
&[],
br#"{"packId":1}"#,
None,
);
assert_eq!(v1.status, 200);
let bal1 = client.balance().unwrap();
let v1v: Value = serde_json::from_slice(&v1.body).unwrap();
let v1_items = v1v["createPackResponse"]["itemList"]
.as_array()
.map_or(0, |a| a.len());
let v1_debit = bal0 - bal1;
let v2 = server.handle_with_ip(
"PUT",
"/ut/v2/game/fifa17/store/transaction/0",
&[],
br#"{"packId":1}"#,
None,
);
assert_eq!(v2.status, 200);
let bal2 = client.balance().unwrap();
let v2v: Value = serde_json::from_slice(&v2.body).unwrap();
let v2_items = v2v["createPackResponse"]["itemList"]
.as_array()
.map_or(0, |a| a.len());
let v2_debit = bal1 - bal2;
assert!(v1_items > 0, "v1 BUY minted items");
assert_eq!(v1_items, v2_items, "v1/v2 BUY yield identical item counts");
assert_eq!(
v1_debit, v2_debit,
"v1/v2 BUY debit identically ({v1_debit} vs {v2_debit})"
);
// GET purchased via v2 → Rust reveal shape; the just-bought items are in the pile.
let reveal = server.handle_with_ip("GET", "/ut/v2/game/fifa17/purchased", &[], b"", None);
assert_eq!(reveal.status, 200, "v2 GET /purchased handled by Rust");
let rv: Value = serde_json::from_slice(&reveal.body).unwrap();
assert!(
rv.get("itemData").and_then(|d| d.as_array()).is_some(),
"v2 reveal is a Rust itemData array: {rv}"
);
// NEVER any Python proxy for the whole v2 Store flow.
assert_eq!(
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
calls0,
"v2 Store flow never reached the Python proxy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn retail_v2_store_flow_matches_v1_through_dispatch() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-v2-{}-{}",
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());
let (h, base) = start_core_seeded(&db_url, true).await;
let (b, d) = (base.clone(), dir.clone());
tokio::task::spawn_blocking(move || {
std::thread::spawn(move || v2_store_flow(&b, &d))
.join()
.expect("v2 store flow thread")
})
.await
.expect("v2 store phase");
h.abort();
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Retail /purchased/items BUY sequence (round-2 S2 regression) ─
//
// The rejected candidate 47ced22 sent the confirmed retail Store BUY
// (POST /ut/game/fifa17/purchased/items) to Python and left Core coins unchanged.
// This replays the exact live sequence through real dispatch and asserts the BUY
// debits Core, the reveal shows the minted items, and Python proxy count is 0.
fn retail_purchased_items_flow(base: &str, dir: &std::path::Path) {
let mock = start_mock_python();
let (server, client, _r, _s) = build_econ_server(base, dir, &mock.url);
let calls0 = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
// Store screen catalogue (v1 /all) — Rust.
let pg = server.handle_with_ip(
"GET",
"/ut/game/fifa17/store/purchasegroup/all",
&[],
b"",
None,
);
assert_eq!(pg.status, 200, "purchasegroup/all Rust-owned");
// THE CONFIRMED RETAIL BUY: POST /ut/game/fifa17/purchased/items must debit Core.
let bal0 = client.balance().unwrap();
let buy = server.handle_with_ip(
"POST",
"/ut/game/fifa17/purchased/items",
&[],
br#"{"packId":1}"#,
None,
);
assert_eq!(buy.status, 200, "purchased/items BUY handled by Rust (200)");
assert!(
!buy.body.windows(10).any(|w| w == b"__python__"),
"purchased/items BUY is Rust-owned (no Python marker)"
);
let bal1 = client.balance().unwrap();
assert!(
bal1 < bal0,
"purchased/items BUY debited Core ({bal0} -> {bal1}) — the round-2 S2 was NO debit"
);
// Reveal poll: GET /ut/game/fifa17/purchased/items — Rust, shows the minted items.
let reveal = server.handle_with_ip("GET", "/ut/game/fifa17/purchased/items", &[], b"", None);
assert_eq!(reveal.status, 200, "purchased/items reveal Rust-owned");
let rv: Value = serde_json::from_slice(&reveal.body).unwrap();
let revealed = rv["itemData"].as_array().map_or(0, |a| a.len());
assert!(revealed > 0, "reveal shows the freshly-minted items: {rv}");
// Repeat reveal is idempotent (no re-grant, no extra debit).
let bal2 = client.balance().unwrap();
let _ = server.handle_with_ip("GET", "/ut/game/fifa17/purchased/items", &[], b"", None);
assert_eq!(
client.balance().unwrap(),
bal2,
"repeat reveal does not mutate coins"
);
// NEVER any Python proxy across the whole /purchased/items sequence.
assert_eq!(
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
calls0,
"retail /purchased/items sequence never reached the Python proxy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn retail_purchased_items_buy_debits_core_through_dispatch() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-pi-{}-{}",
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());
let (h, base) = start_core_seeded(&db_url, true).await;
let (b, d) = (base.clone(), dir.clone());
tokio::task::spawn_blocking(move || {
std::thread::spawn(move || retail_purchased_items_flow(&b, &d))
.join()
.expect("purchased/items flow thread")
})
.await
.expect("purchased/items phase");
h.abort();
std::fs::remove_dir_all(&dir).ok();
}