feat(fifa17): wire async economy handlers into the host via a runtime bridge (unrouted)

Bridges the synchronous thread-per-connection host to the async
transfer-market/pile handlers WITHOUT flipping the classifier. classify()
is untouched; production still proxies every economy route to Python. The
new dispatch is exercised only by the integration harness via
Server::try_handle_economy — handler wiring, not authority cutover.

async_bridge.rs: AsyncBridge owns ONE process-lifetime multi-threaded Tokio
runtime, shared by every connection via Arc. block_on() runs a future from
the sync dispatch thread; if invoked from within an ambient runtime it
offloads onto its own runtime + a std channel instead of panicking
("cannot start a runtime from within a runtime"). 4 unit tests incl. the
nested-runtime-safety case and concurrent multi-thread drivers.

lib.rs: EconomyRoute + classify_economy (mirrors the Python route table:
credits, purchasegroup, store/transaction, purchased, item DELETE/PUT,
ut/delete match/item/trade, auctionhouse/transfermarket, tradePile, trade).
EconomyServices (Core econ transport + durable MarketStore/PileStore + the
bridge + the pack-content pool), attached via Server::with_economy (kept out
of `new`/`from_config` so existing tests build a DB-less Server; production
from_config attachment is the barrier step). Server::try_handle_economy
dispatches: sync handlers (credits/purchasegroup/store-buy/pack-open/
quick-sell/match) inline; async handlers (market list/query/buy/cancel,
move) on the bridge via owned `async move` blocks. build_content_pool
derives the resolvable FIFA∩Core candidate pool from Core content.

market.rs: FIX the load-bearing hazard the FakeEconomy tests missed — the
async market handlers call the BLOCKING reqwest Core client, which panics
(reqwest::blocking::wait::enter) when run while a Tokio runtime is entered.
off_runtime() hops each Core call to a fresh OS thread with no runtime
entered, so blocking is legal. handle_move_items resolver gains `+ Sync`
(future must be Send for the bridge).

tests/economy_integration.rs: economy_full_sequence_through_dispatch drives
the WHOLE cluster through the REAL Server dispatch + bridge against a live
in-process Core (seeded with fifa17 dev content: 100k coins + owned cards),
on a plain OS thread (direct bridge path), over the real blocking
HttpCoreClient — no fakes: Store BUY (pool draw + shape + debit 400 + mint 5),
credits, quick-sell (reverse-resolve + credit), match WIN (+400), market
list->query->buy->query(sold)->second-buy-fails(no double debit), cancel
(cancelled not buyable), move-items, then reopen the durable market/pile
stores from disk (sold + pile persist). Deterministic. start_core_seeded
loads fifa17 dev content so /collection renders real definitions.

Tests: host 68 lib (+4 bridge) + 2 economy_integration + 24 host_test, all
green; adapter unchanged-green; clippy -D warnings + fmt clean.
This commit is contained in:
OpenFUT Agent
2026-08-13 21:30:10 +00:00
parent 0e2ca5a7c3
commit 580d80a86e
5 changed files with 898 additions and 6 deletions
+19 -4
View File
@@ -102,11 +102,26 @@ fn auction_record(l: &Listing) -> Value {
})
}
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
/// has NO Tokio runtime entered, then block until it finishes. The market
/// handlers are `async` and driven on the shared runtime by the host bridge, but
/// `CoreEconomy` is a `reqwest::blocking` client: calling it while a runtime is
/// entered panics (`reqwest::blocking::wait::enter`). Hopping to a plain thread
/// keeps the Core call off the runtime, so blocking is legal. Cheap relative to
/// the network round-trip it guards.
fn off_runtime<T, F>(f: F) -> T
where
F: FnOnce() -> T + Send,
T: Send,
{
std::thread::scope(|s| s.spawn(f).join().expect("off-runtime Core call panicked"))
}
/// Current Core balance as the `credits` field, or 0 if Core is unreachable
/// (used only to decorate an already-decided response; the transactional path
/// never trusts a fabricated balance).
fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 {
econ.balance().unwrap_or(0)
off_runtime(|| econ.balance()).unwrap_or(0)
}
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
@@ -280,7 +295,7 @@ pub async fn handle_market_buy(
// Pre-check funds so an affordability failure is a clean 461, distinct from a
// Core transport failure (503). Core's purchase is still the atomic authority.
let balance = match econ.balance() {
let balance = match off_runtime(|| econ.balance()) {
Ok(b) => b,
Err(_) => {
let _ = store.rollback_reservation(&id).await;
@@ -298,7 +313,7 @@ pub async fn handle_market_buy(
// Mint the won card into the buyer's club. A listing sells at most once (the
// CAS guarantees it), so a deterministic minted id is safe and idempotent.
let minted_item_id = format!("market-buy:{id}");
match econ.purchase_item(price, &minted_item_id, &listing.card_id) {
match off_runtime(|| econ.purchase_item(price, &minted_item_id, &listing.card_id)) {
Ok(new_balance) => {
// Core has taken the coins and minted the item; finalise the listing.
// If completing fails, Core is still authoritative — report success.
@@ -336,7 +351,7 @@ pub async fn handle_market_buy(
/// never a fabricated move.
pub async fn handle_move_items(
body: &[u8],
resolver: &dyn SquadWireResolver,
resolver: &(dyn SquadWireResolver + Sync),
pile_store: &PileStore,
) -> WireResponse {
let b = parse_body(body);